import type { Y } from '@blockeditor/store'; import type { FontFamily, FontStyle, FontWeight, } from '../../../element-model/common.js'; import type { TextElementModel } from '../../../element-model/text.js'; import type { Bound } from '../../../utils/bound.js'; import { getPointsFromBoundsWithRotation, rotatePoints, } from '../../../utils/math-utils.js'; export type TextDelta = { insert: string; attributes?: { [k: string]: unknown; }; }; const getMeasureCtx = (function initMeasureContext() { let ctx: CanvasRenderingContext2D | null = null; let canvas: HTMLCanvasElement | null = null; return () => { if (!canvas) { canvas = document.createElement('canvas'); ctx = canvas.getContext('2d')!; } return ctx!; }; })(); export function getFontString({ fontStyle, fontWeight, fontSize, fontFamily, }: { fontStyle: string; fontWeight: string; fontSize: number; fontFamily: string; }): string { const lineHeight = getLineHeight(fontFamily, fontSize); return `${fontStyle} ${fontWeight} ${fontSize}px/${lineHeight}px ${wrapFontFamily( fontFamily )}, sans-serif`.trim(); } const cachedFontFamily = new Map< string, { fontSize: number; lineHeight: number; } >(); export function getLineHeight(fontFamily: string, fontSize: number) { const ctx = getMeasureCtx(); const wrappedFontFamily = wrapFontFamily(fontFamily); if (cachedFontFamily.has(wrappedFontFamily)) { const cache = cachedFontFamily.get(wrappedFontFamily)!; return (fontSize / cache.fontSize) * cache.lineHeight; } const font = `${fontSize}px ${wrapFontFamily(fontFamily)}`; ctx.font = `${fontSize}px ${wrapFontFamily(fontFamily)}`; const textMetrcs = ctx.measureText('M'); const lineHeight = textMetrcs.fontBoundingBoxAscent + textMetrcs.fontBoundingBoxDescent; // cached when font property does not fallback if (font === ctx.font) { cachedFontFamily.set(wrappedFontFamily, { fontSize, lineHeight }); } return lineHeight; } export function wrapFontFamily(fontFamily: string): string { return `"${fontFamily}"`; } function transformDelta(delta: TextDelta): (TextDelta | '\n')[] { const result: (TextDelta | '\n')[] = []; let tmpString = delta.insert; while (tmpString.length > 0) { const index = tmpString.indexOf('\n'); if (index === -1) { result.push({ insert: tmpString, attributes: delta.attributes, }); break; } if (tmpString.slice(0, index).length > 0) { result.push({ insert: tmpString.slice(0, index), attributes: delta.attributes, }); } result.push('\n'); tmpString = tmpString.slice(index + 1); } return result; } /** * convert a delta insert array to chunks, each chunk is a line */ export function deltaInsertsToChunks(delta: TextDelta[]): TextDelta[][] { if (delta.length === 0) { return [[]]; } const transformedDelta = delta.flatMap(transformDelta); function* chunksGenerator(arr: (TextDelta | '\n')[]) { let start = 0; for (let i = 0; i < arr.length; i++) { if (arr[i] === '\n') { const chunk = arr.slice(start, i); start = i + 1; yield chunk as TextDelta[]; } else if (i === arr.length - 1) { yield arr.slice(start) as TextDelta[]; } } if (arr.at(-1) === '\n') { yield []; } } return Array.from(chunksGenerator(transformedDelta)); } const RS_LTR_CHARS = 'A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF' + '\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF'; const RS_RTL_CHARS = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC'; // eslint-disable-next-line no-misleading-character-class const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`); export function isRTL(text: string) { return RE_RTL_CHECK.test(text); } export function normalizeText(text: string): string { return ( text // replace tabs with spaces so they render and measure correctly .replace(/\t/g, ' ') // normalize newlines .replace(/\r?\n|\r/g, '\n') ); } export function splitIntoLines(text: string): string[] { return normalizeText(text).split('\n'); } export function getLineWidth(text: string, font: string): number { const ctx = getMeasureCtx(); if (font !== ctx.font) ctx.font = font; const width = ctx.measureText(text).width; return width; } export function getTextWidth(text: string, font: string): number { const lines = splitIntoLines(text); let width = 0; lines.forEach(line => { width = Math.max(width, getLineWidth(line, font)); }); return width; } export function wrapTextDeltas(text: Y.Text, font: string, w: number) { const deltas: TextDelta[] = (text.toDelta() as TextDelta[]).flatMap( delta => ({ insert: wrapText(delta.insert, font, w), attributes: delta.attributes, }) ) as TextDelta[]; return deltas; } export const charWidth = (() => { const cachedCharWidth: { [key: string]: Array } = {}; const calculate = (char: string, font: string) => { const ascii = char.charCodeAt(0); if (!cachedCharWidth[font]) { cachedCharWidth[font] = []; } if (!cachedCharWidth[font][ascii]) { const width = getLineWidth(char, font); cachedCharWidth[font][ascii] = width; } return cachedCharWidth[font][ascii]; }; const getCache = (font: string) => { return cachedCharWidth[font]; }; return { calculate, getCache, }; })(); export function parseTokens(text: string): string[] { // Splitting words containing "-" as those are treated as separate words // by css wrapping algorithm eg non-profit => non-, profit const words = text.split('-'); if (words.length > 1) { // non-proft org => ['non-', 'profit org'] words.forEach((word, index) => { if (index !== words.length - 1) { words[index] = word += '-'; } }); } // Joining the words with space and splitting them again with space to get the // final list of tokens // ['non-', 'profit org'] =>,'non- proft org' => ['non-','profit','org'] return words.join(' ').split(' '); } export function wrapText(text: string, font: string, maxWidth: number): string { // 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 text; } const lines: Array = []; const originalLines = text.split('\n'); const spaceWidth = getLineWidth(' ', font); let currentLine = ''; let currentLineWidthTillNow = 0; const push = (str: string) => { if (str.trim()) { lines.push(str); } }; const resetParams = () => { currentLine = ''; currentLineWidthTillNow = 0; }; originalLines.forEach(originalLine => { const currentLineWidth = getTextWidth(originalLine, font); // Push the line if its <= maxWidth if (currentLineWidth <= maxWidth) { lines.push(originalLine); return; // continue } const words = parseTokens(originalLine); resetParams(); let index = 0; while (index < words.length) { const currentWordWidth = getLineWidth(words[index], font); // This will only happen when single word takes entire width if (currentWordWidth === maxWidth) { push(words[index]); index++; } // Start breaking longer words exceeding max width else if (currentWordWidth > maxWidth) { // push current line since the current word exceeds the max width // so will be appended in next line push(currentLine); resetParams(); while (words[index].length > 0) { const currentChar = String.fromCodePoint( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion words[index].codePointAt(0)! ); const width = charWidth.calculate(currentChar, font); currentLineWidthTillNow += width; words[index] = words[index].slice(currentChar.length); if (currentLineWidthTillNow >= maxWidth) { push(currentLine); currentLine = currentChar; currentLineWidthTillNow = width; } else { currentLine += currentChar; } } // push current line if appending space exceeds max width if (currentLineWidthTillNow + spaceWidth >= maxWidth) { push(currentLine); resetParams(); // space needs to be appended before next word // as currentLine contains chars which couldn't be appended // to previous line unless the line ends with hyphen to sync // with css word-wrap } else if (!currentLine.endsWith('-')) { currentLine += ' '; currentLineWidthTillNow += spaceWidth; } index++; } else { // Start appending words in a line till max width reached while (currentLineWidthTillNow < maxWidth && index < words.length) { const word = words[index]; currentLineWidthTillNow = getLineWidth(currentLine + word, font); if (currentLineWidthTillNow > maxWidth) { push(currentLine); resetParams(); break; } index++; // if word ends with "-" then we don't need to add space // to sync with css word-wrap const shouldAppendSpace = !word.endsWith('-'); currentLine += word; if (shouldAppendSpace) { currentLine += ' '; } // Push the word if appending space exceeds max width if (currentLineWidthTillNow + spaceWidth >= maxWidth) { if (shouldAppendSpace) { lines.push(currentLine.slice(0, -1)); } else { lines.push(currentLine); } resetParams(); break; } } } } if (currentLine.slice(-1) === ' ') { // only remove last trailing space which we have added when joining words currentLine = currentLine.slice(0, -1); push(currentLine); } }); return lines.join('\n'); } export const truncateTextByWidth = ( text: string, font: string, width: number ) => { let totalWidth = 0; let i = 0; for (; i < text.length; i++) { const char = text[i]; totalWidth += charWidth.calculate(char, font); if (totalWidth > width) { break; } } return text.slice(0, i); }; export function getTextCursorPosition( model: TextElementModel, coord: { x: number; y: number } ) { const leftTop = getPointsFromBoundsWithRotation(model)[0]; const mousePos = rotatePoints( [[coord.x, coord.y]], leftTop, -model.rotate )[0]; return [ Math.floor( (mousePos[1] - leftTop[1]) / getLineHeight(model.fontFamily, model.fontSize) ), mousePos[0] - leftTop[0], ]; } export function getCursorByCoord( model: TextElementModel, coord: { x: number; y: number } ) { const [lineIndex, offsetX] = getTextCursorPosition(model, coord); const font = getFontString(model); const deltas = wrapTextDeltas(model.text, font, model.w); const lines = deltaInsertsToChunks(deltas).map(line => line.map(iTextDelta => iTextDelta.insert).join('') ); const string = lines[lineIndex]; let index = lines.slice(0, lineIndex).join('').length - 1; let currentStringWidth = 0; let charIndex = 0; while (currentStringWidth < offsetX) { index += 1; if (charIndex === string.length) { break; } currentStringWidth += charWidth.calculate(string[charIndex], font); charIndex += 1; } return index; } export function normalizeTextBound( text: TextElementModel, bound: Bound, dragging: boolean = false ): Bound { if (!text.text) return bound; const yText = text.text; const { fontStyle, fontWeight, fontSize, fontFamily } = text; const lineHeightPx = getLineHeight(fontFamily, fontSize); const font = getFontString({ fontStyle, fontWeight, fontSize, fontFamily, }); let lines: TextDelta[][] = []; const deltas: TextDelta[] = yText.toDelta() as TextDelta[]; const widestCharWidth = [...yText.toString()] .map(char => getTextWidth(char, font)) .sort((a, b) => a - b) .pop() ?? getTextWidth('W', font); if (bound.w < widestCharWidth) { bound.w = widestCharWidth; } const width = bound.w; const insertDeltas = deltas.flatMap(delta => ({ insert: wrapText(delta.insert, font, width), attributes: delta.attributes, })) as TextDelta[]; lines = deltaInsertsToChunks(insertDeltas); if (!dragging && !text.hasMaxWidth) { lines = deltaInsertsToChunks(deltas); const widestLineWidth = Math.max( ...yText .toString() .split('\n') .map(text => getTextWidth(text, font)) ); bound.w = widestLineWidth; } bound.h = lineHeightPx * lines.length; return bound; } export function getFontFacesByFontFamily( fontFamily: FontFamily | string ): FontFace[] { const fonts = document.fonts; return ( [...fonts.keys()] .filter(fontFace => { return fontFace.family === fontFamily; }) // remove duplicate font faces .filter( (item, index, arr) => arr.findIndex( fontFace => fontFace.family === item.family && fontFace.weight === item.weight && fontFace.style === item.style ) === index ) ); } export function isFontWeightSupported( fontFamily: FontFamily | string, weight: FontWeight ) { const fontFaces = getFontFacesByFontFamily(fontFamily); const fontFace = fontFaces.find(fontFace => fontFace.weight === weight); return !!fontFace; } export function isFontStyleSupported( fontFamily: FontFamily | string, style: FontStyle ) { const fontFaces = getFontFacesByFontFamily(fontFamily); const fontFace = fontFaces.find(fontFace => fontFace.style === style); return !!fontFace; }