Created
August 19, 2024 17:48
-
-
Save aleclarson/55a580acfa5ba7817d62fb8c9e307228 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { LeastRecentlyUsedCache } from './lru' | |
const cache = new LeastRecentlyUsedCache<string, number>(500) | |
const canvasElem = document.createElement('canvas') | |
canvasElem.setAttribute( | |
'style', | |
'position: absolute; top: -2000px; left: -2000px; overflow: hidden; clip: rect(0 0 0 0); z-index: -100', | |
) | |
document.body.appendChild(canvasElem) | |
const normalizeFontWeight = (weight: string | number | undefined) => | |
weight == null || weight == 'normal' ? 400 : weight == 'bold' ? 700 : +weight | |
const toCacheKey = ( | |
word: string, | |
fontFamily: string, | |
fontSize: number, | |
fontWeight: string | number | undefined, | |
) => `${fontFamily};${fontSize};${normalizeFontWeight(fontWeight)};${word}` | |
export interface TextStyle { | |
fontFamily: string | |
fontSize: number | |
fontWeight?: string | number | |
letterSpacing?: number | |
} | |
export type MeasuredText = ReturnType<typeof measureText> | |
export function measureText( | |
text: string, | |
style: TextStyle, | |
maxLineWidth = Infinity, | |
firstLineMaxWidth = maxLineWidth, | |
) { | |
const words = text.split(/([ \-\n])/) | |
const wordWidths: number[] = [] | |
const lines: string[] = [] | |
const lineWidths: number[] = [] | |
const removeTrailingSpace = (line: number) => { | |
const text = lines[line] | |
if (text && text[text.length - 1] == ' ') { | |
lines[line] = text.slice(0, -1) | |
lineWidths[line] -= measureWord(' ', style) | |
} | |
} | |
let currentLine = '' | |
let currentLineWidth = 0 | |
let currentLineMaxWidth = firstLineMaxWidth | |
const letterSpacing = style.letterSpacing || 0 | |
const getNextLineWidth = (word: string, width: number) => { | |
return currentLineWidth + width + letterSpacing * (currentLine.length + word.length) | |
} | |
const finishLine = () => { | |
lines.push(currentLine) | |
lineWidths.push(getNextLineWidth('', 0)) | |
removeTrailingSpace(lines.length - 1) | |
} | |
for (let i = 0; i < words.length; i++) { | |
let word = words[i] | |
if (word == '\n') { | |
finishLine() | |
currentLine = '' | |
currentLineWidth = 0 | |
continue | |
} | |
let width = measureWord(word, style) | |
wordWidths[i] = width | |
if (getNextLineWidth(word, width) > currentLineMaxWidth) { | |
// If a word is longer than the maximum line width, split it into | |
// multiple lines. | |
if (currentLineWidth == 0 || width > currentLineMaxWidth) { | |
const chars = word.split('') | |
const charWidths = chars.map(char => { | |
return measureWord(char, style) | |
}) | |
for (let i = 0; i < chars.length; i++) { | |
const char = chars[i] | |
const charWidth = charWidths[i] | |
if (getNextLineWidth(char, charWidth) > currentLineMaxWidth) { | |
finishLine() | |
if (i == 0) { | |
currentLine = char | |
currentLineWidth = charWidth | |
} else { | |
const prevChar = chars[i - 1] | |
const prevCharWidth = charWidths[i - 1] | |
// Replace previous char with a hyphen. | |
lines[lines.length - 1] = lines.at(-1)!.slice(0, -1) + '-' | |
lineWidths[lineWidths.length - 1] += -prevCharWidth + measureWord('-', style) | |
// Move previous char and current char to next line. | |
currentLine = prevChar + char | |
currentLineWidth = prevCharWidth + charWidth | |
} | |
} else { | |
currentLine += char | |
currentLineWidth += charWidth | |
} | |
} | |
} else { | |
if (word != ' ') { | |
i-- // Add to next line, unless this is a space. | |
} | |
finishLine() | |
currentLine = '' | |
currentLineWidth = 0 | |
currentLineMaxWidth = maxLineWidth | |
} | |
} else { | |
currentLine += word | |
currentLineWidth += width | |
} | |
} | |
if (currentLineWidth > 0) { | |
finishLine() | |
} | |
return { words, wordWidths, lines, lineWidths } | |
} | |
export function measureWord(word: string, { fontFamily, fontSize, fontWeight }: TextStyle): number { | |
if (!word.length) { | |
return 0 | |
} | |
// fontSize = | |
// Math.round(fontSize * window.devicePixelRatio) / window.devicePixelRatio | |
const key = toCacheKey(word, fontFamily, fontSize, fontWeight) | |
const cached = cache.get(key) | |
if (cached != null) { | |
return cached | |
} | |
const ctx = canvasElem.getContext('2d')! | |
ctx.font = `${fontWeight} ${fontSize}px "${fontFamily}"` | |
const { width } = ctx.measureText(word) | |
cache.set(key, width) | |
return width | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment