import { isDevEnv, isTestEnv } from "@excalidraw/common"; import { charWidth, getLineWidth } from "./textMeasurements"; import type { FontString } from "./types"; /** * This module approximates browser-like soft wrapping for Excalidraw text. * * The flow is: * 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex. * 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and * records where each visual line came from in the source text. * 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token * that is wider than the available width. * 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing * whitespace so the rendered text stays consistent with what users see on canvas. * * Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers * that need metadata such as mapping visual lines back to `originalText` * for caret placement or future editor features. */ let cachedCjkRegex: RegExp | undefined; let cachedLineBreakRegex: RegExp | undefined; let cachedEmojiRegex: RegExp | undefined; /** * Test if a given text contains any CJK characters (including symbols, punctuation, etc,). */ export const containsCJK = (text: string) => { if (!cachedCjkRegex) { cachedCjkRegex = Regex.class(...Object.values(CJK)); } return cachedCjkRegex.test(text); }; const getLineBreakRegex = () => { if (!cachedLineBreakRegex) { try { cachedLineBreakRegex = getLineBreakRegexAdvanced(); } catch { cachedLineBreakRegex = getLineBreakRegexSimple(); } } return cachedLineBreakRegex; }; const getEmojiRegex = () => { if (!cachedEmojiRegex) { cachedEmojiRegex = getEmojiRegexUnicode(); } return cachedEmojiRegex; }; /** * Common symbols used across different languages. */ const COMMON = { /** * Natural breaking points for any grammars. * * Hello world * ↑ BREAK ALWAYS " " → ["Hello", " ", "world"] * Hello-world * ↑ BREAK AFTER "-" → ["Hello-", "world"] */ WHITESPACE: /\s/u, HYPHEN: /-/u, /** * Generally do not break, unless closed symbol is followed by an opening symbol. * * Also, western punctation is often used in modern Korean and expects to be treated * similarly to the CJK opening and closing symbols. * * Hello(한글)→ ["Hello", "(한", "글)"] * ↑ BREAK BEFORE "(" * ↑ BREAK AFTER ")" */ OPENING: /<\(\[\{/u, CLOSING: />\)\]\}.,:;!\?…\//u, }; /** * Characters and symbols used in Chinese, Japanese and Korean. */ const CJK = { /** * Every CJK breaks before and after, unless it's paired with an opening or closing symbol. * * Does not include every possible char used in CJK texts, such as currency, parentheses or punctuation. */ CHAR: /\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}`'^〃〰〆#&*+-ー/\=|¦〒¬ ̄/u, /** * Opening and closing CJK punctuation breaks before and after all such characters (in case of many), * and creates pairs with neighboring characters. * * Hello た。→ ["Hello", "た。"] * ↑ DON'T BREAK "た。" * * Hello「た」 World → ["Hello", "「た」", "World"] * ↑ DON'T BREAK "「た" * ↑ DON'T BREAK "た" * ↑ BREAK BEFORE "「" * ↑ BREAK AFTER "」" */ // eslint-disable-next-line prettier/prettier OPENING:/([{〈《⦅「「『【〖〔〘〚<〝/u, CLOSING: /)]}〉》⦆」」』】〗〕〙〛>。.,、〟‥?!:;・〜〞/u, /** * Currency symbols break before, not after * * Price¥100 → ["Price", "¥100"] * ↑ BREAK BEFORE "¥" */ CURRENCY: /¥₩£¢$/u, }; const EMOJI = { FLAG: /\p{RI}\p{RI}/u, JOINER: /(?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?/u, ZWJ: /\u200D/u, ANY: /[\p{Emoji}]/u, MOST: /[\p{Extended_Pictographic}\p{Emoji_Presentation}]/u, }; /** * Simple fallback for browsers (mainly Safari < 16.4) that don't support "Lookbehind assertion". * * Browser support as of 10/2024: * - 91% Lookbehind assertion https://caniuse.com/mdn-javascript_regular_expressions_lookbehind_assertion * - 94% Unicode character class escape https://caniuse.com/mdn-javascript_regular_expressions_unicode_character_class_escape * * Does not include advanced CJK breaking rules, but covers most of the core cases, especially for latin. */ const getLineBreakRegexSimple = () => Regex.or( getEmojiRegex(), Break.On(COMMON.HYPHEN, COMMON.WHITESPACE, CJK.CHAR), ); /** * Specifies the line breaking rules based for alphabetic-based languages, * Chinese, Japanese, Korean and Emojis. * * "Hello-world" → ["Hello-", "world"] * "Hello 「世界。」🌎🗺" → ["Hello", " ", "「世", "界。」", "🌎", "🗺"] */ const getLineBreakRegexAdvanced = () => Regex.or( // Unicode-defined regex for (multi-codepoint) Emojis getEmojiRegex(), // Rules for whitespace and hyphen Break.Before(COMMON.WHITESPACE).Build(), Break.After(COMMON.WHITESPACE, COMMON.HYPHEN).Build(), // Rules for CJK (chars, symbols, currency) Break.Before(CJK.CHAR, CJK.CURRENCY) .NotPrecededBy(COMMON.OPENING, CJK.OPENING) .Build(), Break.After(CJK.CHAR) .NotFollowedBy(COMMON.HYPHEN, COMMON.CLOSING, CJK.CLOSING) .Build(), // Rules for opening and closing punctuation Break.BeforeMany(CJK.OPENING).NotPrecededBy(COMMON.OPENING).Build(), Break.AfterMany(CJK.CLOSING).NotFollowedBy(COMMON.CLOSING).Build(), Break.AfterMany(COMMON.CLOSING).FollowedBy(COMMON.OPENING).Build(), ); /** * Matches various emoji types. * * 1. basic emojis (😀, 🌍) * 2. flags (🇨🇿) * 3. multi-codepoint emojis: * - skin tones (👍🏽) * - variation selectors (☂️) * - keycaps (1️⃣) * - tag sequences (🏴󠁧󠁢󠁥󠁮󠁧󠁿) * - emoji sequences (👨‍👩‍👧‍👦, 👩‍🚀, 🏳️‍🌈) * * Unicode points: * - \uFE0F: presentation selector * - \u20E3: enclosing keycap * - \u200D: zero width joiner * - \u{E0020}-\u{E007E}: tags * - \u{E007F}: cancel tag * * @see https://unicode.org/reports/tr51/#EBNF_and_Regex, with changes: * - replaced \p{Emoji} with [\p{Extended_Pictographic}\p{Emoji_Presentation}], see more in `should tokenize emojis mixed with mixed text` test * - replaced \p{Emod} with \p{Emoji_Modifier} as some engines do not understand the abbreviation (i.e. https://devina.io/redos-checker) */ const getEmojiRegexUnicode = () => Regex.group( Regex.or( EMOJI.FLAG, Regex.and( EMOJI.MOST, EMOJI.JOINER, Regex.build( `(?:${EMOJI.ZWJ.source}(?:${EMOJI.FLAG.source}|${EMOJI.ANY.source}${EMOJI.JOINER.source}))*`, ), ), ), ); /** * Regex utilities for unicode character classes. */ const Regex = { /** * Builds a regex from a string. */ build: (regex: string): RegExp => new RegExp(regex, "u"), /** * Joins regexes into a single string. */ join: (...regexes: RegExp[]): string => regexes.map((x) => x.source).join(""), /** * Joins regexes into a single regex as with "and" operator. */ and: (...regexes: RegExp[]): RegExp => Regex.build(Regex.join(...regexes)), /** * Joins regexes into a single regex with "or" operator. */ or: (...regexes: RegExp[]): RegExp => Regex.build(regexes.map((x) => x.source).join("|")), /** * Puts regexes into a matching group. */ group: (...regexes: RegExp[]): RegExp => Regex.build(`(${Regex.join(...regexes)})`), /** * Puts regexes into a character class. */ class: (...regexes: RegExp[]): RegExp => Regex.build(`[${Regex.join(...regexes)}]`), }; /** * Human-readable lookahead and lookbehind utilities for defining line break * opportunities between pairs of character classes. */ const Break = { /** * Break on the given class of characters. */ On: (...regexes: RegExp[]) => { const joined = Regex.join(...regexes); return Regex.build(`([${joined}])`); }, /** * Break before the given class of characters. */ Before: (...regexes: RegExp[]) => { const joined = Regex.join(...regexes); const builder = () => Regex.build(`(?=[${joined}])`); return Break.Chain(builder) as Omit< ReturnType, "FollowedBy" >; }, /** * Break after the given class of characters. */ After: (...regexes: RegExp[]) => { const joined = Regex.join(...regexes); const builder = () => Regex.build(`(?<=[${joined}])`); return Break.Chain(builder) as Omit< ReturnType, "PreceededBy" >; }, /** * Break before one or multiple characters of the same class. */ BeforeMany: (...regexes: RegExp[]) => { const joined = Regex.join(...regexes); const builder = () => Regex.build(`(?, "FollowedBy" >; }, /** * Break after one or multiple character from the same class. */ AfterMany: (...regexes: RegExp[]) => { const joined = Regex.join(...regexes); const builder = () => Regex.build(`(?<=[${joined}])(?![${joined}])`); return Break.Chain(builder) as Omit< ReturnType, "PreceededBy" >; }, /** * Do not break before the given class of characters. */ NotBefore: (...regexes: RegExp[]) => { const joined = Regex.join(...regexes); const builder = () => Regex.build(`(?![${joined}])`); return Break.Chain(builder) as Omit< ReturnType, "NotFollowedBy" >; }, /** * Do not break after the given class of characters. */ NotAfter: (...regexes: RegExp[]) => { const joined = Regex.join(...regexes); const builder = () => Regex.build(`(?, "NotPrecededBy" >; }, Chain: (rootBuilder: () => RegExp) => ({ /** * Build the root regex. */ Build: rootBuilder, /** * Specify additional class of characters that should precede the root regex. */ PreceededBy: (...regexes: RegExp[]) => { const root = rootBuilder(); const preceeded = Break.After(...regexes).Build(); const builder = () => Regex.and(preceeded, root); return Break.Chain(builder) as Omit< ReturnType, "PreceededBy" >; }, /** * Specify additional class of characters that should follow the root regex. */ FollowedBy: (...regexes: RegExp[]) => { const root = rootBuilder(); const followed = Break.Before(...regexes).Build(); const builder = () => Regex.and(root, followed); return Break.Chain(builder) as Omit< ReturnType, "FollowedBy" >; }, /** * Specify additional class of characters that should not precede the root regex. */ NotPrecededBy: (...regexes: RegExp[]) => { const root = rootBuilder(); const notPreceeded = Break.NotAfter(...regexes).Build(); const builder = () => Regex.and(notPreceeded, root); return Break.Chain(builder) as Omit< ReturnType, "NotPrecededBy" >; }, /** * Specify additional class of characters that should not follow the root regex. */ NotFollowedBy: (...regexes: RegExp[]) => { const root = rootBuilder(); const notFollowed = Break.NotBefore(...regexes).Build(); const builder = () => Regex.and(root, notFollowed); return Break.Chain(builder) as Omit< ReturnType, "NotFollowedBy" >; }, }), }; /** * Breaks the line into the tokens based on the found line break opporutnities. * * Note: tokenization normalizes to NFC first so decomposed graphemes are treated as * their composed variants for wrapping. Any code that needs exact source offsets should * keep in mind that this assumes the input text is already NFC-normalized. */ export const parseTokens = (line: string) => { const breakLineRegex = getLineBreakRegex(); // normalizing to single-codepoint composed chars due to canonical equivalence // of multi-codepoint versions for chars like č, で (~ so that we don't break a line in between c and ˇ) // filtering due to multi-codepoint chars like 👨‍👩‍👧‍👦, 👩🏽‍🦰 return line.normalize("NFC").split(breakLineRegex).filter(Boolean); }; /** * Wraps the original text into the lines based on the given width. * * This is a convenience adapter over `getWrappedTextLines()` for call sites * that only need the rendered wrapped string and not the source offsets. */ export const wrapText = ( text: string, font: FontString, maxWidth: number, ): string => { return getWrappedTextLines(text, font, maxWidth) .map((line) => line.text) .join("\n"); }; /** * A single rendered visual line produced from the original text. * * `start` and `end` are end-exclusive code-unit offsets into the original text, and do * not include synthetic soft line breaks inserted by this module. If trailing whitespace * was trimmed away at a wrap boundary, `end` points to the last rendered character. */ export type WrappedTextLine = { text: string; start: number; end: number; }; /** * Splits only on existing hard line breaks and preserves original offsets. */ const getHardLineBreaks = (text: string): WrappedTextLine[] => { let offset = 0; return text.split("\n").map((line) => { const start = offset; const end = start + line.length; offset = end + 1; return { text: line, start, end, }; }); }; /** * Returns the rendered visual lines together with their source offsets. * * This is the source-of-truth wrapping pipeline for callers that need more than the * final wrapped string, for example caret placement or future editor/rich-text mapping. */ export const getWrappedTextLines = ( text: string, font: FontString, maxWidth: number, ): WrappedTextLine[] => { // if maxWidth is not finite or NaN which can happen in case of bugs in // computation, we need to make sure we don't continue as we'll end up // in an infinite loop if (!Number.isFinite(maxWidth) || maxWidth < 0) { return getHardLineBreaks(text); } const lines: WrappedTextLine[] = []; let offset = 0; for (const originalLine of text.split("\n")) { const originalLineWidth = getLineWidth(originalLine, font); if (originalLineWidth <= maxWidth) { lines.push({ text: originalLine, start: offset, end: offset + originalLine.length, }); } else { lines.push(...wrapLine(originalLine, font, maxWidth, offset)); } offset += originalLine.length + 1; } return lines; }; /** * Wraps a single hard line into one or more visual lines. * * The line-local offsets are tracked in original-text code units so * we can map the visual line back to the source. */ const wrapLine = ( line: string, font: FontString, maxWidth: number, lineStart: number, ): WrappedTextLine[] => { const lines: WrappedTextLine[] = []; const tokens = parseTokens(line); let currentLine = ""; let currentLineStart = lineStart; let currentLineEnd = lineStart; let currentLineWidth = 0; // Tracks the next token's code-unit position in the original source string. let tokenOffset = lineStart; let tokenIndex = 0; while (tokenIndex < tokens.length) { const token = tokens[tokenIndex]; const tokenStart = tokenOffset; const tokenEnd = tokenStart + token.length; const testLine = currentLine + token; // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here const testLineWidth = isSingleCharacter(token) ? currentLineWidth + charWidth.calculate(token, font) : getLineWidth(testLine, font); // build up the current line, skipping length check for possibly trailing whitespaces if (/\s/.test(token) || testLineWidth <= maxWidth) { if (!currentLine) { currentLineStart = tokenStart; } currentLine = testLine; currentLineEnd = tokenEnd; currentLineWidth = testLineWidth; tokenOffset = tokenEnd; tokenIndex++; continue; } // current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped if (!currentLine) { const wrappedWord = wrapWord(token, font, maxWidth, tokenStart); const trailingLine = wrappedWord[wrappedWord.length - 1] ?? { text: "", start: tokenStart, end: tokenStart, }; const precedingLines = wrappedWord.slice(0, -1); lines.push(...precedingLines); // trailing line of the wrapped word might still be joined with next token/s currentLine = trailingLine.text; currentLineStart = trailingLine.start; currentLineEnd = trailingLine.end; currentLineWidth = getLineWidth(trailingLine.text, font); tokenOffset = tokenEnd; tokenIndex++; } else { // push & reset, but don't iterate on the next token, as we didn't use it yet! lines.push( trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd), ); // purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above currentLine = ""; currentLineStart = tokenStart; currentLineEnd = tokenStart; currentLineWidth = 0; } } // iterator done, push the trailing line if exists if (currentLine) { const trailingLine = trimLine( currentLine, currentLineStart, currentLineEnd, font, maxWidth, ); lines.push(trailingLine); } return lines; }; /** * Wraps a single word that could not be placed on an empty line as-is. */ const wrapWord = ( word: string, font: FontString, maxWidth: number, wordStart: number, ): WrappedTextLine[] => { // multi-codepoint emojis are already broken apart and shouldn't be broken further if (getEmojiRegex().test(word)) { return [ { text: word, start: wordStart, end: wordStart + word.length, }, ]; } satisfiesWordInvariant(word); const lines: WrappedTextLine[] = []; const chars = Array.from(word); let currentLine = ""; let currentLineStart = wordStart; let currentLineEnd = wordStart; let currentLineWidth = 0; let offset = wordStart; for (const char of chars) { const charStart = offset; const charEnd = charStart + char.length; const _charWidth = charWidth.calculate(char, font); const testLineWidth = currentLineWidth + _charWidth; if (testLineWidth <= maxWidth) { if (!currentLine) { currentLineStart = charStart; } currentLine = currentLine + char; currentLineEnd = charEnd; currentLineWidth = testLineWidth; offset = charEnd; continue; } if (currentLine) { lines.push({ text: currentLine, start: currentLineStart, end: currentLineEnd, }); } currentLine = char; currentLineStart = charStart; currentLineEnd = charEnd; currentLineWidth = _charWidth; offset = charEnd; } if (currentLine) { lines.push({ text: currentLine, start: currentLineStart, end: currentLineEnd, }); } return lines; }; /** * Trims trailing whitespace that is exceeding the `maxWidth`. * * Used for the trailing visual line of a hard line, where some trailing * whitespace may still be visible if it fits into the available width. */ const trimLine = ( line: string, start: number, end: number, font: FontString, maxWidth: number, ): WrappedTextLine => { const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth; if (!shouldTrimWhitespaces) { return { text: line, start, end, }; } // defensively default to `trimeEnd` in case the regex does not match let [, trimmedLine, whitespaces] = line.match(/^(.+?)(\s+)$/) ?? [ line, line.trimEnd(), "", ]; let trimmedLineWidth = getLineWidth(trimmedLine, font); for (const whitespace of Array.from(whitespaces)) { const _charWidth = charWidth.calculate(whitespace, font); const testLineWidth = trimmedLineWidth + _charWidth; if (testLineWidth > maxWidth) { break; } trimmedLine = trimmedLine + whitespace; trimmedLineWidth = testLineWidth; } return { text: trimmedLine, start, end: end - (line.length - trimmedLine.length), }; }; /** * Used for internal soft-wrap boundaries, where trailing whitespace should not * survive into the rendered line even though it still exists in the original * text. */ const trimLineEndAtSoftBreak = ( line: string, start: number, end: number, ): WrappedTextLine => { const trimmedLine = line.trimEnd(); return { text: trimmedLine, start, end: end - (line.length - trimmedLine.length), }; }; /** * Check if the given string is a single character. * * Handles multi-byte chars (é, 中) and purposefully does not handle multi-codepoint char (👨‍👩‍👧‍👦, 👩🏽‍🦰). */ const isSingleCharacter = (maybeSingleCharacter: string) => { return ( maybeSingleCharacter.codePointAt(0) !== undefined && maybeSingleCharacter.codePointAt(1) === undefined ); }; /** * Invariant for the word wrapping algorithm. */ const satisfiesWordInvariant = (word: string) => { if (isTestEnv() || isDevEnv()) { if (/\s/.test(word)) { throw new Error("Word should not contain any whitespaces!"); } } };