From f9815b8b4f612ec1ed4cf2484a61e4ac0befc187 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 28 Oct 2024 10:08:05 +0100 Subject: [PATCH 01/29] fix: image cropping svg + compat mode (#8710) Co-authored-by: Ryan Di --- .../excalidraw/renderer/staticSvgScene.ts | 88 ++++++++++++++----- packages/excalidraw/scene/export.ts | 2 + packages/excalidraw/scene/types.ts | 7 ++ .../tests/__snapshots__/export.test.tsx.snap | 2 +- packages/utils/export.ts | 3 + 5 files changed, 79 insertions(+), 23 deletions(-) diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index f4781c0ce..5570ad8c3 100644 --- a/packages/excalidraw/renderer/staticSvgScene.ts +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -7,7 +7,7 @@ import { SVG_NS, } from "../constants"; import { normalizeLink, toValidURL } from "../data/url"; -import { getElementAbsoluteCoords } from "../element"; +import { getElementAbsoluteCoords, hashString } from "../element"; import { createPlaceholderEmbeddableLabel, getEmbedLink, @@ -411,7 +411,25 @@ const renderElementToSvg = ( const fileData = isInitializedImageElement(element) && files[element.fileId]; if (fileData) { - const symbolId = `image-${fileData.id}`; + const { reuseImages = true } = renderConfig; + + let symbolId = `image-${fileData.id}`; + + let uncroppedWidth = element.width; + let uncroppedHeight = element.height; + if (element.crop) { + ({ width: uncroppedWidth, height: uncroppedHeight } = + getUncroppedWidthAndHeight(element)); + + symbolId = `image-crop-${fileData.id}-${hashString( + `${uncroppedWidth}x${uncroppedHeight}`, + )}`; + } + + if (!reuseImages) { + symbolId = `image-${element.id}`; + } + let symbol = svgRoot.querySelector(`#${symbolId}`); if (!symbol) { symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol"); @@ -421,18 +439,7 @@ const renderElementToSvg = ( image.setAttribute("href", fileData.dataURL); image.setAttribute("preserveAspectRatio", "none"); - if (element.crop) { - const { width: uncroppedWidth, height: uncroppedHeight } = - getUncroppedWidthAndHeight(element); - - symbol.setAttribute( - "viewBox", - `${ - element.crop.x / (element.crop.naturalWidth / uncroppedWidth) - } ${ - element.crop.y / (element.crop.naturalHeight / uncroppedHeight) - } ${width} ${height}`, - ); + if (element.crop || !reuseImages) { image.setAttribute("width", `${uncroppedWidth}`); image.setAttribute("height", `${uncroppedHeight}`); } else { @@ -456,8 +463,23 @@ const renderElementToSvg = ( use.setAttribute("filter", IMAGE_INVERT_FILTER); } - use.setAttribute("width", `${width}`); - use.setAttribute("height", `${height}`); + let normalizedCropX = 0; + let normalizedCropY = 0; + + if (element.crop) { + const { width: uncroppedWidth, height: uncroppedHeight } = + getUncroppedWidthAndHeight(element); + normalizedCropX = + element.crop.x / (element.crop.naturalWidth / uncroppedWidth); + normalizedCropY = + element.crop.y / (element.crop.naturalHeight / uncroppedHeight); + } + + const adjustedCenterX = cx + normalizedCropX; + const adjustedCenterY = cy + normalizedCropY; + + use.setAttribute("width", `${width + normalizedCropX}`); + use.setAttribute("height", `${height + normalizedCropY}`); use.setAttribute("opacity", `${opacity}`); // We first apply `scale` transforms (horizontal/vertical mirroring) @@ -467,21 +489,43 @@ const renderElementToSvg = ( // the transformations correctly (the transform-origin was not being // applied correctly). if (element.scale[0] !== 1 || element.scale[1] !== 1) { - const translateX = element.scale[0] !== 1 ? -width : 0; - const translateY = element.scale[1] !== 1 ? -height : 0; use.setAttribute( "transform", - `scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`, + `translate(${adjustedCenterX} ${adjustedCenterY}) scale(${ + element.scale[0] + } ${ + element.scale[1] + }) translate(${-adjustedCenterX} ${-adjustedCenterY})`, ); } const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + + if (element.crop) { + const mask = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask"); + mask.setAttribute("id", `mask-image-crop-${element.id}`); + mask.setAttribute("fill", "#fff"); + const maskRect = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + + maskRect.setAttribute("x", `${normalizedCropX}`); + maskRect.setAttribute("y", `${normalizedCropY}`); + maskRect.setAttribute("width", `${width}`); + maskRect.setAttribute("height", `${height}`); + + mask.appendChild(maskRect); + root.appendChild(mask); + g.setAttribute("mask", `url(#${mask.id})`); + } + g.appendChild(use); g.setAttribute( "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, + `translate(${offsetX - normalizedCropX} ${ + offsetY - normalizedCropY + }) rotate(${degree} ${adjustedCenterX} ${adjustedCenterY})`, ); if (element.roundness) { diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 85f5f2b7c..a311e4404 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -284,6 +284,7 @@ export const exportToSvg = async ( renderEmbeddables?: boolean; exportingFrame?: ExcalidrawFrameLikeElement | null; skipInliningFonts?: true; + reuseImages?: boolean; }, ): Promise => { const frameRendering = getFrameRenderingConfig( @@ -425,6 +426,7 @@ export const exportToSvg = async ( .map((element) => [element.id, true]), ) : new Map(), + reuseImages: opts?.reuseImages ?? true, }, ); diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 67ab3e3ab..46ee26b74 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -46,6 +46,13 @@ export type SVGRenderConfig = { frameRendering: AppState["frameRendering"]; canvasBackgroundColor: AppState["viewBackgroundColor"]; embedsValidationStatus: EmbedsValidationStatus; + /** + * whether to attempt to reuse images as much as possible through symbols + * (reduces SVG size, but may be incompoatible with some SVG renderers) + * + * @default true + */ + reuseImages: boolean; }; export type InteractiveCanvasRenderConfig = { diff --git a/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap index fd4f902b2..1ba16e2ee 100644 --- a/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap @@ -10,5 +10,5 @@ exports[`export > exporting svg containing transformed images > svg export outpu - " + " `; diff --git a/packages/utils/export.ts b/packages/utils/export.ts index 9adb6fec3..a82ef66e6 100644 --- a/packages/utils/export.ts +++ b/packages/utils/export.ts @@ -167,10 +167,12 @@ export const exportToSvg = async ({ renderEmbeddables, exportingFrame, skipInliningFonts, + reuseImages, }: Omit & { exportPadding?: number; renderEmbeddables?: boolean; skipInliningFonts?: true; + reuseImages?: boolean; }): Promise => { const { elements: restoredElements, appState: restoredAppState } = restore( { elements, appState }, @@ -187,6 +189,7 @@ export const exportToSvg = async ({ exportingFrame, renderEmbeddables, skipInliningFonts, + reuseImages, }); }; From 79b181bcdcbff6166dbfa7b8b11118ddbd6c9833 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 29 Oct 2024 22:40:24 +0100 Subject: [PATCH 02/29] fix: restore svg image DataURL dimensions (#8730) --- excalidraw-app/collab/Collab.tsx | 29 ++++++++- excalidraw-app/data/FileManager.ts | 71 ++++++++++++++++------- excalidraw-app/data/LocalData.ts | 8 +-- excalidraw-app/data/firebase.ts | 8 +-- packages/excalidraw/components/App.tsx | 66 +++++++++++++++------ packages/excalidraw/data/blob.ts | 22 +++++-- packages/excalidraw/data/encode.ts | 53 +++++++++-------- packages/excalidraw/data/image.ts | 16 ++--- packages/excalidraw/element/image.ts | 2 +- packages/excalidraw/scene/export.ts | 4 +- packages/excalidraw/tests/export.test.tsx | 4 +- packages/excalidraw/types.ts | 5 ++ packages/utils/utils.unmocked.test.ts | 2 +- 13 files changed, 196 insertions(+), 94 deletions(-) diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index 7059a67c5..944c0deb0 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -1,6 +1,7 @@ import throttle from "lodash.throttle"; import { PureComponent } from "react"; import type { + BinaryFileData, ExcalidrawImperativeAPI, SocketId, } from "../../packages/excalidraw/types"; @@ -9,6 +10,7 @@ import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants"; import type { ImportedDataState } from "../../packages/excalidraw/data/types"; import type { ExcalidrawElement, + FileId, InitializedExcalidrawImageElement, OrderedExcalidrawElement, } from "../../packages/excalidraw/element/types"; @@ -157,7 +159,7 @@ class Collab extends PureComponent { throw new AbortError(); } - return saveFilesToFirebase({ + const { savedFiles, erroredFiles } = await saveFilesToFirebase({ prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`, files: await encodeFilesForUpload({ files: addedFiles, @@ -165,6 +167,29 @@ class Collab extends PureComponent { maxBytes: FILE_UPLOAD_MAX_BYTES, }), }); + + return { + savedFiles: savedFiles.reduce( + (acc: Map, id) => { + const fileData = addedFiles.get(id); + if (fileData) { + acc.set(id, fileData); + } + return acc; + }, + new Map(), + ), + erroredFiles: erroredFiles.reduce( + (acc: Map, id) => { + const fileData = addedFiles.get(id); + if (fileData) { + acc.set(id, fileData); + } + return acc; + }, + new Map(), + ), + }; }, }); this.excalidrawAPI = props.excalidrawAPI; @@ -394,7 +419,7 @@ class Collab extends PureComponent { .filter((element) => { return ( isInitializedImageElement(element) && - !this.fileManager.isFileHandled(element.fileId) && + !this.fileManager.isFileTracked(element.fileId) && !element.isDeleted && (opts.forceFetchFiles ? element.status !== "pending" || diff --git a/excalidraw-app/data/FileManager.ts b/excalidraw-app/data/FileManager.ts index e9b460247..56f832b68 100644 --- a/excalidraw-app/data/FileManager.ts +++ b/excalidraw-app/data/FileManager.ts @@ -16,14 +16,26 @@ import type { BinaryFiles, } from "../../packages/excalidraw/types"; +type FileVersion = Required["version"]; + export class FileManager { /** files being fetched */ private fetchingFiles = new Map(); + private erroredFiles_fetch = new Map< + ExcalidrawImageElement["fileId"], + true + >(); /** files being saved */ - private savingFiles = new Map(); + private savingFiles = new Map< + ExcalidrawImageElement["fileId"], + FileVersion + >(); /* files already saved to persistent storage */ - private savedFiles = new Map(); - private erroredFiles = new Map(); + private savedFiles = new Map(); + private erroredFiles_save = new Map< + ExcalidrawImageElement["fileId"], + FileVersion + >(); private _getFiles; private _saveFiles; @@ -37,8 +49,8 @@ export class FileManager { erroredFiles: Map; }>; saveFiles: (data: { addedFiles: Map }) => Promise<{ - savedFiles: Map; - erroredFiles: Map; + savedFiles: Map; + erroredFiles: Map; }>; }) { this._getFiles = getFiles; @@ -46,19 +58,28 @@ export class FileManager { } /** - * returns whether file is already saved or being processed + * returns whether file is saved/errored, or being processed */ - isFileHandled = (id: FileId) => { + isFileTracked = (id: FileId) => { return ( this.savedFiles.has(id) || - this.fetchingFiles.has(id) || this.savingFiles.has(id) || - this.erroredFiles.has(id) + this.fetchingFiles.has(id) || + this.erroredFiles_fetch.has(id) || + this.erroredFiles_save.has(id) ); }; - isFileSaved = (id: FileId) => { - return this.savedFiles.has(id); + isFileSavedOrBeingSaved = (file: BinaryFileData) => { + const fileVersion = this.getFileVersion(file); + return ( + this.savedFiles.get(file.id) === fileVersion || + this.savingFiles.get(file.id) === fileVersion + ); + }; + + getFileVersion = (file: BinaryFileData) => { + return file.version ?? 1; }; saveFiles = async ({ @@ -71,13 +92,16 @@ export class FileManager { const addedFiles: Map = new Map(); for (const element of elements) { + const fileData = + isInitializedImageElement(element) && files[element.fileId]; + if ( - isInitializedImageElement(element) && - files[element.fileId] && - !this.isFileHandled(element.fileId) + fileData && + // NOTE if errored during save, won't retry due to this check + !this.isFileSavedOrBeingSaved(fileData) ) { addedFiles.set(element.fileId, files[element.fileId]); - this.savingFiles.set(element.fileId, true); + this.savingFiles.set(element.fileId, this.getFileVersion(fileData)); } } @@ -86,8 +110,12 @@ export class FileManager { addedFiles, }); - for (const [fileId] of savedFiles) { - this.savedFiles.set(fileId, true); + for (const [fileId, fileData] of savedFiles) { + this.savedFiles.set(fileId, this.getFileVersion(fileData)); + } + + for (const [fileId, fileData] of erroredFiles) { + this.erroredFiles_save.set(fileId, this.getFileVersion(fileData)); } return { @@ -121,10 +149,10 @@ export class FileManager { const { loadedFiles, erroredFiles } = await this._getFiles(ids); for (const file of loadedFiles) { - this.savedFiles.set(file.id, true); + this.savedFiles.set(file.id, this.getFileVersion(file)); } for (const [fileId] of erroredFiles) { - this.erroredFiles.set(fileId, true); + this.erroredFiles_fetch.set(fileId, true); } return { loadedFiles, erroredFiles }; @@ -160,7 +188,7 @@ export class FileManager { ): element is InitializedExcalidrawImageElement => { return ( isInitializedImageElement(element) && - this.isFileSaved(element.fileId) && + this.savedFiles.has(element.fileId) && element.status === "pending" ); }; @@ -169,7 +197,8 @@ export class FileManager { this.fetchingFiles.clear(); this.savingFiles.clear(); this.savedFiles.clear(); - this.erroredFiles.clear(); + this.erroredFiles_fetch.clear(); + this.erroredFiles_save.clear(); } } diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index c8ac5b19a..2e524522c 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -183,8 +183,8 @@ export class LocalData { ); }, async saveFiles({ addedFiles }) { - const savedFiles = new Map(); - const erroredFiles = new Map(); + const savedFiles = new Map(); + const erroredFiles = new Map(); // before we use `storage` event synchronization, let's update the flag // optimistically. Hopefully nothing fails, and an IDB read executed @@ -195,10 +195,10 @@ export class LocalData { [...addedFiles].map(async ([id, fileData]) => { try { await set(id, fileData, filesStore); - savedFiles.set(id, true); + savedFiles.set(id, fileData); } catch (error: any) { console.error(error); - erroredFiles.set(id, true); + erroredFiles.set(id, fileData); } }), ); diff --git a/excalidraw-app/data/firebase.ts b/excalidraw-app/data/firebase.ts index c73018acf..299f3ee98 100644 --- a/excalidraw-app/data/firebase.ts +++ b/excalidraw-app/data/firebase.ts @@ -177,8 +177,8 @@ export const saveFilesToFirebase = async ({ }) => { const firebase = await loadFirebaseStorage(); - const erroredFiles = new Map(); - const savedFiles = new Map(); + const erroredFiles: FileId[] = []; + const savedFiles: FileId[] = []; await Promise.all( files.map(async ({ id, buffer }) => { @@ -194,9 +194,9 @@ export const saveFilesToFirebase = async ({ cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`, }, ); - savedFiles.set(id, true); + savedFiles.push(id); } catch (error: any) { - erroredFiles.set(id, true); + erroredFiles.push(id); } }), ); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index fb0c24d91..6b6022794 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -304,8 +304,10 @@ import { Toast } from "./Toast"; import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import { dataURLToFile, + dataURLToString, generateIdFromFile, getDataURL, + getDataURL_sync, getFileFromEvent, ImageURLToFile, isImageFileHandle, @@ -2122,9 +2124,7 @@ class App extends React.Component { } if (actionResult.files) { - this.files = actionResult.replaceFiles - ? actionResult.files - : { ...this.files, ...actionResult.files }; + this.addMissingFiles(actionResult.files, actionResult.replaceFiles); this.addNewImagesToImageCache(); } @@ -3237,7 +3237,7 @@ class App extends React.Component { }); if (opts.files) { - this.files = { ...this.files, ...opts.files }; + this.addMissingFiles(opts.files); } this.store.shouldCaptureIncrement(); @@ -3746,23 +3746,56 @@ class App extends React.Component { } }; - /** adds supplied files to existing files in the appState */ + /** + * adds supplied files to existing files in the appState. + * NOTE if file already exists in editor state, the file data is not updated + * */ public addFiles: ExcalidrawImperativeAPI["addFiles"] = withBatchedUpdates( (files) => { - const filesMap = files.reduce((acc, fileData) => { - acc.set(fileData.id, fileData); - return acc; - }, new Map()); + const { addedFiles } = this.addMissingFiles(files); - this.files = { ...this.files, ...Object.fromEntries(filesMap) }; - - this.clearImageShapeCache(Object.fromEntries(filesMap)); + this.clearImageShapeCache(addedFiles); this.scene.triggerUpdate(); this.addNewImagesToImageCache(); }, ); + private addMissingFiles = ( + files: BinaryFiles | BinaryFileData[], + replace = false, + ) => { + const nextFiles = replace ? {} : { ...this.files }; + const addedFiles: BinaryFiles = {}; + + const _files = Array.isArray(files) ? files : Object.values(files); + + for (const fileData of _files) { + if (nextFiles[fileData.id]) { + continue; + } + + addedFiles[fileData.id] = fileData; + nextFiles[fileData.id] = fileData; + + if (fileData.mimeType === MIME_TYPES.svg) { + const restoredDataURL = getDataURL_sync( + normalizeSVG(dataURLToString(fileData.dataURL)), + MIME_TYPES.svg, + ); + if (fileData.dataURL !== restoredDataURL) { + // bump version so persistence layer can update the store + fileData.version = (fileData.version ?? 1) + 1; + fileData.dataURL = restoredDataURL; + } + } + } + + this.files = nextFiles; + + return { addedFiles }; + }; + public updateScene = withBatchedUpdates( (sceneData: { elements?: SceneData["elements"]; @@ -9285,7 +9318,7 @@ class App extends React.Component { if (mimeType === MIME_TYPES.svg) { try { imageFile = SVGStringToFile( - await normalizeSVG(await imageFile.text()), + normalizeSVG(await imageFile.text()), imageFile.name, ); } catch (error: any) { @@ -9353,16 +9386,15 @@ class App extends React.Component { return new Promise>( async (resolve, reject) => { try { - this.files = { - ...this.files, - [fileId]: { + this.addMissingFiles([ + { mimeType, id: fileId, dataURL, created: Date.now(), lastRetrieved: Date.now(), }, - }; + ]); const cachedImageData = this.imageCache.get(fileId); if (!cachedImageData) { this.addNewImagesToImageCache(); diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index 1eb1e1bed..69a62cda5 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -8,13 +8,14 @@ import { calculateScrollCenter } from "../scene"; import type { AppState, DataURL, LibraryItem } from "../types"; import type { ValueOf } from "../utility-types"; import { bytesToHexString, isPromiseLike } from "../utils"; +import { base64ToString, stringToBase64, toByteString } from "./encode"; import type { FileSystemHandle } from "./filesystem"; import { nativeFileSystemSupported } from "./filesystem"; import { isValidExcalidrawData, isValidLibrary } from "./json"; import { restore, restoreLibraryItems } from "./restore"; import type { ImportedLibraryData } from "./types"; -const parseFileContents = async (blob: Blob | File) => { +const parseFileContents = async (blob: Blob | File): Promise => { let contents: string; if (blob.type === MIME_TYPES.png) { @@ -46,9 +47,7 @@ const parseFileContents = async (blob: Blob | File) => { } if (blob.type === MIME_TYPES.svg) { try { - return await ( - await import("./image") - ).decodeSvgMetadata({ + return (await import("./image")).decodeSvgMetadata({ svg: contents, }); } catch (error: any) { @@ -249,6 +248,7 @@ export const generateIdFromFile = async (file: File): Promise => { } }; +/** async. For sync variant, use getDataURL_sync */ export const getDataURL = async (file: Blob | File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -261,6 +261,16 @@ export const getDataURL = async (file: Blob | File): Promise => { }); }; +export const getDataURL_sync = ( + data: string | Uint8Array | ArrayBuffer, + mimeType: ValueOf, +): DataURL => { + return `data:${mimeType};base64,${stringToBase64( + toByteString(data), + true, + )}` as DataURL; +}; + export const dataURLToFile = (dataURL: DataURL, filename = "") => { const dataIndexStart = dataURL.indexOf(","); const byteString = atob(dataURL.slice(dataIndexStart + 1)); @@ -274,6 +284,10 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => { return new File([ab], filename, { type: mimeType }); }; +export const dataURLToString = (dataURL: DataURL) => { + return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1)); +}; + export const resizeImageFile = async ( file: File, opts: { diff --git a/packages/excalidraw/data/encode.ts b/packages/excalidraw/data/encode.ts index 104ab1ca8..44e6b9974 100644 --- a/packages/excalidraw/data/encode.ts +++ b/packages/excalidraw/data/encode.ts @@ -5,24 +5,23 @@ import { encryptData, decryptData } from "./encryption"; // byte (binary) strings // ----------------------------------------------------------------------------- -// fast, Buffer-compatible implem -export const toByteString = ( - data: string | Uint8Array | ArrayBuffer, -): Promise => { - return new Promise((resolve, reject) => { - const blob = - typeof data === "string" - ? new Blob([new TextEncoder().encode(data)]) - : new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]); - const reader = new FileReader(); - reader.onload = (event) => { - if (!event.target || typeof event.target.result !== "string") { - return reject(new Error("couldn't convert to byte string")); - } - resolve(event.target.result); - }; - reader.readAsBinaryString(blob); - }); +// Buffer-compatible implem. +// +// Note that in V8, spreading the uint8array (by chunks) into +// `String.fromCharCode(...uint8array)` tends to be faster for large +// strings/buffers, in case perf is needed in the future. +export const toByteString = (data: string | Uint8Array | ArrayBuffer) => { + const bytes = + typeof data === "string" + ? new TextEncoder().encode(data) + : data instanceof Uint8Array + ? data + : new Uint8Array(data); + let bstring = ""; + for (const byte of bytes) { + bstring += String.fromCharCode(byte); + } + return bstring; }; const byteStringToArrayBuffer = (byteString: string) => { @@ -46,12 +45,12 @@ const byteStringToString = (byteString: string) => { * @param isByteString set to true if already byte string to prevent bloat * due to reencoding */ -export const stringToBase64 = async (str: string, isByteString = false) => { - return isByteString ? window.btoa(str) : window.btoa(await toByteString(str)); +export const stringToBase64 = (str: string, isByteString = false) => { + return isByteString ? window.btoa(str) : window.btoa(toByteString(str)); }; // async to align with stringToBase64 -export const base64ToString = async (base64: string, isByteString = false) => { +export const base64ToString = (base64: string, isByteString = false) => { return isByteString ? window.atob(base64) : byteStringToString(window.atob(base64)); @@ -82,18 +81,18 @@ type EncodedData = { /** * Encodes (and potentially compresses via zlib) text to byte string */ -export const encode = async ({ +export const encode = ({ text, compress, }: { text: string; /** defaults to `true`. If compression fails, falls back to bstring alone. */ compress?: boolean; -}): Promise => { +}): EncodedData => { let deflated!: string; if (compress !== false) { try { - deflated = await toByteString(deflate(text)); + deflated = toByteString(deflate(text)); } catch (error: any) { console.error("encode: cannot deflate", error); } @@ -102,11 +101,11 @@ export const encode = async ({ version: "1", encoding: "bstring", compressed: !!deflated, - encoded: deflated || (await toByteString(text)), + encoded: deflated || toByteString(text), }; }; -export const decode = async (data: EncodedData): Promise => { +export const decode = (data: EncodedData): string => { let decoded: string; switch (data.encoding) { @@ -114,7 +113,7 @@ export const decode = async (data: EncodedData): Promise => { // if compressed, do not double decode the bstring decoded = data.compressed ? data.encoded - : await byteStringToString(data.encoded); + : byteStringToString(data.encoded); break; default: throw new Error(`decode: unknown encoding "${data.encoding}"`); diff --git a/packages/excalidraw/data/image.ts b/packages/excalidraw/data/image.ts index 4c5ef39dc..02d059fd3 100644 --- a/packages/excalidraw/data/image.ts +++ b/packages/excalidraw/data/image.ts @@ -32,7 +32,7 @@ export const encodePngMetadata = async ({ const metadataChunk = tEXt.encode( MIME_TYPES.excalidraw, JSON.stringify( - await encode({ + encode({ text: metadata, compress: true, }), @@ -59,7 +59,7 @@ export const decodePngMetadata = async (blob: Blob) => { } throw new Error("FAILED"); } - return await decode(encodedData); + return decode(encodedData); } catch (error: any) { console.error(error); throw new Error("FAILED"); @@ -72,9 +72,9 @@ export const decodePngMetadata = async (blob: Blob) => { // SVG // ----------------------------------------------------------------------------- -export const encodeSvgMetadata = async ({ text }: { text: string }) => { - const base64 = await stringToBase64( - JSON.stringify(await encode({ text })), +export const encodeSvgMetadata = ({ text }: { text: string }) => { + const base64 = stringToBase64( + JSON.stringify(encode({ text })), true /* is already byte string */, ); @@ -87,7 +87,7 @@ export const encodeSvgMetadata = async ({ text }: { text: string }) => { return metadata; }; -export const decodeSvgMetadata = async ({ svg }: { svg: string }) => { +export const decodeSvgMetadata = ({ svg }: { svg: string }) => { if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) { const match = svg.match( /\s*(.+?)\s*/, @@ -100,7 +100,7 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => { const isByteString = version !== "1"; try { - const json = await base64ToString(match[1], isByteString); + const json = base64ToString(match[1], isByteString); const encodedData = JSON.parse(json); if (!("encoded" in encodedData)) { // legacy, un-encoded scene JSON @@ -112,7 +112,7 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => { } throw new Error("FAILED"); } - return await decode(encodedData); + return decode(encodedData); } catch (error: any) { console.error(error); throw new Error("FAILED"); diff --git a/packages/excalidraw/element/image.ts b/packages/excalidraw/element/image.ts index 33c585269..7088e301f 100644 --- a/packages/excalidraw/element/image.ts +++ b/packages/excalidraw/element/image.ts @@ -94,7 +94,7 @@ export const isHTMLSVGElement = (node: Node | null): node is SVGElement => { return node?.nodeName.toLowerCase() === "svg"; }; -export const normalizeSVG = async (SVGString: string) => { +export const normalizeSVG = (SVGString: string) => { const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg); const svg = doc.querySelector("svg"); const errorNode = doc.querySelector("parsererror"); diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index a311e4404..43e737be5 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -319,9 +319,7 @@ export const exportToSvg = async ( // the tempScene hack which duplicates and regenerates ids if (exportEmbedScene) { try { - metadata = await ( - await import("../data/image") - ).encodeSvgMetadata({ + metadata = (await import("../data/image")).encodeSvgMetadata({ // when embedding scene, we want to embed the origionally supplied // elements which don't contain the temp frame labels. // But it also requires that the exportToSvg is being supplied with diff --git a/packages/excalidraw/tests/export.test.tsx b/packages/excalidraw/tests/export.test.tsx index 35429d9b4..65b399dbb 100644 --- a/packages/excalidraw/tests/export.test.tsx +++ b/packages/excalidraw/tests/export.test.tsx @@ -62,10 +62,10 @@ describe("export", () => { }); it("test encoding/decoding scene for SVG export", async () => { - const encoded = await encodeSvgMetadata({ + const encoded = encodeSvgMetadata({ text: serializeAsJSON(testElements, h.state, {}, "local"), }); - const decoded = JSON.parse(await decodeSvgMetadata({ svg: encoded })); + const decoded = JSON.parse(decodeSvgMetadata({ svg: encoded })); expect(decoded.elements).toEqual([ expect.objectContaining({ type: "text", text: "😀" }), ]); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index b843dfacb..453851a0e 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -106,6 +106,11 @@ export type BinaryFileData = { * Epoch timestamp in milliseconds. */ lastRetrieved?: number; + /** + * indicates the version of the file. This can be used to determine whether + * the file dataURL has changed e.g. as part of restore due to schema update. + */ + version?: number; }; export type BinaryFileMetadata = Omit; diff --git a/packages/utils/utils.unmocked.test.ts b/packages/utils/utils.unmocked.test.ts index 0cf0bb8a4..7c892cb59 100644 --- a/packages/utils/utils.unmocked.test.ts +++ b/packages/utils/utils.unmocked.test.ts @@ -27,7 +27,7 @@ describe("embedding scene data", () => { const svg = svgNode.outerHTML; - const parsedString = await decodeSvgMetadata({ svg }); + const parsedString = decodeSvgMetadata({ svg }); const importedData: ImportedDataState = JSON.parse(parsedString); expect(sourceElements.map((x) => x.id)).toEqual( From 03028eaa8c72504ecb9b65acfdc20aa2887daffd Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Wed, 30 Oct 2024 13:40:24 +0200 Subject: [PATCH 03/29] fix: load font faces in Safari manually (#8693) --- packages/excalidraw/components/App.tsx | 23 +- packages/excalidraw/constants.ts | 1 + packages/excalidraw/fonts/Fonts.ts | 324 +++++++++++++++++++------ packages/excalidraw/scene/export.ts | 128 +--------- 4 files changed, 269 insertions(+), 207 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 6b6022794..3cb187ab8 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -49,7 +49,7 @@ import { } from "../appState"; import type { PastedMixedContent } from "../clipboard"; import { copyTextToSystemClipboard, parseClipboard } from "../clipboard"; -import { ARROW_TYPE, type EXPORT_IMAGE_TYPES } from "../constants"; +import { ARROW_TYPE, isSafari, type EXPORT_IMAGE_TYPES } from "../constants"; import { APP_NAME, CURSOR_TYPE, @@ -2320,11 +2320,11 @@ class App extends React.Component { // clear the shape and image cache so that any images in initialData // can be loaded fresh this.clearImageShapeCache(); - // FontFaceSet loadingdone event we listen on may not always - // fire (looking at you Safari), so on init we manually load all - // fonts and rerender scene text elements once done. This also - // seems faster even in browsers that do fire the loadingdone event. - this.fonts.loadSceneFonts(); + + // manually loading the font faces seems faster even in browsers that do fire the loadingdone event + this.fonts.loadSceneFonts().then((fontFaces) => { + this.fonts.onLoaded(fontFaces); + }); }; private isMobileBreakpoint = (width: number, height: number) => { @@ -2567,8 +2567,8 @@ class App extends React.Component { ), // rerender text elements on font load to fix #637 && #1553 addEventListener(document.fonts, "loadingdone", (event) => { - const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces; - this.fonts.onLoaded(loadedFontFaces); + const fontFaces = (event as FontFaceSetLoadEvent).fontfaces; + this.fonts.onLoaded(fontFaces); }), // Safari-only desktop pinch zoom addEventListener( @@ -3236,6 +3236,13 @@ class App extends React.Component { } }); + // paste event may not fire FontFace loadingdone event in Safari, hence loading font faces manually + if (isSafari) { + Fonts.loadElementsFonts(newElements).then((fontFaces) => { + this.fonts.onLoaded(fontFaces); + }); + } + if (opts.files) { this.addMissingFiles(opts.files); } diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 341316d12..6bd8f1e99 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -2,6 +2,7 @@ import cssVariables from "./css/variables.module.scss"; import type { AppProps, AppState } from "./types"; import type { ExcalidrawElement, FontFamilyValues } from "./element/types"; import { COLOR_PALETTE } from "./colors"; + export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform); export const isAndroid = /\b(android)\b/i.test(navigator.userAgent); diff --git a/packages/excalidraw/fonts/Fonts.ts b/packages/excalidraw/fonts/Fonts.ts index 46b0f63c3..31b5ad000 100644 --- a/packages/excalidraw/fonts/Fonts.ts +++ b/packages/excalidraw/fonts/Fonts.ts @@ -3,11 +3,17 @@ import { FONT_FAMILY_FALLBACKS, CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT, + isSafari, + getFontFamilyFallbacks, } from "../constants"; import { isTextElement } from "../element"; -import { charWidth, getContainerElement } from "../element/textElement"; +import { + charWidth, + containsCJK, + getContainerElement, +} from "../element/textElement"; import { ShapeCache } from "../scene/ShapeCache"; -import { getFontString } from "../utils"; +import { getFontString, PromisePool, promiseTry } from "../utils"; import { ExcalidrawFontFace } from "./ExcalidrawFontFace"; import { CascadiaFontFaces } from "./Cascadia"; @@ -73,6 +79,13 @@ export class Fonts { this.scene = scene; } + /** + * Get all the font families for the given scene. + */ + public getSceneFamilies = () => { + return Fonts.getUniqueFamilies(this.scene.getNonDeletedElements()); + }; + /** * if we load a (new) font, it's likely that text elements using it have * already been rendered using a fallback font. Thus, we want invalidate @@ -81,7 +94,7 @@ export class Fonts { * Invalidates text elements and rerenders scene, provided that at least one * of the supplied fontFaces has not already been processed. */ - public onLoaded = (fontFaces: readonly FontFace[]) => { + public onLoaded = (fontFaces: readonly FontFace[]): void => { // bail if all fonts with have been processed. We're checking just a // subset of the font properties (though it should be enough), so it // can technically bail on a false positive. @@ -127,12 +140,40 @@ export class Fonts { /** * Load font faces for a given scene and trigger scene update. + * + * FontFaceSet loadingdone event we listen on may not always + * fire (looking at you Safari), so on init we manually load all + * fonts and rerender scene text elements once done. + * + * For Safari we make sure to check against each loaded font face + * with the unique characters per family in the scene, + * otherwise fonts might remain unloaded. */ public loadSceneFonts = async (): Promise => { const sceneFamilies = this.getSceneFamilies(); - const loaded = await Fonts.loadFontFaces(sceneFamilies); - this.onLoaded(loaded); - return loaded; + const charsPerFamily = isSafari + ? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements()) + : undefined; + + return Fonts.loadFontFaces(sceneFamilies, charsPerFamily); + }; + + /** + * Load font faces for passed elements - use when the scene is unavailable (i.e. export). + * + * For Safari we make sure to check against each loaded font face, + * with the unique characters per family in the elements + * otherwise fonts might remain unloaded. + */ + public static loadElementsFonts = async ( + elements: readonly ExcalidrawElement[], + ): Promise => { + const fontFamilies = Fonts.getUniqueFamilies(elements); + const charsPerFamily = isSafari + ? Fonts.getCharsPerFamily(elements) + : undefined; + + return Fonts.loadFontFaces(fontFamilies, charsPerFamily); }; /** @@ -144,17 +185,48 @@ export class Fonts { }; /** - * Load font faces for passed elements - use when the scene is unavailable (i.e. export). + * Generate CSS @font-face declarations for the given elements. */ - public static loadElementsFonts = async ( + public static async generateFontFaceDeclarations( elements: readonly ExcalidrawElement[], - ): Promise => { - const fontFamilies = Fonts.getElementsFamilies(elements); - return await Fonts.loadFontFaces(fontFamilies); - }; + ) { + const families = Fonts.getUniqueFamilies(elements); + const charsPerFamily = Fonts.getCharsPerFamily(elements); + + // for simplicity, assuming we have just one family with the CJK handdrawn fallback + const familyWithCJK = families.find((x) => + getFontFamilyFallbacks(x).includes(CJK_HAND_DRAWN_FALLBACK_FONT), + ); + + if (familyWithCJK) { + const characters = Fonts.getCharacters(charsPerFamily, familyWithCJK); + + if (containsCJK(characters)) { + const family = FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT]; + + // adding the same characters to the CJK handrawn family + charsPerFamily[family] = new Set(characters); + + // the order between the families and fallbacks is important, as fallbacks need to be defined first and in the reversed order + // so that they get overriden with the later defined font faces, i.e. in case they share some codepoints + families.unshift(FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT]); + } + } + + // don't trigger hundreds of concurrent requests (each performing fetch, creating a worker, etc.), + // instead go three requests at a time, in a controlled manner, without completely blocking the main thread + // and avoiding potential issues such as rate limits + const iterator = Fonts.fontFacesStylesGenerator(families, charsPerFamily); + const concurrency = 3; + const fontFaces = await new PromisePool(iterator, concurrency).all(); + + // dedup just in case (i.e. could be the same font faces with 0 glyphs) + return Array.from(new Set(fontFaces)); + } private static async loadFontFaces( fontFamilies: Array, + charsPerFamily?: Record>, ) { // add all registered font faces into the `document.fonts` (if not added already) for (const { fontFaces, metadata } of Fonts.registered.values()) { @@ -170,35 +242,136 @@ export class Fonts { } } - const loadedFontFaces = await Promise.all( - fontFamilies.map(async (fontFamily) => { - const fontString = getFontString({ - fontFamily, - fontSize: 16, - }); + // loading 10 font faces at a time, in a controlled manner + const iterator = Fonts.fontFacesLoader(fontFamilies, charsPerFamily); + const concurrency = 10; + const fontFaces = await new PromisePool(iterator, concurrency).all(); + return fontFaces.flat().filter(Boolean); + } - // WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one! - if (!window.document.fonts.check(fontString)) { + private static *fontFacesLoader( + fontFamilies: Array, + charsPerFamily?: Record>, + ): Generator> { + for (const [index, fontFamily] of fontFamilies.entries()) { + const font = getFontString({ + fontFamily, + fontSize: 16, + }); + + // WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one! + // for Safari on init, we rather check with the "text" param, even though it's less efficient, as otherwise fonts might remain unloaded + const text = + isSafari && charsPerFamily + ? Fonts.getCharacters(charsPerFamily, fontFamily) + : ""; + + if (!window.document.fonts.check(font, text)) { + yield promiseTry(async () => { try { // WARN: browser prioritizes loading only font faces with unicode ranges for characters which are present in the document (html & canvas), other font faces could stay unloaded // we might want to retry here, i.e. in case CDN is down, but so far I didn't experience any issues - maybe it handles retry-like logic under the hood - return await window.document.fonts.load(fontString); + const fontFaces = await window.document.fonts.load(font, text); + + return [index, fontFaces]; } catch (e) { // don't let it all fail if just one font fails to load console.error( - `Failed to load font "${fontString}" from urls "${Fonts.registered + `Failed to load font "${font}" from urls "${Fonts.registered .get(fontFamily) ?.fontFaces.map((x) => x.urls)}"`, e, ); } - } + }); + } + } + } - return Promise.resolve(); - }), - ); + private static *fontFacesStylesGenerator( + families: Array, + charsPerFamily: Record>, + ): Generator> { + for (const [familyIndex, family] of families.entries()) { + const { fontFaces, metadata } = Fonts.registered.get(family) ?? {}; - return loadedFontFaces.flat().filter(Boolean) as FontFace[]; + if (!Array.isArray(fontFaces)) { + console.error( + `Couldn't find registered fonts for font-family "${family}"`, + Fonts.registered, + ); + continue; + } + + if (metadata?.local) { + // don't inline local fonts + continue; + } + + for (const [fontFaceIndex, fontFace] of fontFaces.entries()) { + yield promiseTry(async () => { + try { + const characters = Fonts.getCharacters(charsPerFamily, family); + const fontFaceCSS = await fontFace.toCSS(characters); + + if (!fontFaceCSS) { + return; + } + + // giving a buffer of 10K font faces per family + const fontFaceOrder = familyIndex * 10_000 + fontFaceIndex; + const fontFaceTuple = [fontFaceOrder, fontFaceCSS] as const; + + return fontFaceTuple; + } catch (error) { + console.error( + `Couldn't transform font-face to css for family "${fontFace.fontFace.family}"`, + error, + ); + } + }); + } + } + } + + /** + * Register a new font. + * + * @param family font family + * @param metadata font metadata + * @param fontFacesDecriptors font faces descriptors + */ + private static register( + this: + | Fonts + | { + registered: Map< + number, + { metadata: FontMetadata; fontFaces: ExcalidrawFontFace[] } + >; + }, + family: string, + metadata: FontMetadata, + ...fontFacesDecriptors: ExcalidrawFontFaceDescriptor[] + ) { + // TODO: likely we will need to abandon number value in order to support custom fonts + const fontFamily = + FONT_FAMILY[family as keyof typeof FONT_FAMILY] ?? + FONT_FAMILY_FALLBACKS[family as keyof typeof FONT_FAMILY_FALLBACKS]; + + const registeredFamily = this.registered.get(fontFamily); + + if (!registeredFamily) { + this.registered.set(fontFamily, { + metadata, + fontFaces: fontFacesDecriptors.map( + ({ uri, descriptors }) => + new ExcalidrawFontFace(family, uri, descriptors), + ), + }); + } + + return this.registered; } /** @@ -248,57 +421,9 @@ export class Fonts { } /** - * Register a new font. - * - * @param family font family - * @param metadata font metadata - * @param fontFacesDecriptors font faces descriptors + * Get all the unique font families for the given elements. */ - private static register( - this: - | Fonts - | { - registered: Map< - number, - { metadata: FontMetadata; fontFaces: ExcalidrawFontFace[] } - >; - }, - family: string, - metadata: FontMetadata, - ...fontFacesDecriptors: ExcalidrawFontFaceDescriptor[] - ) { - // TODO: likely we will need to abandon number value in order to support custom fonts - const fontFamily = - FONT_FAMILY[family as keyof typeof FONT_FAMILY] ?? - FONT_FAMILY_FALLBACKS[family as keyof typeof FONT_FAMILY_FALLBACKS]; - - const registeredFamily = this.registered.get(fontFamily); - - if (!registeredFamily) { - this.registered.set(fontFamily, { - metadata, - fontFaces: fontFacesDecriptors.map( - ({ uri, descriptors }) => - new ExcalidrawFontFace(family, uri, descriptors), - ), - }); - } - - return this.registered; - } - - /** - * Gets all the font families for the given scene. - */ - public getSceneFamilies = () => { - return Fonts.getElementsFamilies(this.scene.getNonDeletedElements()); - }; - - private static getAllFamilies() { - return Array.from(Fonts.registered.keys()); - } - - private static getElementsFamilies( + private static getUniqueFamilies( elements: ReadonlyArray, ): Array { return Array.from( @@ -310,6 +435,51 @@ export class Fonts { }, new Set()), ); } + + /** + * Get all the unique characters per font family for the given scene. + */ + private static getCharsPerFamily( + elements: ReadonlyArray, + ): Record> { + const charsPerFamily: Record> = {}; + + for (const element of elements) { + if (!isTextElement(element)) { + continue; + } + + // gather unique codepoints only when inlining fonts + for (const char of element.originalText) { + if (!charsPerFamily[element.fontFamily]) { + charsPerFamily[element.fontFamily] = new Set(); + } + + charsPerFamily[element.fontFamily].add(char); + } + } + + return charsPerFamily; + } + + /** + * Get characters for a given family. + */ + private static getCharacters( + charsPerFamily: Record>, + family: number, + ) { + return charsPerFamily[family] + ? Array.from(charsPerFamily[family]).join("") + : ""; + } + + /** + * Get all registered font families. + */ + private static getAllFamilies() { + return Array.from(Fonts.registered.keys()); + } } /** diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 43e737be5..c4ab1b865 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -9,14 +9,7 @@ import type { import type { Bounds } from "../element/bounds"; import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds"; import { renderSceneToSvg } from "../renderer/staticSvgScene"; -import { - arrayToMap, - distance, - getFontString, - PromisePool, - promiseTry, - toBrandedType, -} from "../utils"; +import { arrayToMap, distance, getFontString, toBrandedType } from "../utils"; import type { AppState, BinaryFiles } from "../types"; import { DEFAULT_EXPORT_PADDING, @@ -25,9 +18,6 @@ import { SVG_NS, THEME, THEME_FILTER, - FONT_FAMILY_FALLBACKS, - getFontFamilyFallbacks, - CJK_HAND_DRAWN_FALLBACK_FONT, } from "../constants"; import { getDefaultAppState } from "../appState"; import { serializeAsJSON } from "../data/json"; @@ -44,12 +34,11 @@ import { import { newTextElement } from "../element"; import { type Mutable } from "../utility-types"; import { newElementWith } from "../element/mutateElement"; -import { isFrameLikeElement, isTextElement } from "../element/typeChecks"; +import { isFrameLikeElement } from "../element/typeChecks"; import type { RenderableElementsMap } from "./types"; import { syncInvalidIndices } from "../fractionalIndex"; import { renderStaticScene } from "../renderer/staticScene"; import { Fonts } from "../fonts"; -import { containsCJK } from "../element/textElement"; const SVG_EXPORT_TAG = ``; @@ -375,7 +364,10 @@ export const exportToSvg = async ( `; } - const fontFaces = opts?.skipInliningFonts ? [] : await getFontFaces(elements); + const fontFaces = !opts?.skipInliningFonts + ? await Fonts.generateFontFaceDeclarations(elements) + : []; + const delimiter = "\n "; // 6 spaces svgRoot.innerHTML = ` @@ -454,111 +446,3 @@ export const getExportSize = ( return [width, height]; }; - -const getFontFaces = async ( - elements: readonly ExcalidrawElement[], -): Promise => { - const fontFamilies = new Set(); - const charsPerFamily: Record> = {}; - - for (const element of elements) { - if (!isTextElement(element)) { - continue; - } - - fontFamilies.add(element.fontFamily); - - // gather unique codepoints only when inlining fonts - for (const char of element.originalText) { - if (!charsPerFamily[element.fontFamily]) { - charsPerFamily[element.fontFamily] = new Set(); - } - - charsPerFamily[element.fontFamily].add(char); - } - } - - const orderedFamilies = Array.from(fontFamilies); - - // for simplicity, assuming we have just one family with the CJK handdrawn fallback - const familyWithCJK = orderedFamilies.find((x) => - getFontFamilyFallbacks(x).includes(CJK_HAND_DRAWN_FALLBACK_FONT), - ); - - if (familyWithCJK) { - const characters = getChars(charsPerFamily[familyWithCJK]); - - if (containsCJK(characters)) { - const family = FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT]; - - // adding the same characters to the CJK handrawn family - charsPerFamily[family] = new Set(characters); - - // the order between the families and fallbacks is important, as fallbacks need to be defined first and in the reversed order - // so that they get overriden with the later defined font faces, i.e. in case they share some codepoints - orderedFamilies.unshift( - FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT], - ); - } - } - - const iterator = fontFacesIterator(orderedFamilies, charsPerFamily); - - // don't trigger hundreds of concurrent requests (each performing fetch, creating a worker, etc.), - // instead go three requests at a time, in a controlled manner, without completely blocking the main thread - // and avoiding potential issues such as rate limits - const concurrency = 3; - const fontFaces = await new PromisePool(iterator, concurrency).all(); - - // dedup just in case (i.e. could be the same font faces with 0 glyphs) - return Array.from(new Set(fontFaces)); -}; - -function* fontFacesIterator( - families: Array, - charsPerFamily: Record>, -): Generator> { - for (const [familyIndex, family] of families.entries()) { - const { fontFaces, metadata } = Fonts.registered.get(family) ?? {}; - - if (!Array.isArray(fontFaces)) { - console.error( - `Couldn't find registered fonts for font-family "${family}"`, - Fonts.registered, - ); - continue; - } - - if (metadata?.local) { - // don't inline local fonts - continue; - } - - for (const [fontFaceIndex, fontFace] of fontFaces.entries()) { - yield promiseTry(async () => { - try { - const characters = getChars(charsPerFamily[family]); - const fontFaceCSS = await fontFace.toCSS(characters); - - if (!fontFaceCSS) { - return; - } - - // giving a buffer of 10K font faces per family - const fontFaceOrder = familyIndex * 10_000 + fontFaceIndex; - const fontFaceTuple = [fontFaceOrder, fontFaceCSS] as const; - - return fontFaceTuple; - } catch (error) { - console.error( - `Couldn't transform font-face to css for family "${fontFace.fontFace.family}"`, - error, - ); - } - }); - } - } -} - -const getChars = (characterSet: Set) => - Array.from(characterSet).join(""); From dfaaff44320a1d18fcb20ffd65391cde39778bfc Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Wed, 30 Oct 2024 15:11:13 +0200 Subject: [PATCH 04/29] fix: fix trailing line whitespaces layout shift (#8714) --- .../excalidraw/element/textElement.test.ts | 35 ++++++++++++++++++ packages/excalidraw/element/textElement.ts | 37 ++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/element/textElement.test.ts b/packages/excalidraw/element/textElement.test.ts index 59727c22f..6275b762e 100644 --- a/packages/excalidraw/element/textElement.test.ts +++ b/packages/excalidraw/element/textElement.test.ts @@ -47,6 +47,41 @@ describe("Test wrapText", () => { expect(res).toBe("don't wrap this number\n99,100.99"); }); + it("should trim all trailing whitespaces", () => { + const text = "Hello "; + const maxWidth = 50; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hello"); + }); + + it("should trim all but one trailing whitespaces", () => { + const text = "Hello "; + const maxWidth = 60; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hello "); + }); + + it("should keep preceding whitespaces and trim all trailing whitespaces", () => { + const text = " Hello World"; + const maxWidth = 90; + const res = wrapText(text, font, maxWidth); + expect(res).toBe(" Hello\nWorld"); + }); + + it("should keep some preceding whitespaces, trim trailing whitespaces, but kep those that fit in the trailing line", () => { + const text = " Hello World "; + const maxWidth = 90; + const res = wrapText(text, font, maxWidth); + expect(res).toBe(" Hello\nWorld "); + }); + + it("should trim keep those whitespace that fit in the trailing line", () => { + const text = "Hello Wo rl d "; + const maxWidth = 100; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hello Wo\nrl d "); + }); + it("should support multiple (multi-codepoint) emojis", () => { const text = "😀🗺🔥👩🏽‍🦰👨‍👩‍👧‍👦🇨🇿"; const maxWidth = 1; diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 7618dba80..9d72961b5 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -681,7 +681,7 @@ const wrapLine = ( lines.push(...precedingLines); - // trailing line of the wrapped word might still be joined with next token/s + // trailing line of the wrapped word might -still be joined with next token/s currentLine = trailingLine; currentLineWidth = getLineWidth(trailingLine, font, true); iterator = tokenIterator.next(); @@ -697,12 +697,45 @@ const wrapLine = ( // iterator done, push the trailing line if exists if (currentLine) { - lines.push(currentLine.trimEnd()); + const trailingLine = trimTrailingLine(currentLine, font, maxWidth); + lines.push(trailingLine); } return lines; }; +// similarly to browsers, does not trim all whitespaces, but only those exceeding the maxWidth +const trimTrailingLine = (line: string, font: FontString, maxWidth: number) => { + const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth; + + if (!shouldTrimWhitespaces) { + return line; + } + + // 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, true); + + 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 trimmedLine; +}; + export const wrapText = ( text: string, font: FontString, From 2734e646ca8460b1343393416184fa615530abce Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Wed, 30 Oct 2024 15:24:12 +0200 Subject: [PATCH 05/29] chore: simplify line-break regexes, separate text wrapping (#8715) --- packages/excalidraw/components/App.tsx | 2 +- packages/excalidraw/element/embeddable.ts | 2 +- packages/excalidraw/element/newElement.ts | 2 +- packages/excalidraw/element/resizeElements.ts | 2 +- .../excalidraw/element/textElement.test.ts | 671 +----------------- packages/excalidraw/element/textElement.ts | 361 +--------- .../excalidraw/element/textWrapping.test.ts | 633 +++++++++++++++++ packages/excalidraw/element/textWrapping.ts | 568 +++++++++++++++ packages/excalidraw/element/textWysiwyg.tsx | 2 +- packages/excalidraw/fonts/Fonts.ts | 7 +- .../tests/linearElementEditor.test.tsx | 2 +- 11 files changed, 1213 insertions(+), 1039 deletions(-) create mode 100644 packages/excalidraw/element/textWrapping.test.ts create mode 100644 packages/excalidraw/element/textWrapping.ts diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 3cb187ab8..5723a0602 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -340,7 +340,6 @@ import { isValidTextContainer, measureText, normalizeText, - wrapText, } from "../element/textElement"; import { showHyperlinkTooltip, @@ -461,6 +460,7 @@ import { vectorNormalize, } from "../../math"; import { cropElement } from "../element/cropElement"; +import { wrapText } from "../element/textWrapping"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts index eada31a5b..9bc4a139b 100644 --- a/packages/excalidraw/element/embeddable.ts +++ b/packages/excalidraw/element/embeddable.ts @@ -4,7 +4,7 @@ import type { ExcalidrawProps } from "../types"; import { getFontString, updateActiveTool } from "../utils"; import { setCursorForShape } from "../cursor"; import { newTextElement } from "./newElement"; -import { wrapText } from "./textElement"; +import { wrapText } from "./textWrapping"; import { isIframeElement } from "./typeChecks"; import type { ExcalidrawElement, diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 55aa011f7..daeb06d5d 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -34,9 +34,9 @@ import { getResizedElementAbsoluteCoords } from "./bounds"; import { measureText, normalizeText, - wrapText, getBoundTextMaxWidth, } from "./textElement"; +import { wrapText } from "./textWrapping"; import { DEFAULT_ELEMENT_PROPS, DEFAULT_FONT_FAMILY, diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 08ca5543f..1fea04371 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -47,10 +47,10 @@ import { handleBindTextResize, getBoundTextMaxWidth, getApproxMinLineHeight, - wrapText, measureText, getMinTextElementWidth, } from "./textElement"; +import { wrapText } from "./textWrapping"; import { LinearElementEditor } from "./linearElementEditor"; import { isInGroup } from "../groups"; import { mutateElbowArrow } from "./routing"; diff --git a/packages/excalidraw/element/textElement.test.ts b/packages/excalidraw/element/textElement.test.ts index 6275b762e..cfc078c81 100644 --- a/packages/excalidraw/element/textElement.test.ts +++ b/packages/excalidraw/element/textElement.test.ts @@ -1,4 +1,4 @@ -import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; +import { FONT_FAMILY } from "../constants"; import { getLineHeight } from "../fonts"; import { API } from "../tests/helpers/api"; import { @@ -6,677 +6,10 @@ import { getContainerCoords, getBoundTextMaxWidth, getBoundTextMaxHeight, - wrapText, detectLineHeight, getLineHeightInPx, - parseTokens, } from "./textElement"; -import type { ExcalidrawTextElementWithContainer, FontString } from "./types"; - -describe("Test wrapText", () => { - // font is irrelevant as jsdom does not support FontFace API - // `measureText` width is mocked to return `text.length` by `jest-canvas-mock` - // https://github.com/hustcc/jest-canvas-mock/blob/master/src/classes/TextMetrics.js - const font = "10px Cascadia, Segoe UI Emoji" as FontString; - - it("should wrap the text correctly when word length is exactly equal to max width", () => { - const text = "Hello Excalidraw"; - // Length of "Excalidraw" is 100 and exacty equal to max width - const res = wrapText(text, font, 100); - expect(res).toEqual(`Hello\nExcalidraw`); - }); - - it("should return the text as is if max width is invalid", () => { - const text = "Hello Excalidraw"; - expect(wrapText(text, font, NaN)).toEqual(text); - expect(wrapText(text, font, -1)).toEqual(text); - expect(wrapText(text, font, Infinity)).toEqual(text); - }); - - it("should show the text correctly when max width reached", () => { - const text = "Hello😀"; - const maxWidth = 10; - const res = wrapText(text, font, maxWidth); - expect(res).toBe("H\ne\nl\nl\no\n😀"); - }); - - it("should not wrap number when wrapping line", () => { - const text = "don't wrap this number 99,100.99"; - const maxWidth = 300; - const res = wrapText(text, font, maxWidth); - expect(res).toBe("don't wrap this number\n99,100.99"); - }); - - it("should trim all trailing whitespaces", () => { - const text = "Hello "; - const maxWidth = 50; - const res = wrapText(text, font, maxWidth); - expect(res).toBe("Hello"); - }); - - it("should trim all but one trailing whitespaces", () => { - const text = "Hello "; - const maxWidth = 60; - const res = wrapText(text, font, maxWidth); - expect(res).toBe("Hello "); - }); - - it("should keep preceding whitespaces and trim all trailing whitespaces", () => { - const text = " Hello World"; - const maxWidth = 90; - const res = wrapText(text, font, maxWidth); - expect(res).toBe(" Hello\nWorld"); - }); - - it("should keep some preceding whitespaces, trim trailing whitespaces, but kep those that fit in the trailing line", () => { - const text = " Hello World "; - const maxWidth = 90; - const res = wrapText(text, font, maxWidth); - expect(res).toBe(" Hello\nWorld "); - }); - - it("should trim keep those whitespace that fit in the trailing line", () => { - const text = "Hello Wo rl d "; - const maxWidth = 100; - const res = wrapText(text, font, maxWidth); - expect(res).toBe("Hello Wo\nrl d "); - }); - - it("should support multiple (multi-codepoint) emojis", () => { - const text = "😀🗺🔥👩🏽‍🦰👨‍👩‍👧‍👦🇨🇿"; - const maxWidth = 1; - const res = wrapText(text, font, maxWidth); - expect(res).toBe("😀\n🗺\n🔥\n👩🏽‍🦰\n👨‍👩‍👧‍👦\n🇨🇿"); - }); - - it("should wrap the text correctly when text contains hyphen", () => { - let text = - "Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects"; - const res = wrapText(text, font, 110); - expect(res).toBe( - `Wikipedia\nis hosted\nby\nWikimedia-\nFoundation,\na non-\nprofit\norganizatio\nn that also\nhosts a\nrange-of\nother\nprojects`, - ); - - text = "Hello thereusing-now"; - expect(wrapText(text, font, 100)).toEqual("Hello\nthereusing\n-now"); - }); - - it("should support wrapping nested lists", () => { - const text = `\tA) one tab\t\t- two tabs - 8 spaces`; - - const maxWidth = 100; - const res = wrapText(text, font, maxWidth); - expect(res).toBe(`\tA) one\ntab\t\t- two\ntabs\n- 8 spaces`); - - const maxWidth2 = 50; - const res2 = wrapText(text, font, maxWidth2); - expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`); - }); - - describe("When text is CJK", () => { - it("should break each CJK character when width is very small", () => { - // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" - const text = "안녕하세요こんにちは世界コンニチハ你好"; - const maxWidth = 10; - const res = wrapText(text, font, maxWidth); - expect(res).toBe( - "안\n녕\n하\n세\n요\nこ\nん\nに\nち\nは\n世\n界\nコ\nン\nニ\nチ\nハ\n你\n好", - ); - }); - - it("should break CJK text into longer segments when width is larger", () => { - // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" - const text = "안녕하세요こんにちは世界コンニチハ你好"; - const maxWidth = 30; - const res = wrapText(text, font, maxWidth); - - // measureText is mocked, so it's not precisely what would happen in prod - expect(res).toBe("안녕하\n세요こ\nんにち\nは世界\nコンニ\nチハ你\n好"); - }); - - it("should handle a combination of CJK, latin, emojis and whitespaces", () => { - const text = `a醫 醫 bb 你好 world-i-😀🗺🔥`; - - const maxWidth = 150; - const res = wrapText(text, font, maxWidth); - expect(res).toBe(`a醫 醫 bb 你\n好 world-i-😀🗺\n🔥`); - - const maxWidth2 = 50; - const res2 = wrapText(text, font, maxWidth2); - expect(res2).toBe(`a醫 醫\nbb 你\n好\nworld\n-i-😀\n🗺🔥`); - - const maxWidth3 = 30; - const res3 = wrapText(text, font, maxWidth3); - expect(res3).toBe(`a醫\n醫\nbb\n你好\nwor\nld-\ni-\n😀\n🗺\n🔥`); - }); - - it("should break before and after a regular CJK character", () => { - const text = "HelloたWorld"; - const maxWidth1 = 50; - const res1 = wrapText(text, font, maxWidth1); - expect(res1).toBe("Hello\nた\nWorld"); - - const maxWidth2 = 60; - const res2 = wrapText(text, font, maxWidth2); - expect(res2).toBe("Helloた\nWorld"); - }); - - it("should break before and after certain CJK symbols", () => { - const text = "こんにちは〃世界"; - const maxWidth1 = 50; - const res1 = wrapText(text, font, maxWidth1); - expect(res1).toBe("こんにちは\n〃世界"); - - const maxWidth2 = 60; - const res2 = wrapText(text, font, maxWidth2); - expect(res2).toBe("こんにちは〃\n世界"); - }); - - it("should break after, not before for certain CJK pairs", () => { - const text = "Hello た。"; - const maxWidth = 70; - const res = wrapText(text, font, maxWidth); - expect(res).toBe("Hello\nた。"); - }); - - it("should break before, not after for certain CJK pairs", () => { - const text = "Hello「たWorld」"; - const maxWidth = 60; - const res = wrapText(text, font, maxWidth); - expect(res).toBe("Hello\n「た\nWorld」"); - }); - - it("should break after, not before for certain CJK character pairs", () => { - const text = "「Helloた」World"; - const maxWidth = 70; - const res = wrapText(text, font, maxWidth); - expect(res).toBe("「Hello\nた」World"); - }); - - it("should break Chinese sentences", () => { - const text = `中国你好!这是一个测试。 -我们来看看:人民币¥1234「很贵」 -(括号)、逗号,句号。空格 换行 全角符号…—`; - - const maxWidth1 = 80; - const res1 = wrapText(text, font, maxWidth1); - expect(res1).toBe(`中国你好!这是一\n个测试。 -我们来看看:人民\n币¥1234「很\n贵」 -(括号)、逗号,\n句号。空格 换行\n全角符号…—`); - - const maxWidth2 = 50; - const res2 = wrapText(text, font, maxWidth2); - expect(res2).toBe(`中国你好!\n这是一个测\n试。 -我们来看\n看:人民币\n¥1234\n「很贵」 -(括号)、\n逗号,句\n号。空格\n换行 全角\n符号…—`); - }); - }); - - it("should break Japanese sentences", () => { - const text = `日本こんにちは!これはテストです。 - 見てみましょう:円¥1234「高い」 - (括弧)、読点、句点。 - 空白 改行 全角記号…ー`; - - const maxWidth1 = 80; - const res1 = wrapText(text, font, maxWidth1); - expect(res1).toBe(`日本こんにちは!\nこれはテストで\nす。 - 見てみましょ\nう:円¥1234\n「高い」 - (括弧)、読\n点、句点。 - 空白 改行\n全角記号…ー`); - - const maxWidth2 = 50; - const res2 = wrapText(text, font, maxWidth2); - expect(res2).toBe(`日本こんに\nちは!これ\nはテストで\nす。 - 見てみ\nましょう:\n円\n¥1234\n「高い」 - (括\n弧)、読\n点、句点。 - 空白\n改行 全角\n記号…ー`); - }); - - it("should break Korean sentences", () => { - const text = `한국 안녕하세요! 이것은 테스트입니다. -우리 보자: 원화₩1234「비싸다」 -(괄호), 쉼표, 마침표. -공백 줄바꿈 전각기호…—`; - - const maxWidth1 = 80; - const res1 = wrapText(text, font, maxWidth1); - expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다. -우리 보자: 원\n화₩1234「비\n싸다」 -(괄호), 쉼\n표, 마침표. -공백 줄바꿈 전\n각기호…—`); - - const maxWidth2 = 60; - const res2 = wrapText(text, font, maxWidth2); - expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다. -우리 보자:\n원화\n₩1234\n「비싸다」 -(괄호),\n쉼표, 마침\n표. -공백 줄바꿈\n전각기호…—`); - }); - - describe("When text contains leading whitespaces", () => { - const text = " \t Hello world"; - - it("should preserve leading whitespaces", () => { - const maxWidth = 120; - const res = wrapText(text, font, maxWidth); - expect(res).toBe(" \t Hello\nworld"); - }); - - it("should break and collapse leading whitespaces when line breaks", () => { - const maxWidth = 60; - const res = wrapText(text, font, maxWidth); - expect(res).toBe("\nHello\nworld"); - }); - - it("should break and collapse leading whitespaces whe words break", () => { - const maxWidth = 30; - const res = wrapText(text, font, maxWidth); - expect(res).toBe("\nHel\nlo\nwor\nld"); - }); - }); - - describe("When text contains trailing whitespaces", () => { - it("shouldn't add new lines for trailing spaces", () => { - const text = "Hello whats up "; - const maxWidth = 200 - BOUND_TEXT_PADDING * 2; - const res = wrapText(text, font, maxWidth); - expect(res).toBe(text); - }); - - it("should ignore trailing whitespaces when line breaks", () => { - const text = "Hippopotomonstrosesquippedaliophobia ??????"; - const maxWidth = 400; - const res = wrapText(text, font, maxWidth); - expect(res).toBe("Hippopotomonstrosesquippedaliophobia\n??????"); - }); - - it("should not ignore trailing whitespaces when word breaks", () => { - const text = "Hippopotomonstrosesquippedaliophobia ??????"; - const maxWidth = 300; - const res = wrapText(text, font, maxWidth); - expect(res).toBe("Hippopotomonstrosesquippedalio\nphobia ??????"); - }); - - it("should ignore trailing whitespaces when word breaks and line breaks", () => { - const text = "Hippopotomonstrosesquippedaliophobia ??????"; - const maxWidth = 180; - const res = wrapText(text, font, maxWidth); - expect(res).toBe("Hippopotomonstrose\nsquippedaliophobia\n??????"); - }); - }); - - describe("When text doesn't contain new lines", () => { - const text = "Hello whats up"; - - [ - { - desc: "break all words when width of each word is less than container width", - width: 80, - res: `Hello\nwhats\nup`, - }, - { - desc: "break all characters when width of each character is less than container width", - width: 25, - res: `H -e -l -l -o -w -h -a -t -s -u -p`, - }, - { - desc: "break words as per the width", - - width: 140, - res: `Hello whats\nup`, - }, - { - desc: "fit the container", - - width: 250, - res: "Hello whats up", - }, - { - desc: "should push the word if its equal to max width", - width: 60, - res: `Hello -whats -up`, - }, - ].forEach((data) => { - it(`should ${data.desc}`, () => { - const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); - expect(res).toEqual(data.res); - }); - }); - }); - - describe("When text contain new lines", () => { - const text = `Hello -whats up`; - [ - { - desc: "break all words when width of each word is less than container width", - width: 80, - res: `Hello\nwhats\nup`, - }, - { - desc: "break all characters when width of each character is less than container width", - width: 25, - res: `H -e -l -l -o -w -h -a -t -s -u -p`, - }, - { - desc: "break words as per the width", - - width: 150, - res: `Hello -whats up`, - }, - { - desc: "fit the container", - - width: 250, - res: `Hello -whats up`, - }, - ].forEach((data) => { - it(`should respect new lines and ${data.desc}`, () => { - const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); - expect(res).toEqual(data.res); - }); - }); - }); - - describe("When text is long", () => { - const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`; - [ - { - desc: "fit characters of long string as per container width", - width: 170, - res: `hellolongtextthi\nsiswhatsupwithyo\nuIamtypingggggan\ndtypinggg break\nit now`, - }, - { - desc: "fit characters of long string as per container width and break words as per the width", - - width: 130, - res: `hellolongtex -tthisiswhats -upwithyouIam -typingggggan -dtypinggg -break it now`, - }, - { - desc: "fit the long text when container width is greater than text length and move the rest to next line", - - width: 600, - res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`, - }, - ].forEach((data) => { - it(`should ${data.desc}`, () => { - const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); - expect(res).toEqual(data.res); - }); - }); - }); - - describe("Test parseTokens", () => { - it("should tokenize latin", () => { - let text = "Excalidraw is a virtual collaborative whiteboard"; - - expect(parseTokens(text)).toEqual([ - "Excalidraw", - " ", - "is", - " ", - "a", - " ", - "virtual", - " ", - "collaborative", - " ", - "whiteboard", - ]); - - text = - "Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects"; - expect(parseTokens(text)).toEqual([ - "Wikipedia", - " ", - "is", - " ", - "hosted", - " ", - "by", - " ", - "Wikimedia-", - " ", - "Foundation,", - " ", - "a", - " ", - "non-", - "profit", - " ", - "organization", - " ", - "that", - " ", - "also", - " ", - "hosts", - " ", - "a", - " ", - "range-", - "of", - " ", - "other", - " ", - "projects", - ]); - }); - - it("should not tokenize number", () => { - const text = "99,100.99"; - const tokens = parseTokens(text); - expect(tokens).toEqual(["99,100.99"]); - }); - - it("should tokenize joined emojis", () => { - const text = `😬🌍🗺🔥☂️👩🏽‍🦰👨‍👩‍👧‍👦👩🏾‍🔬🏳️‍🌈🧔‍♀️🧑‍🤝‍🧑🙅🏽‍♂️✅0️⃣🇨🇿🦅`; - const tokens = parseTokens(text); - - expect(tokens).toEqual([ - "😬", - "🌍", - "🗺", - "🔥", - "☂️", - "👩🏽‍🦰", - "👨‍👩‍👧‍👦", - "👩🏾‍🔬", - "🏳️‍🌈", - "🧔‍♀️", - "🧑‍🤝‍🧑", - "🙅🏽‍♂️", - "✅", - "0️⃣", - "🇨🇿", - "🦅", - ]); - }); - - it("should tokenize emojis mixed with mixed text", () => { - const text = `😬a🌍b🗺c🔥d☂️《👩🏽‍🦰》👨‍👩‍👧‍👦德👩🏾‍🔬こ🏳️‍🌈안🧔‍♀️g🧑‍🤝‍🧑h🙅🏽‍♂️e✅f0️⃣g🇨🇿10🦅#hash`; - const tokens = parseTokens(text); - - expect(tokens).toEqual([ - "😬", - "a", - "🌍", - "b", - "🗺", - "c", - "🔥", - "d", - "☂️", - "《", - "👩🏽‍🦰", - "》", - "👨‍👩‍👧‍👦", - "德", - "👩🏾‍🔬", - "こ", - "🏳️‍🌈", - "안", - "🧔‍♀️", - "g", - "🧑‍🤝‍🧑", - "h", - "🙅🏽‍♂️", - "e", - "✅", - "f0️⃣g", // bummer, but ok, as we traded kecaps not breaking (less common) for hash and numbers not breaking (more common) - "🇨🇿", - "10", // nice! do not break the number, as it's by default matched by \p{Emoji} - "🦅", - "#hash", // nice! do not break the hash, as it's by default matched by \p{Emoji} - ]); - }); - - it("should tokenize decomposed chars into their composed variants", () => { - // each input character is in a decomposed form - const text = "čでäぴέ다й한"; - expect(text.normalize("NFC").length).toEqual(8); - expect(text).toEqual(text.normalize("NFD")); - - const tokens = parseTokens(text); - expect(tokens.length).toEqual(8); - expect(tokens).toEqual(["č", "で", "ä", "ぴ", "έ", "다", "й", "한"]); - }); - - it("should tokenize artificial CJK", () => { - const text = `《道德經》醫-醫こんにちは世界!안녕하세요세계;다.다...원/달(((다)))[[1]]〚({((한))>)〛た…[Hello] World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`; - - // [ - // '《道', '德', '經》', '醫-', - // '醫', 'こ', 'ん', 'に', - // 'ち', 'は', '世', '界!', - // '안', '녕', '하', '세', - // '요', '세', '계;', '다.', - // '다...', '원/', '달', '(((다)))', - // '[[1]]', '〚({((한))>)〛', 'た…', '[Hello]', - // ' ', 'World?', 'ニ', 'ュ', - // 'ー', 'ヨ', 'ー', 'ク・', - // '¥3700.55', 'す。', '090-', '1234-', - // '5678¥1,000', '〜', '$5,000', '「素', - // '晴', 'ら', 'し', 'い!」', - // '〔重', '要〕', '#', '1:', - // 'Taro', '君', '30%', 'は、', - // '(た', 'な', 'ば', 'た)', - // '〰', '¥110±', '¥570', 'で', - // '20℃', '〜', '9:30', '〜', - // '10:00', '【一', '番】' - // ] - const tokens = parseTokens(text); - - // Latin - expect(tokens).toContain("[[1]]"); - expect(tokens).toContain("[Hello]"); - expect(tokens).toContain("World?"); - expect(tokens).toContain("Taro"); - - // Chinese - expect(tokens).toContain("《道"); - expect(tokens).toContain("德"); - expect(tokens).toContain("經》"); - expect(tokens).toContain("醫-"); - expect(tokens).toContain("醫"); - - // Japanese - expect(tokens).toContain("こ"); - expect(tokens).toContain("ん"); - expect(tokens).toContain("に"); - expect(tokens).toContain("ち"); - expect(tokens).toContain("は"); - expect(tokens).toContain("世"); - expect(tokens).toContain("ニ"); - expect(tokens).toContain("ク・"); - expect(tokens).toContain("界!"); - expect(tokens).toContain("た…"); - expect(tokens).toContain("す。"); - expect(tokens).toContain("ュ"); - expect(tokens).toContain("ー"); - expect(tokens).toContain("「素"); - expect(tokens).toContain("晴"); - expect(tokens).toContain("ら"); - expect(tokens).toContain("し"); - expect(tokens).toContain("い!」"); - expect(tokens).toContain("君"); - expect(tokens).toContain("は、"); - expect(tokens).toContain("(た"); - expect(tokens).toContain("な"); - expect(tokens).toContain("ば"); - expect(tokens).toContain("た)"); - expect(tokens).toContain("で"); - expect(tokens).toContain("【一"); - expect(tokens).toContain("番】"); - - // Check for Korean - expect(tokens).toContain("안"); - expect(tokens).toContain("녕"); - expect(tokens).toContain("하"); - expect(tokens).toContain("세"); - expect(tokens).toContain("요"); - expect(tokens).toContain("세"); - expect(tokens).toContain("계;"); - expect(tokens).toContain("다."); - expect(tokens).toContain("다..."); - expect(tokens).toContain("원/"); - expect(tokens).toContain("달"); - expect(tokens).toContain("(((다)))"); - expect(tokens).toContain("〚({((한))>)〛"); - - // Numbers and units - expect(tokens).toContain("¥3700.55"); - expect(tokens).toContain("090-"); - expect(tokens).toContain("1234-"); - expect(tokens).toContain("5678¥1,000"); - expect(tokens).toContain("$5,000"); - expect(tokens).toContain("1:"); - expect(tokens).toContain("30%"); - expect(tokens).toContain("¥110±"); - expect(tokens).toContain("¥570"); - expect(tokens).toContain("20℃"); - expect(tokens).toContain("9:30"); - expect(tokens).toContain("10:00"); - - // Punctuation and symbols - expect(tokens).toContain("〜"); - expect(tokens).toContain("〰"); - expect(tokens).toContain("#"); - }); - }); -}); +import type { ExcalidrawTextElementWithContainer } from "./types"; describe("Test measureText", () => { describe("Test getContainerCoords", () => { diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 9d72961b5..8c4bc5988 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -16,12 +16,12 @@ import { BOUND_TEXT_PADDING, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, - ENV, TEXT_ALIGN, VERTICAL_ALIGN, } from "../constants"; import type { MaybeTransformHandleType } from "./transformHandles"; import { isTextElement } from "."; +import { wrapText } from "./textWrapping"; import { isBoundToContainer, isArrowElement } from "./typeChecks"; import { LinearElementEditor } from "./linearElementEditor"; import type { AppState } from "../types"; @@ -31,172 +31,6 @@ import { } from "./containerCache"; import type { ExtractSetType } from "../utility-types"; -/** - * 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: ZWJ (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 do not understand the abbreviation (i.e. https://devina.io/redos-checker) - */ -const _EMOJI_CHAR = - /(\p{RI}\p{RI}|[\p{Extended_Pictographic}\p{Emoji_Presentation}](?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?(?:\u200D(?:\p{RI}\p{RI}|[\p{Emoji}](?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?))*)/u; - -/** - * Detect a CJK char, though does not include every possible char used in CJK texts, - * such as symbols and punctuations. - * - * By default every CJK is a breaking point, though CJK has additional breaking points, - * including full width punctuations or symbols (Chinese and Japanese) and western punctuations (Korean). - * - * Additional CJK breaking point rules: - * - expect a break before (lookahead), but not after (negative lookbehind), i.e. "(" or "(" - * - expect a break after (lookbehind), but not before (negative lookahead), i.e. ")" or ")" - * - expect a break always (lookahead and lookbehind), i.e. "〃" - */ -const _CJK_CHAR = - /\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}/u; - -/** - * Following characters break only with CJK, not with alphabetic characters. - * This is essential for Korean, as it uses alphabetic punctuation, but expects CJK-like breaking points. - * - * Hello((た)) → ["Hello", "((た))"] - * Hello((World)) → ["Hello((World))"] - */ -const _CJK_BREAK_NOT_AFTER_BUT_BEFORE = /<\(\[\{/u; -const _CJK_BREAK_NOT_BEFORE_BUT_AFTER = />\)\]\}.,:;\?!/u; -const _CJK_BREAK_ALWAYS = / 〃〜~〰#&*+-ー/=|¬ ̄¦/u; -const _CJK_SYMBOLS_AND_PUNCTUATION = - /()[]{}〈〉《》⦅⦆「」「」『』【】〖〗〔〕〘〙〚〛<>〝〞'〟・。゚゙,、.:;?!%ー/u; - -/** - * Following characters break with any character, even though are mostly used with CJK. - * - * Hello た。→ ["Hello", "た。"] - * ↑ DON'T BREAK "た。" (negative lookahead) - * Hello「た」 World → ["Hello", "「た」", "World"] - * ↑ DON'T BREAK "「た" (negative lookbehind) - * ↑ DON'T BREAK "た」"(negative lookahead) - * ↑ BREAK BEFORE "「" (lookahead) - * ↑ BREAK AFTER "」" (lookbehind) - */ -const _ANY_BREAK_NOT_AFTER_BUT_BEFORE = /([{〈《⦅「「『【〖〔〘〚<〝/u; -const _ANY_BREAK_NOT_BEFORE_BUT_AFTER = - /)]}〉》⦆」」』】〗〕〙〛>〞'〟・。゚゙,、.:;?!%±‥…\//u; - -/** - * Natural breaking points for any grammars. - * - * Hello-world - * ↑ BREAK AFTER "-" → ["Hello-", "world"] - * Hello world - * ↑ BREAK ALWAYS " " → ["Hello", " ", "world"] - */ -const _ANY_BREAK_AFTER = /-/u; -const _ANY_BREAK_ALWAYS = /\s/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 BREAK_LINE_REGEX_SIMPLE = new RegExp( - `${_EMOJI_CHAR.source}|([${_ANY_BREAK_ALWAYS.source}${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}${_ANY_BREAK_AFTER.source}])`, - "u", -); - -// Hello World → ["Hello", " World"] -// ↑ BREAK BEFORE " " -// HelloたWorld → ["Hello", "たWorld"] -// ↑ BREAK BEFORE "た" -// Hello「World」→ ["Hello", "「World」"] -// ↑ BREAK BEFORE "「" -const getLookaheadBreakingPoints = () => { - const ANY_BREAKING_POINT = `(? { - const ANY_BREAKING_POINT = `(?![${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}])(?<=[${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}${_ANY_BREAK_ALWAYS.source}${_ANY_BREAK_AFTER.source}])`; - const CJK_BREAKING_POINT = `(?![${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}${_CJK_BREAK_NOT_BEFORE_BUT_AFTER.source}${_ANY_BREAK_AFTER.source}])(?<=[${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}][${_CJK_BREAK_NOT_BEFORE_BUT_AFTER.source}]*)`; - return new RegExp(`(?:${ANY_BREAKING_POINT}|${CJK_BREAKING_POINT})`, "u"); -}; - -/** - * Break a line based on the whitespaces, CJK / emoji chars and language specific breaking points, - * like hyphen for alphabetic and various full-width codepoints for CJK - especially Japanese, e.g.: - * - * "Hello 世界。🌎🗺" → ["Hello", " ", "世", "界。", "🌎", "🗺"] - * "Hello-world" → ["Hello-", "world"] - * "「Hello World」" → ["「Hello", " ", "World」"] - */ -const getBreakLineRegexAdvanced = () => - new RegExp( - `${_EMOJI_CHAR.source}|${getLookaheadBreakingPoints().source}|${ - getLookbehindBreakingPoints().source - }`, - "u", - ); - -let cachedBreakLineRegex: RegExp | undefined; - -// Lazy-load for browsers that don't support "Lookbehind assertion" -const getBreakLineRegex = () => { - if (!cachedBreakLineRegex) { - try { - cachedBreakLineRegex = getBreakLineRegexAdvanced(); - } catch { - cachedBreakLineRegex = BREAK_LINE_REGEX_SIMPLE; - } - } - - return cachedBreakLineRegex; -}; - -const CJK_REGEX = new RegExp( - `[${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}${_CJK_SYMBOLS_AND_PUNCTUATION.source}]`, - "u", -); - -const EMOJI_REGEX = new RegExp(`${_EMOJI_CHAR.source}`, "u"); - -export const containsCJK = (text: string) => { - return CJK_REGEX.test(text); -}; - -export const containsEmoji = (text: string) => { - return EMOJI_REGEX.test(text); -}; - export const normalizeText = (text: string) => { return ( normalizeEOL(text) @@ -510,7 +344,7 @@ let canvas: HTMLCanvasElement | undefined; * * `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies. */ -const getLineWidth = ( +export const getLineWidth = ( text: string, font: FontString, forceAdvanceWidth?: true, @@ -575,197 +409,6 @@ export const getTextHeight = ( return getLineHeightInPx(fontSize, lineHeight) * lineCount; }; -export const parseTokens = (line: string) => { - const breakLineRegex = getBreakLineRegex(); - - // 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); -}; - -// 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 - ); -}; - -const satisfiesWordInvariant = (word: string) => { - if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { - if (/\s/.test(word)) { - throw new Error("Word should not contain any whitespaces!"); - } - } -}; - -const wrapWord = ( - word: string, - font: FontString, - maxWidth: number, -): Array => { - // multi-codepoint emojis are already broken apart and shouldn't be broken further - if (EMOJI_REGEX.test(word)) { - return [word]; - } - - satisfiesWordInvariant(word); - - const lines: Array = []; - const chars = Array.from(word); - - let currentLine = ""; - let currentLineWidth = 0; - - for (const char of chars) { - const _charWidth = charWidth.calculate(char, font); - const testLineWidth = currentLineWidth + _charWidth; - - if (testLineWidth <= maxWidth) { - currentLine = currentLine + char; - currentLineWidth = testLineWidth; - continue; - } - - if (currentLine) { - lines.push(currentLine); - } - - currentLine = char; - currentLineWidth = _charWidth; - } - - if (currentLine) { - lines.push(currentLine); - } - - return lines; -}; - -const wrapLine = ( - line: string, - font: FontString, - maxWidth: number, -): string[] => { - const lines: Array = []; - const tokens = parseTokens(line); - const tokenIterator = tokens[Symbol.iterator](); - - let currentLine = ""; - let currentLineWidth = 0; - - let iterator = tokenIterator.next(); - - while (!iterator.done) { - const token = iterator.value; - 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, true); - - // build up the current line, skipping length check for possibly trailing whitespaces - if (/\s/.test(token) || testLineWidth <= maxWidth) { - currentLine = testLine; - currentLineWidth = testLineWidth; - iterator = tokenIterator.next(); - 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); - const trailingLine = wrappedWord[wrappedWord.length - 1] ?? ""; - 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; - currentLineWidth = getLineWidth(trailingLine, font, true); - iterator = tokenIterator.next(); - } else { - // push & reset, but don't iterate on the next token, as we didn't use it yet! - lines.push(currentLine.trimEnd()); - - // purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above - currentLine = ""; - currentLineWidth = 0; - } - } - - // iterator done, push the trailing line if exists - if (currentLine) { - const trailingLine = trimTrailingLine(currentLine, font, maxWidth); - lines.push(trailingLine); - } - - return lines; -}; - -// similarly to browsers, does not trim all whitespaces, but only those exceeding the maxWidth -const trimTrailingLine = (line: string, font: FontString, maxWidth: number) => { - const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth; - - if (!shouldTrimWhitespaces) { - return line; - } - - // 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, true); - - 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 trimmedLine; -}; - -export const wrapText = ( - text: string, - font: FontString, - 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"); - - for (const originalLine of originalLines) { - const currentLineWidth = getLineWidth(originalLine, font, true); - - if (currentLineWidth <= maxWidth) { - lines.push(originalLine); - continue; - } - - const wrappedLine = wrapLine(originalLine, font, maxWidth); - lines.push(...wrappedLine); - } - - return lines.join("\n"); -}; - export const charWidth = (() => { const cachedCharWidth: { [key: FontString]: Array } = {}; diff --git a/packages/excalidraw/element/textWrapping.test.ts b/packages/excalidraw/element/textWrapping.test.ts new file mode 100644 index 000000000..6c7bcb819 --- /dev/null +++ b/packages/excalidraw/element/textWrapping.test.ts @@ -0,0 +1,633 @@ +import { wrapText, parseTokens } from "./textWrapping"; +import type { FontString } from "./types"; + +describe("Test wrapText", () => { + // font is irrelevant as jsdom does not support FontFace API + // `measureText` width is mocked to return `text.length` by `jest-canvas-mock` + // https://github.com/hustcc/jest-canvas-mock/blob/master/src/classes/TextMetrics.js + const font = "10px Cascadia, Segoe UI Emoji" as FontString; + + it("should wrap the text correctly when word length is exactly equal to max width", () => { + const text = "Hello Excalidraw"; + // Length of "Excalidraw" is 100 and exacty equal to max width + const res = wrapText(text, font, 100); + expect(res).toEqual(`Hello\nExcalidraw`); + }); + + it("should return the text as is if max width is invalid", () => { + const text = "Hello Excalidraw"; + expect(wrapText(text, font, NaN)).toEqual(text); + expect(wrapText(text, font, -1)).toEqual(text); + expect(wrapText(text, font, Infinity)).toEqual(text); + }); + + it("should show the text correctly when max width reached", () => { + const text = "Hello😀"; + const maxWidth = 10; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("H\ne\nl\nl\no\n😀"); + }); + + it("should not wrap number when wrapping line", () => { + const text = "don't wrap this number 99,100.99"; + const maxWidth = 300; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("don't wrap this number\n99,100.99"); + }); + + it("should trim all trailing whitespaces", () => { + const text = "Hello "; + const maxWidth = 50; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hello"); + }); + + it("should trim all but one trailing whitespaces", () => { + const text = "Hello "; + const maxWidth = 60; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hello "); + }); + + it("should keep preceding whitespaces and trim all trailing whitespaces", () => { + const text = " Hello World"; + const maxWidth = 90; + const res = wrapText(text, font, maxWidth); + expect(res).toBe(" Hello\nWorld"); + }); + + it("should keep some preceding whitespaces, trim trailing whitespaces, but kep those that fit in the trailing line", () => { + const text = " Hello World "; + const maxWidth = 90; + const res = wrapText(text, font, maxWidth); + expect(res).toBe(" Hello\nWorld "); + }); + + it("should trim keep those whitespace that fit in the trailing line", () => { + const text = "Hello Wo rl d "; + const maxWidth = 100; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hello Wo\nrl d "); + }); + + it("should support multiple (multi-codepoint) emojis", () => { + const text = "😀🗺🔥👩🏽‍🦰👨‍👩‍👧‍👦🇨🇿"; + const maxWidth = 1; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("😀\n🗺\n🔥\n👩🏽‍🦰\n👨‍👩‍👧‍👦\n🇨🇿"); + }); + + it("should wrap the text correctly when text contains hyphen", () => { + let text = + "Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects"; + const res = wrapText(text, font, 110); + expect(res).toBe( + `Wikipedia\nis hosted\nby\nWikimedia-\nFoundation,\na non-\nprofit\norganizatio\nn that also\nhosts a\nrange-of\nother\nprojects`, + ); + + text = "Hello thereusing-now"; + expect(wrapText(text, font, 100)).toEqual("Hello\nthereusing\n-now"); + }); + + it("should support wrapping nested lists", () => { + const text = `\tA) one tab\t\t- two tabs - 8 spaces`; + + const maxWidth = 100; + const res = wrapText(text, font, maxWidth); + expect(res).toBe(`\tA) one\ntab\t\t- two\ntabs\n- 8 spaces`); + + const maxWidth2 = 50; + const res2 = wrapText(text, font, maxWidth2); + expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`); + }); + + describe("When text is CJK", () => { + it("should break each CJK character when width is very small", () => { + // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" + const text = "안녕하세요こんにちは世界コンニチハ你好"; + const maxWidth = 10; + const res = wrapText(text, font, maxWidth); + expect(res).toBe( + "안\n녕\n하\n세\n요\nこ\nん\nに\nち\nは\n世\n界\nコ\nン\nニ\nチ\nハ\n你\n好", + ); + }); + + it("should break CJK text into longer segments when width is larger", () => { + // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" + const text = "안녕하세요こんにちは世界コンニチハ你好"; + const maxWidth = 30; + const res = wrapText(text, font, maxWidth); + + // measureText is mocked, so it's not precisely what would happen in prod + expect(res).toBe("안녕하\n세요こ\nんにち\nは世界\nコンニ\nチハ你\n好"); + }); + + it("should handle a combination of CJK, latin, emojis and whitespaces", () => { + const text = `a醫 醫 bb 你好 world-i-😀🗺🔥`; + + const maxWidth = 150; + const res = wrapText(text, font, maxWidth); + expect(res).toBe(`a醫 醫 bb 你\n好 world-i-😀🗺\n🔥`); + + const maxWidth2 = 50; + const res2 = wrapText(text, font, maxWidth2); + expect(res2).toBe(`a醫 醫\nbb 你\n好\nworld\n-i-😀\n🗺🔥`); + + const maxWidth3 = 30; + const res3 = wrapText(text, font, maxWidth3); + expect(res3).toBe(`a醫\n醫\nbb\n你好\nwor\nld-\ni-\n😀\n🗺\n🔥`); + }); + + it("should break before and after a regular CJK character", () => { + const text = "HelloたWorld"; + const maxWidth1 = 50; + const res1 = wrapText(text, font, maxWidth1); + expect(res1).toBe("Hello\nた\nWorld"); + + const maxWidth2 = 60; + const res2 = wrapText(text, font, maxWidth2); + expect(res2).toBe("Helloた\nWorld"); + }); + + it("should break before and after certain CJK symbols", () => { + const text = "こんにちは〃世界"; + const maxWidth1 = 50; + const res1 = wrapText(text, font, maxWidth1); + expect(res1).toBe("こんにちは\n〃世界"); + + const maxWidth2 = 60; + const res2 = wrapText(text, font, maxWidth2); + expect(res2).toBe("こんにちは〃\n世界"); + }); + + it("should break after, not before for certain CJK pairs", () => { + const text = "Hello た。"; + const maxWidth = 70; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hello\nた。"); + }); + + it("should break before, not after for certain CJK pairs", () => { + const text = "Hello「たWorld」"; + const maxWidth = 60; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hello\n「た\nWorld」"); + }); + + it("should break after, not before for certain CJK character pairs", () => { + const text = "「Helloた」World"; + const maxWidth = 70; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("「Hello\nた」World"); + }); + + it("should break Chinese sentences", () => { + const text = `中国你好!这是一个测试。 +我们来看看:人民币¥1234「很贵」 +(括号)、逗号,句号。空格 换行 全角符号…—`; + + const maxWidth1 = 80; + const res1 = wrapText(text, font, maxWidth1); + expect(res1).toBe(`中国你好!这是一\n个测试。 +我们来看看:人民\n币¥1234「很\n贵」 +(括号)、逗号,\n句号。空格 换行\n全角符号…—`); + + const maxWidth2 = 50; + const res2 = wrapText(text, font, maxWidth2); + expect(res2).toBe(`中国你好!\n这是一个测\n试。 +我们来看\n看:人民币\n¥1234\n「很贵」 +(括号)、\n逗号,句\n号。空格\n换行 全角\n符号…—`); + }); + + it("should break Japanese sentences", () => { + const text = `日本こんにちは!これはテストです。 + 見てみましょう:円¥1234「高い」 + (括弧)、読点、句点。 + 空白 改行 全角記号…ー`; + + const maxWidth1 = 80; + const res1 = wrapText(text, font, maxWidth1); + expect(res1).toBe(`日本こんにちは!\nこれはテストで\nす。 + 見てみましょ\nう:円¥1234\n「高い」 + (括弧)、読\n点、句点。 + 空白 改行\n全角記号…ー`); + + const maxWidth2 = 50; + const res2 = wrapText(text, font, maxWidth2); + expect(res2).toBe(`日本こんに\nちは!これ\nはテストで\nす。 + 見てみ\nましょう:\n円\n¥1234\n「高い」 + (括\n弧)、読\n点、句点。 + 空白\n改行 全角\n記号…ー`); + }); + + it("should break Korean sentences", () => { + const text = `한국 안녕하세요! 이것은 테스트입니다. +우리 보자: 원화₩1234「비싸다」 +(괄호), 쉼표, 마침표. +공백 줄바꿈 전각기호…—`; + + const maxWidth1 = 80; + const res1 = wrapText(text, font, maxWidth1); + expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다. +우리 보자: 원\n화₩1234「비\n싸다」 +(괄호), 쉼\n표, 마침표. +공백 줄바꿈 전\n각기호…—`); + + const maxWidth2 = 60; + const res2 = wrapText(text, font, maxWidth2); + expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다. +우리 보자:\n원화\n₩1234\n「비싸다」 +(괄호),\n쉼표, 마침\n표. +공백 줄바꿈\n전각기호…—`); + }); + }); + + describe("When text contains leading whitespaces", () => { + const text = " \t Hello world"; + + it("should preserve leading whitespaces", () => { + const maxWidth = 120; + const res = wrapText(text, font, maxWidth); + expect(res).toBe(" \t Hello\nworld"); + }); + + it("should break and collapse leading whitespaces when line breaks", () => { + const maxWidth = 60; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("\nHello\nworld"); + }); + + it("should break and collapse leading whitespaces whe words break", () => { + const maxWidth = 30; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("\nHel\nlo\nwor\nld"); + }); + }); + + describe("When text contains trailing whitespaces", () => { + it("shouldn't add new lines for trailing spaces", () => { + const text = "Hello whats up "; + const maxWidth = 190; + const res = wrapText(text, font, maxWidth); + expect(res).toBe(text); + }); + + it("should ignore trailing whitespaces when line breaks", () => { + const text = "Hippopotomonstrosesquippedaliophobia ??????"; + const maxWidth = 400; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hippopotomonstrosesquippedaliophobia\n??????"); + }); + + it("should not ignore trailing whitespaces when word breaks", () => { + const text = "Hippopotomonstrosesquippedaliophobia ??????"; + const maxWidth = 300; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hippopotomonstrosesquippedalio\nphobia ??????"); + }); + + it("should ignore trailing whitespaces when word breaks and line breaks", () => { + const text = "Hippopotomonstrosesquippedaliophobia ??????"; + const maxWidth = 180; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hippopotomonstrose\nsquippedaliophobia\n??????"); + }); + }); + + describe("When text doesn't contain new lines", () => { + const text = "Hello whats up"; + + [ + { + desc: "break all words when width of each word is less than container width", + width: 70, + res: `Hello\nwhats\nup`, + }, + { + desc: "break all characters when width of each character is less than container width", + width: 15, + res: `H\ne\nl\nl\no\nw\nh\na\nt\ns\nu\np`, + }, + { + desc: "break words as per the width", + + width: 130, + res: `Hello whats\nup`, + }, + { + desc: "fit the container", + + width: 240, + res: "Hello whats up", + }, + { + desc: "push the word if its equal to max width", + width: 50, + res: `Hello\nwhats\nup`, + }, + ].forEach((data) => { + it(`should ${data.desc}`, () => { + const res = wrapText(text, font, data.width); + expect(res).toEqual(data.res); + }); + }); + }); + + describe("When text contain new lines", () => { + const text = `Hello\n whats up`; + [ + { + desc: "break all words when width of each word is less than container width", + width: 70, + res: `Hello\n whats\nup`, + }, + { + desc: "break all characters when width of each character is less than container width", + width: 15, + res: `H\ne\nl\nl\no\n\nw\nh\na\nt\ns\nu\np`, + }, + { + desc: "break words as per the width", + width: 140, + res: `Hello\n whats up`, + }, + ].forEach((data) => { + it(`should respect new lines and ${data.desc}`, () => { + const res = wrapText(text, font, data.width); + expect(res).toEqual(data.res); + }); + }); + }); + + describe("When text is long", () => { + const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`; + [ + { + desc: "fit characters of long string as per container width", + width: 160, + res: `hellolongtextthi\nsiswhatsupwithyo\nuIamtypingggggan\ndtypinggg break\nit now`, + }, + { + desc: "fit characters of long string as per container width and break words as per the width", + + width: 120, + res: `hellolongtex\ntthisiswhats\nupwithyouIam\ntypingggggan\ndtypinggg\nbreak it now`, + }, + { + desc: "fit the long text when container width is greater than text length and move the rest to next line", + + width: 590, + res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`, + }, + ].forEach((data) => { + it(`should ${data.desc}`, () => { + const res = wrapText(text, font, data.width); + expect(res).toEqual(data.res); + }); + }); + }); + + describe("Test parseTokens", () => { + it("should tokenize latin", () => { + let text = "Excalidraw is a virtual collaborative whiteboard"; + + expect(parseTokens(text)).toEqual([ + "Excalidraw", + " ", + "is", + " ", + "a", + " ", + "virtual", + " ", + "collaborative", + " ", + "whiteboard", + ]); + + text = + "Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects"; + expect(parseTokens(text)).toEqual([ + "Wikipedia", + " ", + "is", + " ", + "hosted", + " ", + "by", + " ", + "Wikimedia-", + " ", + "Foundation,", + " ", + "a", + " ", + "non-", + "profit", + " ", + "organization", + " ", + "that", + " ", + "also", + " ", + "hosts", + " ", + "a", + " ", + "range-", + "of", + " ", + "other", + " ", + "projects", + ]); + }); + + it("should not tokenize number", () => { + const text = "99,100.99"; + const tokens = parseTokens(text); + expect(tokens).toEqual(["99,100.99"]); + }); + + it("should tokenize joined emojis", () => { + const text = `😬🌍🗺🔥☂️👩🏽‍🦰👨‍👩‍👧‍👦👩🏾‍🔬🏳️‍🌈🧔‍♀️🧑‍🤝‍🧑🙅🏽‍♂️✅0️⃣🇨🇿🦅`; + const tokens = parseTokens(text); + + expect(tokens).toEqual([ + "😬", + "🌍", + "🗺", + "🔥", + "☂️", + "👩🏽‍🦰", + "👨‍👩‍👧‍👦", + "👩🏾‍🔬", + "🏳️‍🌈", + "🧔‍♀️", + "🧑‍🤝‍🧑", + "🙅🏽‍♂️", + "✅", + "0️⃣", + "🇨🇿", + "🦅", + ]); + }); + + it("should tokenize emojis mixed with mixed text", () => { + const text = `😬a🌍b🗺c🔥d☂️《👩🏽‍🦰》👨‍👩‍👧‍👦德👩🏾‍🔬こ🏳️‍🌈안🧔‍♀️g🧑‍🤝‍🧑h🙅🏽‍♂️e✅f0️⃣g🇨🇿10🦅#hash`; + const tokens = parseTokens(text); + + expect(tokens).toEqual([ + "😬", + "a", + "🌍", + "b", + "🗺", + "c", + "🔥", + "d", + "☂️", + "《", + "👩🏽‍🦰", + "》", + "👨‍👩‍👧‍👦", + "德", + "👩🏾‍🔬", + "こ", + "🏳️‍🌈", + "안", + "🧔‍♀️", + "g", + "🧑‍🤝‍🧑", + "h", + "🙅🏽‍♂️", + "e", + "✅", + "f0️⃣g", // bummer, but ok, as we traded kecaps not breaking (less common) for hash and numbers not breaking (more common) + "🇨🇿", + "10", // nice! do not break the number, as it's by default matched by \p{Emoji} + "🦅", + "#hash", // nice! do not break the hash, as it's by default matched by \p{Emoji} + ]); + }); + + it("should tokenize decomposed chars into their composed variants", () => { + // each input character is in a decomposed form + const text = "čでäぴέ다й한"; + expect(text.normalize("NFC").length).toEqual(8); + expect(text).toEqual(text.normalize("NFD")); + + const tokens = parseTokens(text); + expect(tokens.length).toEqual(8); + expect(tokens).toEqual(["č", "で", "ä", "ぴ", "έ", "다", "й", "한"]); + }); + + it("should tokenize artificial CJK", () => { + const text = `《道德經》醫-醫こんにちは世界!안녕하세요세계;요』,다.다...원/달(((다)))[[1]]〚({((한))>)〛(「た」)た…[Hello] \t World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`; + // [ + // '《道', '德', '經》', '醫-', + // '醫', 'こ', 'ん', 'に', + // 'ち', 'は', '世', '界!', + // '안', '녕', '하', '세', + // '요', '세', '계;', '요』,', + // '다.', '다...', '원/', '달', + // '(((다)))', '[[1]]', '〚({((한))>)〛', '(「た」)', + // 'た…', '[Hello]', ' ', '\t', + // ' ', 'World?', 'ニ', 'ュ', + // 'ー', 'ヨ', 'ー', 'ク・', + // '¥3700.55', 'す。', '090-', '1234-', + // '5678', '¥1,000〜', '$5,000', '「素', + // '晴', 'ら', 'し', 'い!」', + // '〔重', '要〕', '#', '1:', + // 'Taro', '君', '30%', 'は、', + // '(た', 'な', 'ば', 'た)', + // '〰', '¥110±', '¥570', 'で', + // '20℃〜', '9:30〜', '10:00', '【一', + // '番】' + // ] + const tokens = parseTokens(text); + + // Latin + expect(tokens).toContain("[[1]]"); + expect(tokens).toContain("[Hello]"); + expect(tokens).toContain("World?"); + expect(tokens).toContain("Taro"); + + // Chinese + expect(tokens).toContain("《道"); + expect(tokens).toContain("德"); + expect(tokens).toContain("經》"); + expect(tokens).toContain("醫-"); + expect(tokens).toContain("醫"); + + // Japanese + expect(tokens).toContain("こ"); + expect(tokens).toContain("ん"); + expect(tokens).toContain("に"); + expect(tokens).toContain("ち"); + expect(tokens).toContain("は"); + expect(tokens).toContain("世"); + expect(tokens).toContain("ク・"); + expect(tokens).toContain("界!"); + expect(tokens).toContain("た…"); + expect(tokens).toContain("す。"); + expect(tokens).toContain("ュ"); + expect(tokens).toContain("「素"); + expect(tokens).toContain("晴"); + expect(tokens).toContain("ら"); + expect(tokens).toContain("し"); + expect(tokens).toContain("い!」"); + expect(tokens).toContain("君"); + expect(tokens).toContain("は、"); + expect(tokens).toContain("(た"); + expect(tokens).toContain("な"); + expect(tokens).toContain("ば"); + expect(tokens).toContain("た)"); + expect(tokens).toContain("で"); + expect(tokens).toContain("【一"); + expect(tokens).toContain("番】"); + + // Check for Korean + expect(tokens).toContain("안"); + expect(tokens).toContain("녕"); + expect(tokens).toContain("하"); + expect(tokens).toContain("세"); + expect(tokens).toContain("요"); + expect(tokens).toContain("세"); + expect(tokens).toContain("계;"); + expect(tokens).toContain("요』,"); + expect(tokens).toContain("다."); + expect(tokens).toContain("다..."); + expect(tokens).toContain("원/"); + expect(tokens).toContain("달"); + expect(tokens).toContain("(((다)))"); + expect(tokens).toContain("〚({((한))>)〛"); + expect(tokens).toContain("(「た」)"); + + // Numbers and units + expect(tokens).toContain("¥3700.55"); + expect(tokens).toContain("090-"); + expect(tokens).toContain("1234-"); + expect(tokens).toContain("5678"); + expect(tokens).toContain("¥1,000〜"); + expect(tokens).toContain("$5,000"); + expect(tokens).toContain("1:"); + expect(tokens).toContain("30%"); + expect(tokens).toContain("¥110±"); + expect(tokens).toContain("20℃〜"); + expect(tokens).toContain("9:30〜"); + expect(tokens).toContain("10:00"); + + // Punctuation and symbols + expect(tokens).toContain(" "); + expect(tokens).toContain("\t"); + expect(tokens).toContain(" "); + expect(tokens).toContain("ニ"); + expect(tokens).toContain("ー"); + expect(tokens).toContain("ヨ"); + expect(tokens).toContain("〰"); + expect(tokens).toContain("#"); + }); + }); +}); diff --git a/packages/excalidraw/element/textWrapping.ts b/packages/excalidraw/element/textWrapping.ts new file mode 100644 index 000000000..597f62e15 --- /dev/null +++ b/packages/excalidraw/element/textWrapping.ts @@ -0,0 +1,568 @@ +import { ENV } from "../constants"; +import { charWidth, getLineWidth } from "./textElement"; +import type { FontString } from "./types"; + +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. + */ +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. + */ +export const wrapText = ( + text: string, + font: FontString, + 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"); + + for (const originalLine of originalLines) { + const currentLineWidth = getLineWidth(originalLine, font, true); + + if (currentLineWidth <= maxWidth) { + lines.push(originalLine); + continue; + } + + const wrappedLine = wrapLine(originalLine, font, maxWidth); + lines.push(...wrappedLine); + } + + return lines.join("\n"); +}; + +/** + * Wraps the original line into the lines based on the given width. + */ +const wrapLine = ( + line: string, + font: FontString, + maxWidth: number, +): string[] => { + const lines: Array = []; + const tokens = parseTokens(line); + const tokenIterator = tokens[Symbol.iterator](); + + let currentLine = ""; + let currentLineWidth = 0; + + let iterator = tokenIterator.next(); + + while (!iterator.done) { + const token = iterator.value; + 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, true); + + // build up the current line, skipping length check for possibly trailing whitespaces + if (/\s/.test(token) || testLineWidth <= maxWidth) { + currentLine = testLine; + currentLineWidth = testLineWidth; + iterator = tokenIterator.next(); + 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); + const trailingLine = wrappedWord[wrappedWord.length - 1] ?? ""; + 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; + currentLineWidth = getLineWidth(trailingLine, font, true); + iterator = tokenIterator.next(); + } else { + // push & reset, but don't iterate on the next token, as we didn't use it yet! + lines.push(currentLine.trimEnd()); + + // purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above + currentLine = ""; + currentLineWidth = 0; + } + } + + // iterator done, push the trailing line if exists + if (currentLine) { + const trailingLine = trimLine(currentLine, font, maxWidth); + lines.push(trailingLine); + } + + return lines; +}; + +/** + * Wraps the word into the lines based on the given width. + */ +const wrapWord = ( + word: string, + font: FontString, + maxWidth: number, +): Array => { + // multi-codepoint emojis are already broken apart and shouldn't be broken further + if (getEmojiRegex().test(word)) { + return [word]; + } + + satisfiesWordInvariant(word); + + const lines: Array = []; + const chars = Array.from(word); + + let currentLine = ""; + let currentLineWidth = 0; + + for (const char of chars) { + const _charWidth = charWidth.calculate(char, font); + const testLineWidth = currentLineWidth + _charWidth; + + if (testLineWidth <= maxWidth) { + currentLine = currentLine + char; + currentLineWidth = testLineWidth; + continue; + } + + if (currentLine) { + lines.push(currentLine); + } + + currentLine = char; + currentLineWidth = _charWidth; + } + + if (currentLine) { + lines.push(currentLine); + } + + return lines; +}; + +/** + * Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`. + */ +const trimLine = (line: string, font: FontString, maxWidth: number) => { + const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth; + + if (!shouldTrimWhitespaces) { + return line; + } + + // 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, true); + + 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 trimmedLine; +}; + +/** + * 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 (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { + if (/\s/.test(word)) { + throw new Error("Word should not contain any whitespaces!"); + } + } +}; diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index 23778cb7b..5663af8f8 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -27,13 +27,13 @@ import { getTextWidth, normalizeText, redrawTextBoundingBox, - wrapText, getBoundTextMaxHeight, getBoundTextMaxWidth, computeContainerDimensionForBoundText, computeBoundTextPosition, getBoundTextElement, } from "./textElement"; +import { wrapText } from "./textWrapping"; import { actionDecreaseFontSize, actionIncreaseFontSize, diff --git a/packages/excalidraw/fonts/Fonts.ts b/packages/excalidraw/fonts/Fonts.ts index 31b5ad000..3e307e7da 100644 --- a/packages/excalidraw/fonts/Fonts.ts +++ b/packages/excalidraw/fonts/Fonts.ts @@ -7,11 +7,8 @@ import { getFontFamilyFallbacks, } from "../constants"; import { isTextElement } from "../element"; -import { - charWidth, - containsCJK, - getContainerElement, -} from "../element/textElement"; +import { charWidth, getContainerElement } from "../element/textElement"; +import { containsCJK } from "../element/textWrapping"; import { ShapeCache } from "../scene/ShapeCache"; import { getFontString, PromisePool, promiseTry } from "../utils"; import { ExcalidrawFontFace } from "./ExcalidrawFontFace"; diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index 5df260d1d..3341d2da3 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -20,7 +20,6 @@ import { LinearElementEditor } from "../element/linearElementEditor"; import { act, queryByTestId, queryByText } from "@testing-library/react"; import { getBoundTextElementPosition, - wrapText, getBoundTextMaxWidth, } from "../element/textElement"; import * as textElementUtils from "../element/textElement"; @@ -29,6 +28,7 @@ import { vi } from "vitest"; import { arrayToMap } from "../utils"; import type { GlobalPoint } from "../../math"; import { pointCenter, pointFrom } from "../../math"; +import { wrapText } from "../element/textWrapping"; const renderInteractiveScene = vi.spyOn( InteractiveCanvas, From 70e0e8dc2904d4bc3938858760f8da46c63783cf Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Fri, 1 Nov 2024 23:43:48 +0200 Subject: [PATCH 06/29] fix: text pushes UI due to padding (#8745) --- packages/excalidraw/element/textWysiwyg.tsx | 11 +++-------- .../__snapshots__/linearElementEditor.test.tsx.snap | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index 5663af8f8..efe27bc6e 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -11,7 +11,7 @@ import { isBoundToContainer, isTextElement, } from "./typeChecks"; -import { CLASSES, isSafari, POINTER_BUTTON } from "../constants"; +import { CLASSES, POINTER_BUTTON } from "../constants"; import type { ExcalidrawElement, ExcalidrawLinearElement, @@ -245,11 +245,6 @@ export const textWysiwyg = ({ const font = getFontString(updatedTextElement); - // adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari) - const padding = !isSafari - ? Math.ceil(updatedTextElement.fontSize / appState.zoom.value / 2) - : 0; - // Make sure text editor height doesn't go beyond viewport const editorMaxHeight = (appState.height - viewportY) / appState.zoom.value; @@ -259,7 +254,7 @@ export const textWysiwyg = ({ lineHeight: updatedTextElement.lineHeight, width: `${width}px`, height: `${height}px`, - left: `${viewportX - padding}px`, + left: `${viewportX}px`, top: `${viewportY}px`, transform: getTransform( width, @@ -269,7 +264,6 @@ export const textWysiwyg = ({ maxWidth, editorMaxHeight, ), - padding: `0 ${padding}px`, textAlign, verticalAlign, color: updatedTextElement.strokeColor, @@ -310,6 +304,7 @@ export const textWysiwyg = ({ minHeight: "1em", backfaceVisibility: "hidden", margin: 0, + padding: 0, border: 0, outline: 0, resize: "none", diff --git a/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap index 399fab29b..00857987c 100644 --- a/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap @@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo class="excalidraw-wysiwyg" data-type="wysiwyg" dir="auto" - style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 25px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); padding: 0px 10px; text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;" + style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;" tabindex="0" wrap="off" /> From da33481fa36b12a341de155e5cfbcc6985f5886d Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Mon, 4 Nov 2024 18:57:40 +0530 Subject: [PATCH 07/29] chore: support upto node 22.x.x (#8755) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e5f90eef5..df08cc278 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "vitest-canvas-mock": "0.3.3" }, "engines": { - "node": "18.0.0 - 20.x.x" + "node": "18.0.0 - 22.x.x" }, "homepage": ".", "prettier": "@excalidraw/prettier-config", From 7c0239e693be2bd0274244fc55c943a7566bf531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Mon, 4 Nov 2024 17:54:00 +0100 Subject: [PATCH 08/29] fix: Console error in dev mode due to missing font path in non-prod (#8756) Fix console error due to missing font path in dev mode reported by Firefox. --- excalidraw-app/index.html | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 83fac2932..4d5af32d6 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -118,12 +118,6 @@ - <% } else { %> - - <% } %> - + <% } else { %> + + <% } %> + @@ -212,6 +212,7 @@
+ <% if (typeof PROD != 'undefined' && PROD == true) { %> + <% } %> From d9ad7c039b804aad2c27fa085c82220b5808eb71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barnab=C3=A1s=20Moln=C3=A1r?= <38168628+barnabasmolnar@users.noreply.github.com> Date: Mon, 4 Nov 2024 23:35:45 +0100 Subject: [PATCH 09/29] feat: export scene to e+ on workspace creation/redemption (#8514) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- .env.development | 10 +- .env.production | 8 + excalidraw-app/App.tsx | 7 + excalidraw-app/ExcalidrawPlusIframeExport.tsx | 222 ++++++++++++++++++ packages/excalidraw/data/encode.ts | 14 ++ packages/excalidraw/errors.ts | 12 + 6 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 excalidraw-app/ExcalidrawPlusIframeExport.tsx diff --git a/.env.development b/.env.development index 2086b1a4b..badc209a2 100644 --- a/.env.development +++ b/.env.development @@ -8,7 +8,7 @@ VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfu VITE_APP_WS_SERVER_URL=http://localhost:3002 VITE_APP_PLUS_LP=https://plus.excalidraw.com -VITE_APP_PLUS_APP=https://app.excalidraw.com +VITE_APP_PLUS_APP=http://localhost:3000 VITE_APP_AI_BACKEND=http://localhost:3015 @@ -37,3 +37,11 @@ VITE_APP_COLLAPSE_OVERLAY=true # Set this flag to false to disable eslint VITE_APP_ENABLE_ESLINT=true + +VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm2g5T+Rub6Kbf1Mf57t0 +7r2zeHuVg4dla3r5ryXMswtzz6x767octl6oLThn33mQsPSy3GKglFZoCTXJR4ij +ba8SxB04sL/N8eRrKja7TFWjCVtRwTTfyy771NYYNFVJclkxHyE5qw4m27crHF1y +UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD +s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot +kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS +HQIDAQAB' diff --git a/.env.production b/.env.production index 64e696847..9ccb8d6fc 100644 --- a/.env.production +++ b/.env.production @@ -15,3 +15,11 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' VITE_APP_ENABLE_TRACKING=false + +VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApQ0jM9Qz8TdFLzcuAZZX +/WvuKSOJxiw6AR/ZcE3eFQWM/mbFdhQgyK8eHGkKQifKzH1xUZjCxyXcxW6ZO02t +kPOPxhz+nxUrIoWCD/V4NGmUA1lxwHuO21HN1gzKrN3xHg5EGjyouR9vibT9VDGF +gq6+4Ic/kJX+AD2MM7Yre2+FsOdysrmuW2Fu3ahuC1uQE7pOe1j0k7auNP0y1q53 +PrB8Ts2LUpepWC1l7zIXFm4ViDULuyWXTEpUcHSsEH8vpd1tckjypxCwkipfZsXx +iPszy0o0Dx2iArPfWMXlFAI9mvyFCyFC3+nSvfyAUb2C4uZgCwAuyFh/ydPF4DEE +PQIDAQAB' diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 9b7eadff8..d7a93bbea 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -126,6 +126,7 @@ import DebugCanvas, { loadSavedDebugState, } from "./components/DebugCanvas"; import { AIComponents } from "./components/AI"; +import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport"; polyfill(); @@ -1125,6 +1126,12 @@ const ExcalidrawWrapper = () => { }; const ExcalidrawApp = () => { + const isCloudExportWindow = + window.location.pathname === "/excalidraw-plus-export"; + if (isCloudExportWindow) { + return ; + } + return ( appJotaiStore}> diff --git a/excalidraw-app/ExcalidrawPlusIframeExport.tsx b/excalidraw-app/ExcalidrawPlusIframeExport.tsx new file mode 100644 index 000000000..64ebdeb60 --- /dev/null +++ b/excalidraw-app/ExcalidrawPlusIframeExport.tsx @@ -0,0 +1,222 @@ +import { useLayoutEffect, useRef } from "react"; +import { STORAGE_KEYS } from "./app_constants"; +import { LocalData } from "./data/LocalData"; +import type { + FileId, + OrderedExcalidrawElement, +} from "../packages/excalidraw/element/types"; +import type { AppState, BinaryFileData } from "../packages/excalidraw/types"; +import { ExcalidrawError } from "../packages/excalidraw/errors"; +import { base64urlToString } from "../packages/excalidraw/data/encode"; + +const EVENT_REQUEST_SCENE = "REQUEST_SCENE"; + +const EXCALIDRAW_PLUS_ORIGIN = import.meta.env.VITE_APP_PLUS_APP; + +// ----------------------------------------------------------------------------- +// outgoing message +// ----------------------------------------------------------------------------- +type MESSAGE_REQUEST_SCENE = { + type: "REQUEST_SCENE"; + jwt: string; +}; + +type MESSAGE_FROM_PLUS = MESSAGE_REQUEST_SCENE; + +// incoming messages +// ----------------------------------------------------------------------------- +type MESSAGE_READY = { type: "READY" }; +type MESSAGE_ERROR = { type: "ERROR"; message: string }; +type MESSAGE_SCENE_DATA = { + type: "SCENE_DATA"; + elements: OrderedExcalidrawElement[]; + appState: Pick; + files: { loadedFiles: BinaryFileData[]; erroredFiles: Map }; +}; + +type MESSAGE_FROM_EDITOR = MESSAGE_ERROR | MESSAGE_SCENE_DATA | MESSAGE_READY; +// ----------------------------------------------------------------------------- + +const parseSceneData = async ({ + rawElementsString, + rawAppStateString, +}: { + rawElementsString: string | null; + rawAppStateString: string | null; +}): Promise => { + if (!rawElementsString || !rawAppStateString) { + throw new ExcalidrawError("Elements or appstate is missing."); + } + + try { + const elements = JSON.parse( + rawElementsString, + ) as OrderedExcalidrawElement[]; + + if (!elements.length) { + throw new ExcalidrawError("Scene is empty, nothing to export."); + } + + const appState = JSON.parse(rawAppStateString) as Pick< + AppState, + "viewBackgroundColor" + >; + + const fileIds = elements.reduce((acc, el) => { + if ("fileId" in el && el.fileId) { + acc.push(el.fileId); + } + return acc; + }, [] as FileId[]); + + const files = await LocalData.fileStorage.getFiles(fileIds); + + return { + type: "SCENE_DATA", + elements, + appState, + files, + }; + } catch (error: any) { + throw error instanceof ExcalidrawError + ? error + : new ExcalidrawError("Failed to parse scene data."); + } +}; + +const verifyJWT = async ({ + token, + publicKey, +}: { + token: string; + publicKey: string; +}) => { + try { + if (!publicKey) { + throw new ExcalidrawError("Public key is undefined"); + } + + const [header, payload, signature] = token.split("."); + + if (!header || !payload || !signature) { + throw new ExcalidrawError("Invalid JWT format"); + } + + // JWT is using Base64URL encoding + const decodedPayload = base64urlToString(payload); + const decodedSignature = base64urlToString(signature); + + const data = `${header}.${payload}`; + const signatureArrayBuffer = Uint8Array.from(decodedSignature, (c) => + c.charCodeAt(0), + ); + + const keyData = publicKey.replace(/-----\w+ PUBLIC KEY-----/g, ""); + const keyArrayBuffer = Uint8Array.from(atob(keyData), (c) => + c.charCodeAt(0), + ); + + const key = await crypto.subtle.importKey( + "spki", + keyArrayBuffer, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + true, + ["verify"], + ); + + const isValid = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + key, + signatureArrayBuffer, + new TextEncoder().encode(data), + ); + + if (!isValid) { + throw new Error("Invalid JWT"); + } + + const parsedPayload = JSON.parse(decodedPayload); + + // Check for expiration + const currentTime = Math.floor(Date.now() / 1000); + if (parsedPayload.exp && parsedPayload.exp < currentTime) { + throw new Error("JWT has expired"); + } + } catch (error) { + console.error("Failed to verify JWT:", error); + throw new Error(error instanceof Error ? error.message : "Invalid JWT"); + } +}; + +export const ExcalidrawPlusIframeExport = () => { + const readyRef = useRef(false); + + useLayoutEffect(() => { + const handleMessage = async (event: MessageEvent) => { + if (event.origin !== EXCALIDRAW_PLUS_ORIGIN) { + throw new ExcalidrawError("Invalid origin"); + } + + if (event.data.type === EVENT_REQUEST_SCENE) { + if (!event.data.jwt) { + throw new ExcalidrawError("JWT is missing"); + } + + try { + try { + await verifyJWT({ + token: event.data.jwt, + publicKey: import.meta.env.VITE_APP_PLUS_EXPORT_PUBLIC_KEY, + }); + } catch (error: any) { + console.error(`Failed to verify JWT: ${error.message}`); + throw new ExcalidrawError("Failed to verify JWT"); + } + + const parsedSceneData: MESSAGE_SCENE_DATA = await parseSceneData({ + rawAppStateString: localStorage.getItem( + STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, + ), + rawElementsString: localStorage.getItem( + STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, + ), + }); + + event.source!.postMessage(parsedSceneData, { + targetOrigin: EXCALIDRAW_PLUS_ORIGIN, + }); + } catch (error) { + const responseData: MESSAGE_ERROR = { + type: "ERROR", + message: + error instanceof ExcalidrawError + ? error.message + : "Failed to export scene data", + }; + event.source!.postMessage(responseData, { + targetOrigin: EXCALIDRAW_PLUS_ORIGIN, + }); + } + } + }; + + window.addEventListener("message", handleMessage); + + // so we don't send twice in StrictMode + if (!readyRef.current) { + readyRef.current = true; + const message: MESSAGE_FROM_EDITOR = { type: "READY" }; + window.parent.postMessage(message, EXCALIDRAW_PLUS_ORIGIN); + } + + return () => { + window.removeEventListener("message", handleMessage); + }; + }, []); + + // Since this component is expected to run in a hidden iframe on Excaildraw+, + // it doesn't need to render anything. All the data we need is available in + // LocalStorage and IndexedDB. It only needs to handle the messaging between + // the parent window and the iframe with the relevant data. + return null; +}; diff --git a/packages/excalidraw/data/encode.ts b/packages/excalidraw/data/encode.ts index 44e6b9974..15dfdb2c0 100644 --- a/packages/excalidraw/data/encode.ts +++ b/packages/excalidraw/data/encode.ts @@ -65,6 +65,20 @@ export const base64ToArrayBuffer = (base64: string): ArrayBuffer => { return byteStringToArrayBuffer(atob(base64)); }; +// ----------------------------------------------------------------------------- +// base64url +// ----------------------------------------------------------------------------- + +export const base64urlToString = (str: string) => { + return window.atob( + // normalize base64URL to base64 + str + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(str.length + ((4 - (str.length % 4)) % 4), "="), + ); +}; + // ----------------------------------------------------------------------------- // text encoding // ----------------------------------------------------------------------------- diff --git a/packages/excalidraw/errors.ts b/packages/excalidraw/errors.ts index 8509deb52..d6091b0e9 100644 --- a/packages/excalidraw/errors.ts +++ b/packages/excalidraw/errors.ts @@ -50,6 +50,7 @@ export class WorkerUrlNotDefinedError extends Error { this.code = code; } } + export class WorkerInTheMainChunkError extends Error { public code; constructor( @@ -61,3 +62,14 @@ export class WorkerInTheMainChunkError extends Error { this.code = code; } } + +/** + * Use this for generic, handled errors, so you can check against them + * and rethrow if needed + */ +export class ExcalidrawError extends Error { + constructor(message: string) { + super(message); + this.name = "ExcalidrawError"; + } +} From 798f5f4dfb3eaefa7cfaef9b6e0c01486a78f0ad Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 6 Nov 2024 17:41:41 +0100 Subject: [PATCH 10/29] feat: update blog url (#8767) --- packages/excalidraw/components/HelpDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/components/HelpDialog.tsx b/packages/excalidraw/components/HelpDialog.tsx index 577d8f187..c233d74af 100644 --- a/packages/excalidraw/components/HelpDialog.tsx +++ b/packages/excalidraw/components/HelpDialog.tsx @@ -22,7 +22,7 @@ const Header = () => ( From df168a68833bf66b2ab7618f96b48d1a92676197 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Fri, 8 Nov 2024 02:44:49 +0530 Subject: [PATCH 11/29] fix: load env vars correctly and set debug and linter flags to false explicitly in prod mode (#8770) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- .env.production | 7 + excalidraw-app/vite.config.mts | 442 +++++++++++++++++---------------- 2 files changed, 229 insertions(+), 220 deletions(-) diff --git a/.env.production b/.env.production index 9ccb8d6fc..11e9fd84b 100644 --- a/.env.production +++ b/.env.production @@ -23,3 +23,10 @@ gq6+4Ic/kJX+AD2MM7Yre2+FsOdysrmuW2Fu3ahuC1uQE7pOe1j0k7auNP0y1q53 PrB8Ts2LUpepWC1l7zIXFm4ViDULuyWXTEpUcHSsEH8vpd1tckjypxCwkipfZsXx iPszy0o0Dx2iArPfWMXlFAI9mvyFCyFC3+nSvfyAUb2C4uZgCwAuyFh/ydPF4DEE PQIDAQAB' + +# Set the below flags explicitly to false in production mode since vite loads and merges .env.local vars when running the build command +VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=false +VITE_APP_COLLAPSE_OVERLAY=false +# Enable eslint in dev server +VITE_APP_ENABLE_ESLINT=false + diff --git a/excalidraw-app/vite.config.mts b/excalidraw-app/vite.config.mts index 5174e5c75..0345c8ee7 100644 --- a/excalidraw-app/vite.config.mts +++ b/excalidraw-app/vite.config.mts @@ -8,231 +8,233 @@ import { createHtmlPlugin } from "vite-plugin-html"; import Sitemap from "vite-plugin-sitemap"; import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins"; -// To load .env.local variables -const envVars = loadEnv("", `../`); -// https://vitejs.dev/config/ -export default defineConfig({ - server: { - port: Number(envVars.VITE_APP_PORT || 3000), - // open the browser - open: true, - }, - // We need to specify the envDir since now there are no - //more located in parallel with the vite.config.ts file but in parent dir - envDir: "../", - build: { - outDir: "build", - rollupOptions: { - output: { - assetFileNames(chunkInfo) { - if (chunkInfo?.name?.endsWith(".woff2")) { - const family = chunkInfo.name.split("-")[0]; - return `fonts/${family}/[name][extname]`; - } - - return "assets/[name]-[hash][extname]"; - }, - // Creating separate chunk for locales except for en and percentages.json so they - // can be cached at runtime and not merged with - // app precache. en.json and percentages.json are needed for first load - // or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too. - manualChunks(id) { - if ( - id.includes("packages/excalidraw/locales") && - id.match(/en.json|percentages.json/) === null - ) { - const index = id.indexOf("locales/"); - // Taking the substring after "locales/" - return `locales/${id.substring(index + 8)}`; - } - }, - }, +export default defineConfig(({ mode }) => { + // To load .env variables + const envVars = loadEnv(mode, `../`); + // https://vitejs.dev/config/ + return { + server: { + port: Number(envVars.VITE_APP_PORT || 3000), + // open the browser + open: true, }, - sourcemap: true, - // don't auto-inline small assets (i.e. fonts hosted on CDN) - assetsInlineLimit: 0, - }, - plugins: [ - Sitemap({ - hostname: "https://excalidraw.com", + // We need to specify the envDir since now there are no + //more located in parallel with the vite.config.ts file but in parent dir + envDir: "../", + build: { outDir: "build", - changefreq: "monthly", - // its static in public folder - generateRobotsTxt: false, - }), - woff2BrowserPlugin(), - react(), - checker({ - typescript: true, - eslint: - envVars.VITE_APP_ENABLE_ESLINT === "false" - ? undefined - : { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' }, - overlay: { - initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false", - badgeStyle: "margin-bottom: 4rem; margin-left: 1rem", - }, - }), - svgrPlugin(), - ViteEjsPlugin(), - VitePWA({ - registerType: "autoUpdate", - devOptions: { - /* set this flag to true to enable in Development mode */ - enabled: false, - }, + rollupOptions: { + output: { + assetFileNames(chunkInfo) { + if (chunkInfo?.name?.endsWith(".woff2")) { + const family = chunkInfo.name.split("-")[0]; + return `fonts/${family}/[name][extname]`; + } - workbox: { - // don't precache fonts, locales and separate chunks - globIgnores: [ - "fonts.css", - "**/locales/**", - "service-worker.js", - "**/*.chunk-*.js", - ], - runtimeCaching: [ - { - urlPattern: new RegExp(".+.woff2"), - handler: "CacheFirst", - options: { - cacheName: "fonts", - expiration: { - maxEntries: 1000, - maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days - }, - cacheableResponse: { - // 0 to cache "opaque" responses from cross-origin requests (i.e. CDN) - statuses: [0, 200], - }, - }, + return "assets/[name]-[hash][extname]"; }, - { - urlPattern: new RegExp("fonts.css"), - handler: "StaleWhileRevalidate", - options: { - cacheName: "fonts", - expiration: { - maxEntries: 50, - }, - }, - }, - { - urlPattern: new RegExp("locales/[^/]+.js"), - handler: "CacheFirst", - options: { - cacheName: "locales", - expiration: { - maxEntries: 50, - maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days - }, - }, - }, - { - urlPattern: new RegExp(".chunk-.+.js"), - handler: "CacheFirst", - options: { - cacheName: "chunk", - expiration: { - maxEntries: 50, - maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days - }, - }, - }, - ], - }, - manifest: { - short_name: "Excalidraw", - name: "Excalidraw", - description: - "Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.", - icons: [ - { - src: "android-chrome-192x192.png", - sizes: "192x192", - type: "image/png", - }, - { - src: "apple-touch-icon.png", - type: "image/png", - sizes: "180x180", - }, - { - src: "favicon-32x32.png", - sizes: "32x32", - type: "image/png", - }, - { - src: "favicon-16x16.png", - sizes: "16x16", - type: "image/png", - }, - ], - start_url: "/", - display: "standalone", - theme_color: "#121212", - background_color: "#ffffff", - file_handlers: [ - { - action: "/", - accept: { - "application/vnd.excalidraw+json": [".excalidraw"], - }, - }, - ], - share_target: { - action: "/web-share-target", - method: "POST", - enctype: "multipart/form-data", - params: { - files: [ - { - name: "file", - accept: [ - "application/vnd.excalidraw+json", - "application/json", - ".excalidraw", - ], - }, - ], + // Creating separate chunk for locales except for en and percentages.json so they + // can be cached at runtime and not merged with + // app precache. en.json and percentages.json are needed for first load + // or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too. + manualChunks(id) { + if ( + id.includes("packages/excalidraw/locales") && + id.match(/en.json|percentages.json/) === null + ) { + const index = id.indexOf("locales/"); + // Taking the substring after "locales/" + return `locales/${id.substring(index + 8)}`; + } }, }, - screenshots: [ - { - src: "/screenshots/virtual-whiteboard.png", - type: "image/png", - sizes: "462x945", - }, - { - src: "/screenshots/wireframe.png", - type: "image/png", - sizes: "462x945", - }, - { - src: "/screenshots/illustration.png", - type: "image/png", - sizes: "462x945", - }, - { - src: "/screenshots/shapes.png", - type: "image/png", - sizes: "462x945", - }, - { - src: "/screenshots/collaboration.png", - type: "image/png", - sizes: "462x945", - }, - { - src: "/screenshots/export.png", - type: "image/png", - sizes: "462x945", - }, - ], }, - }), - createHtmlPlugin({ - minify: true, - }), - ], - publicDir: "../public", + sourcemap: true, + // don't auto-inline small assets (i.e. fonts hosted on CDN) + assetsInlineLimit: 0, + }, + plugins: [ + Sitemap({ + hostname: "https://excalidraw.com", + outDir: "build", + changefreq: "monthly", + // its static in public folder + generateRobotsTxt: false, + }), + woff2BrowserPlugin(), + react(), + checker({ + typescript: true, + eslint: + envVars.VITE_APP_ENABLE_ESLINT === "false" + ? undefined + : { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' }, + overlay: { + initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false", + badgeStyle: "margin-bottom: 4rem; margin-left: 1rem", + }, + }), + svgrPlugin(), + ViteEjsPlugin(), + VitePWA({ + registerType: "autoUpdate", + devOptions: { + /* set this flag to true to enable in Development mode */ + enabled: false, + }, + + workbox: { + // don't precache fonts, locales and separate chunks + globIgnores: [ + "fonts.css", + "**/locales/**", + "service-worker.js", + "**/*.chunk-*.js", + ], + runtimeCaching: [ + { + urlPattern: new RegExp(".+.woff2"), + handler: "CacheFirst", + options: { + cacheName: "fonts", + expiration: { + maxEntries: 1000, + maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days + }, + cacheableResponse: { + // 0 to cache "opaque" responses from cross-origin requests (i.e. CDN) + statuses: [0, 200], + }, + }, + }, + { + urlPattern: new RegExp("fonts.css"), + handler: "StaleWhileRevalidate", + options: { + cacheName: "fonts", + expiration: { + maxEntries: 50, + }, + }, + }, + { + urlPattern: new RegExp("locales/[^/]+.js"), + handler: "CacheFirst", + options: { + cacheName: "locales", + expiration: { + maxEntries: 50, + maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days + }, + }, + }, + { + urlPattern: new RegExp(".chunk-.+.js"), + handler: "CacheFirst", + options: { + cacheName: "chunk", + expiration: { + maxEntries: 50, + maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days + }, + }, + }, + ], + }, + manifest: { + short_name: "Excalidraw", + name: "Excalidraw", + description: + "Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.", + icons: [ + { + src: "android-chrome-192x192.png", + sizes: "192x192", + type: "image/png", + }, + { + src: "apple-touch-icon.png", + type: "image/png", + sizes: "180x180", + }, + { + src: "favicon-32x32.png", + sizes: "32x32", + type: "image/png", + }, + { + src: "favicon-16x16.png", + sizes: "16x16", + type: "image/png", + }, + ], + start_url: "/", + display: "standalone", + theme_color: "#121212", + background_color: "#ffffff", + file_handlers: [ + { + action: "/", + accept: { + "application/vnd.excalidraw+json": [".excalidraw"], + }, + }, + ], + share_target: { + action: "/web-share-target", + method: "POST", + enctype: "multipart/form-data", + params: { + files: [ + { + name: "file", + accept: [ + "application/vnd.excalidraw+json", + "application/json", + ".excalidraw", + ], + }, + ], + }, + }, + screenshots: [ + { + src: "/screenshots/virtual-whiteboard.png", + type: "image/png", + sizes: "462x945", + }, + { + src: "/screenshots/wireframe.png", + type: "image/png", + sizes: "462x945", + }, + { + src: "/screenshots/illustration.png", + type: "image/png", + sizes: "462x945", + }, + { + src: "/screenshots/shapes.png", + type: "image/png", + sizes: "462x945", + }, + { + src: "/screenshots/collaboration.png", + type: "image/png", + sizes: "462x945", + }, + { + src: "/screenshots/export.png", + type: "image/png", + sizes: "462x945", + }, + ], + }, + }), + createHtmlPlugin({ + minify: true, + }), + ], + publicDir: "../public", + }; }); From ef9ea14a75f085fb64255f5a4f11bc14610df9aa Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Sat, 9 Nov 2024 13:42:11 +0530 Subject: [PATCH 12/29] fix: remove manifest.json (#8783) * fix: remove manifest.json * disable pwa in dev --- public/manifest.json | 74 -------------------------------------------- 1 file changed, 74 deletions(-) delete mode 100644 public/manifest.json diff --git a/public/manifest.json b/public/manifest.json deleted file mode 100644 index 374d426e6..000000000 --- a/public/manifest.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "short_name": "Excalidraw", - "name": "Excalidraw", - "description": "Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them.", - "icons": [ - { - "src": "android-chrome-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "apple-touch-icon.png", - "type": "image/png", - "sizes": "180x180" - } - ], - "start_url": "/", - "display": "standalone", - "theme_color": "#000000", - "background_color": "#ffffff", - "file_handlers": [ - { - "action": "/", - "accept": { - "application/vnd.excalidraw+json": [".excalidraw"] - } - } - ], - "share_target": { - "action": "/web-share-target", - "method": "POST", - "enctype": "multipart/form-data", - "params": { - "files": [ - { - "name": "file", - "accept": ["application/vnd.excalidraw+json", "application/json", ".excalidraw"] - } - ] - } - }, - "screenshots": [ - { - "src": "/screenshots/virtual-whiteboard.png", - "type": "image/png", - "sizes": "462x945" - }, - { - "src": "/screenshots/wireframe.png", - "type": "image/png", - "sizes": "462x945" - }, - { - "src": "/screenshots/illustration.png", - "type": "image/png", - "sizes": "462x945" - }, - { - "src": "/screenshots/shapes.png", - "type": "image/png", - "sizes": "462x945" - }, - { - "src": "/screenshots/collaboration.png", - "type": "image/png", - "sizes": "462x945" - }, - { - "src": "/screenshots/export.png", - "type": "image/png", - "sizes": "462x945" - } - ] -} From ee091d0dbd089ec79f070aedf8c1f857b7a55839 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Sat, 9 Nov 2024 21:45:37 +0530 Subject: [PATCH 13/29] build: add a flag VITE_APP_ENABLE_PWA for enabling pwa in dev environment (#8784) * build: add a flag VITE_APP_ENABLE_PWA for enabling pwa in dev environment * fix * set VITE_ENABLE_PWA to false in .env.development --- .env.development | 3 +++ excalidraw-app/vite-env.d.ts | 3 +++ excalidraw-app/vite.config.mts | 3 ++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.env.development b/.env.development index badc209a2..c2e84da60 100644 --- a/.env.development +++ b/.env.development @@ -38,6 +38,9 @@ VITE_APP_COLLAPSE_OVERLAY=true # Set this flag to false to disable eslint VITE_APP_ENABLE_ESLINT=true +# Enable PWA in dev server +VITE_APP_ENABLE_PWA=true + VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm2g5T+Rub6Kbf1Mf57t0 7r2zeHuVg4dla3r5ryXMswtzz6x767octl6oLThn33mQsPSy3GKglFZoCTXJR4ij ba8SxB04sL/N8eRrKja7TFWjCVtRwTTfyy771NYYNFVJclkxHyE5qw4m27crHF1y diff --git a/excalidraw-app/vite-env.d.ts b/excalidraw-app/vite-env.d.ts index 3230946fb..ade60e859 100644 --- a/excalidraw-app/vite-env.d.ts +++ b/excalidraw-app/vite-env.d.ts @@ -29,6 +29,9 @@ interface ImportMetaEnv { // Enable eslint in dev server VITE_APP_ENABLE_ESLINT: string; + // Enable PWA in dev server + VITE_APP_ENABLE_PWA: string; + VITE_APP_PLUS_LP: string; VITE_APP_PLUS_APP: string; diff --git a/excalidraw-app/vite.config.mts b/excalidraw-app/vite.config.mts index 0345c8ee7..2d18f8c06 100644 --- a/excalidraw-app/vite.config.mts +++ b/excalidraw-app/vite.config.mts @@ -80,7 +80,7 @@ export default defineConfig(({ mode }) => { registerType: "autoUpdate", devOptions: { /* set this flag to true to enable in Development mode */ - enabled: false, + enabled: envVars.VITE_APP_ENABLE_PWA === "true", }, workbox: { @@ -169,6 +169,7 @@ export default defineConfig(({ mode }) => { }, ], start_url: "/", + id:"excalidraw", display: "standalone", theme_color: "#121212", background_color: "#ffffff", From 35f778a734a1cc3de7e52915a431d71610e08baa Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 9 Nov 2024 21:04:50 +0100 Subject: [PATCH 14/29] build: set PWA flag in dev to false (#8788) --- .env.development | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.development b/.env.development index c2e84da60..5f69de146 100644 --- a/.env.development +++ b/.env.development @@ -39,7 +39,7 @@ VITE_APP_COLLAPSE_OVERLAY=true VITE_APP_ENABLE_ESLINT=true # Enable PWA in dev server -VITE_APP_ENABLE_PWA=true +VITE_APP_ENABLE_PWA=false VITE_APP_PLUS_EXPORT_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm2g5T+Rub6Kbf1Mf57t0 7r2zeHuVg4dla3r5ryXMswtzz6x767octl6oLThn33mQsPSy3GKglFZoCTXJR4ij From 6e0ee89ee4464854aec48645c1bd158c9205c057 Mon Sep 17 00:00:00 2001 From: Hamir Mahal Date: Mon, 11 Nov 2024 03:05:55 -0800 Subject: [PATCH 15/29] fix: usage of `node12 which is deprecated` (#8791) --- .github/workflows/semantic-pr-title.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/semantic-pr-title.yml b/.github/workflows/semantic-pr-title.yml index 969d23640..34a6413fe 100644 --- a/.github/workflows/semantic-pr-title.yml +++ b/.github/workflows/semantic-pr-title.yml @@ -11,6 +11,6 @@ jobs: semantic: runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v3.0.0 + - uses: amannn/action-semantic-pull-request@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 57cf577376e283beae08eb46192cfea7caa48d0c Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Mon, 11 Nov 2024 23:56:00 +0530 Subject: [PATCH 16/29] fix: cleanup scripts and support upto node 22 (#8794) --- excalidraw-app/package.json | 2 +- package.json | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/excalidraw-app/package.json b/excalidraw-app/package.json index 69fc1ec48..53bf8e3a1 100644 --- a/excalidraw-app/package.json +++ b/excalidraw-app/package.json @@ -23,7 +23,7 @@ ] }, "engines": { - "node": ">=18.0.0" + "node": "18.0.0 - 22.x.x" }, "dependencies": { "@excalidraw/random-username": "1.0.0", diff --git a/package.json b/package.json index df08cc278..3bd87196e 100644 --- a/package.json +++ b/package.json @@ -55,15 +55,9 @@ "build:app": "yarn --cwd ./excalidraw-app build:app", "build:version": "yarn --cwd ./excalidraw-app build:version", "build": "yarn --cwd ./excalidraw-app build", - "fix:code": "yarn test:code --fix", - "fix:other": "yarn prettier --write", - "fix": "yarn fix:other && yarn fix:code", - "locales-coverage": "node scripts/build-locales-coverage.js", - "locales-coverage:description": "node scripts/locales-coverage-description.js", - "prepare": "husky install", - "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", + "build:preview": "yarn --cwd ./excalidraw-app build:preview", "start": "yarn --cwd ./excalidraw-app start", - "start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o", + "start:production": "yarn --cwd ./excalidraw-app start:production", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false", "test:app": "vitest", "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", @@ -74,9 +68,15 @@ "test:coverage": "vitest --coverage", "test:coverage:watch": "vitest --coverage --watch", "test:ui": "yarn test --ui --coverage.enabled=true", + "fix:code": "yarn test:code --fix", + "fix:other": "yarn prettier --write", + "fix": "yarn fix:other && yarn fix:code", + "locales-coverage": "node scripts/build-locales-coverage.js", + "locales-coverage:description": "node scripts/locales-coverage-description.js", + "prepare": "husky install", + "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "autorelease": "node scripts/autorelease.js", "prerelease:excalidraw": "node scripts/prerelease.js", - "build:preview": "yarn build && vite preview --port 5000", "release:excalidraw": "node scripts/release.js", "rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/*/{build,dist}", "rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules", From 98c0a67333046afc1d9fbf90f87dd55923c3773d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 20:34:35 +0100 Subject: [PATCH 17/29] build(deps): bump cross-spawn from 7.0.3 to 7.0.6 (#8824) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 9b3180535..e77a95c4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4819,9 +4819,9 @@ cross-fetch@3.1.5: node-fetch "2.6.7" cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" From 0927431d0d9145589554a77f0d6d3fd7c3e7149e Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 19 Nov 2024 20:46:55 +0100 Subject: [PATCH 18/29] chore: bump `@excalidraw/mermaid-to-excalidraw` (#8829) --- packages/excalidraw/package.json | 2 +- yarn.lock | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 0f6f6b8a1..f9cff1b6a 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -58,7 +58,7 @@ "dependencies": { "@braintree/sanitize-url": "6.0.2", "@excalidraw/laser-pointer": "1.3.1", - "@excalidraw/mermaid-to-excalidraw": "1.1.0", + "@excalidraw/mermaid-to-excalidraw": "1.1.1", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.0.3", "@radix-ui/react-tabs": "1.0.2", diff --git a/yarn.lock b/yarn.lock index e77a95c4e..c2814308a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1891,13 +1891,13 @@ resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== -"@excalidraw/mermaid-to-excalidraw@1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.0.tgz#a24a7aa3ad2e4f671054fdb670a8508bab463814" - integrity sha512-YP2roqrImzek1SpUAeToSTNhH5Gfw9ogdI5KHp7c+I/mX7SEW8oNqqX7CP+oHcUgNF6RrYIkqSrnMRN9/3EGLg== +"@excalidraw/mermaid-to-excalidraw@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.1.tgz#b06cfa64cb125aea12b9f229c897b06104c12bac" + integrity sha512-sYij5SOcMPNoWcvgfxSCjb3EMx1RvevLiLiOjkUy2nexZVhvfTnuJXmI4gAJeVxXf4e5tEPqUChdCveC9dWoPw== dependencies: "@excalidraw/markdown-to-text" "0.1.2" - mermaid "10.9.0" + mermaid "10.9.3" nanoid "4.0.2" "@excalidraw/prettier-config@1.0.2": @@ -5428,10 +5428,10 @@ domhandler@^4.2.0, domhandler@^4.3.1: dependencies: domelementtype "^2.2.0" -dompurify@^3.0.5: - version "3.1.4" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.4.tgz#42121304b2b3a6bae22f80131ff8a8f3f3c56be2" - integrity sha512-2gnshi6OshmuKil8rMZuQCGiUF3cUxHY3NGDzUAdUx/NPEe5DVnO8BDoAQouvgwnx0R/+a6jUn36Z0FSdq8vww== +"dompurify@^3.0.5 <3.1.7": + version "3.1.6" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.6.tgz#43c714a94c6a7b8801850f82e756685300a027e2" + integrity sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ== domutils@^2.8.0: version "2.8.0" @@ -7818,10 +7818,10 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -mermaid@10.9.0: - version "10.9.0" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.0.tgz#4d1272fbe434bd8f3c2c150554dc8a23a9bf9361" - integrity sha512-swZju0hFox/B/qoLKK0rOxxgh8Cf7rJSfAUc1u8fezVihYMvrJAS45GzAxTVf4Q+xn9uMgitBcmWk7nWGXOs/g== +mermaid@10.9.3: + version "10.9.3" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.3.tgz#90bc6f15c33dbe5d9507fed31592cc0d88fee9f7" + integrity sha512-V80X1isSEvAewIL3xhmz/rVmc27CVljcsbWxkxlWJWY/1kQa4XOABqpDl2qQLGKzpKm6WbTfUEKImBlUfFYArw== dependencies: "@braintree/sanitize-url" "^6.0.1" "@types/d3-scale" "^4.0.3" @@ -7832,7 +7832,7 @@ mermaid@10.9.0: d3-sankey "^0.12.3" dagre-d3-es "7.0.10" dayjs "^1.11.7" - dompurify "^3.0.5" + dompurify "^3.0.5 <3.1.7" elkjs "^0.9.0" katex "^0.16.9" khroma "^2.0.0" From 2db5bbcb29e263f4a5b2609b626d38d93d4758ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Wed, 20 Nov 2024 11:46:45 +0100 Subject: [PATCH 19/29] fix: Unify binding update options for `updateBoundElements()` (#8832) Fix insonsistent naming for option newSize/oldSize for updateBoundElements() --- packages/excalidraw/components/App.tsx | 2 +- .../excalidraw/components/Stats/MultiDimension.tsx | 3 +-- packages/excalidraw/components/Stats/utils.ts | 4 +--- packages/excalidraw/element/binding.ts | 8 ++++---- packages/excalidraw/element/resizeElements.ts | 11 +++++------ packages/excalidraw/tests/cropElement.test.tsx | 4 ++-- .../excalidraw/tests/linearElementEditor.test.tsx | 3 +-- packages/excalidraw/tests/resize.test.tsx | 4 ++-- 8 files changed, 17 insertions(+), 22 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 5723a0602..611634dad 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -10237,7 +10237,7 @@ class App extends React.Component { croppingElement, this.scene.getNonDeletedElementsMap(), { - oldSize: { + newSize: { width: croppingElement.width, height: croppingElement.height, }, diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index 0d1a65e91..257642e98 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -69,7 +69,6 @@ const resizeElementInGroup = ( originalElementsMap: ElementsMap, ) => { const updates = getResizedUpdates(anchorX, anchorY, scale, origElement); - const { width: oldWidth, height: oldHeight } = latestElement; mutateElement(latestElement, updates, false); const boundTextElement = getBoundTextElement( @@ -79,7 +78,7 @@ const resizeElementInGroup = ( if (boundTextElement) { const newFontSize = boundTextElement.fontSize * scale; updateBoundElements(latestElement, elementsMap, { - oldSize: { width: oldWidth, height: oldHeight }, + newSize: { width: updates.width, height: updates.height }, }); const latestBoundTextElement = elementsMap.get(boundTextElement.id); if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index a6a443b9b..3fcbc11c7 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -151,8 +151,6 @@ export const resizeElement = ( nextHeight = Math.max(nextHeight, minHeight); } - const { width: oldWidth, height: oldHeight } = latestElement; - mutateElement( latestElement, { @@ -201,7 +199,7 @@ export const resizeElement = ( } updateBoundElements(latestElement, elementsMap, { - oldSize: { width: oldWidth, height: oldHeight }, + newSize: { width: nextWidth, height: nextHeight }, }); if (boundTextElement && boundTextFont) { diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index d0faa4269..3c4869fe4 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -576,11 +576,11 @@ export const updateBoundElements = ( elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; - oldSize?: { width: number; height: number }; + newSize?: { width: number; height: number }; changedElements?: Map; }, ) => { - const { oldSize, simultaneouslyUpdated, changedElements } = options ?? {}; + const { newSize, simultaneouslyUpdated, changedElements } = options ?? {}; const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( simultaneouslyUpdated, ); @@ -603,12 +603,12 @@ export const updateBoundElements = ( startBinding: maybeCalculateNewGapWhenScaling( changedElement, element.startBinding, - oldSize, + newSize, ), endBinding: maybeCalculateNewGapWhenScaling( changedElement, element.endBinding, - oldSize, + newSize, ), }; diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 1fea04371..dab5ffa1c 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -739,9 +739,9 @@ export const resizeSingleElement = ( mutateElement(element, resizedElement); updateBoundElements(element, elementsMap, { - oldSize: { - width: stateAtResizeStart.width, - height: stateAtResizeStart.height, + newSize: { + width: resizedElement.width, + height: resizedElement.height, }, }); @@ -999,14 +999,13 @@ export const resizeMultipleElements = ( element, update: { boundTextFontSize, ...update }, } of elementsAndUpdates) { - const { angle } = update; - const { width: oldWidth, height: oldHeight } = element; + const { angle, width: newWidth, height: newHeight } = update; mutateElement(element, update, false); updateBoundElements(element, elementsMap, { simultaneouslyUpdated: elementsToUpdate, - oldSize: { width: oldWidth, height: oldHeight }, + newSize: { width: newWidth, height: newHeight }, }); const boundTextElement = getBoundTextElement(element, elementsMap); diff --git a/packages/excalidraw/tests/cropElement.test.tsx b/packages/excalidraw/tests/cropElement.test.tsx index f2cb297f2..9b03c5261 100644 --- a/packages/excalidraw/tests/cropElement.test.tsx +++ b/packages/excalidraw/tests/cropElement.test.tsx @@ -156,8 +156,8 @@ describe("Crop an image", () => { [-initialWidth / 3, 0], true, ); - expect(image.width).toBe(resizedWidth); - expect(image.height).toBe(resizedHeight); + expect(image.width).toBeCloseTo(resizedWidth, 10); + expect(image.height).toBeCloseTo(resizedHeight, 10); // re-crop to initial state UI.crop(image, "w", naturalWidth, naturalHeight, [-initialWidth / 3, 0]); diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index 3341d2da3..a6abfcdc9 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -1235,8 +1235,7 @@ describe("Test Linear Elements", () => { mouse.downAt(rect.x, rect.y); mouse.moveTo(200, 0); mouse.upAt(200, 0); - - expect(arrow.width).toBe(205); + expect(arrow.width).toBe(200); expect(rect.x).toBe(200); expect(rect.y).toBe(0); expect(handleBindTextResizeSpy).toHaveBeenCalledWith( diff --git a/packages/excalidraw/tests/resize.test.tsx b/packages/excalidraw/tests/resize.test.tsx index 05f8627a8..abf99bcf6 100644 --- a/packages/excalidraw/tests/resize.test.tsx +++ b/packages/excalidraw/tests/resize.test.tsx @@ -882,11 +882,11 @@ describe("multiple selection", () => { expect(leftBoundArrow.x).toBeCloseTo(-110); expect(leftBoundArrow.y).toBeCloseTo(50); - expect(leftBoundArrow.width).toBeCloseTo(137.5, 0); + expect(leftBoundArrow.width).toBeCloseTo(140, 0); expect(leftBoundArrow.height).toBeCloseTo(7, 0); expect(leftBoundArrow.angle).toEqual(0); expect(leftBoundArrow.startBinding).toBeNull(); - expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(12.352); + expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10); expect(leftBoundArrow.endBinding?.elementId).toBe( leftArrowBinding.elementId, ); From 840f1428c49e3dffa6474743ca2677b7697638db Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 20 Nov 2024 13:10:07 +0100 Subject: [PATCH 20/29] chore: bump `@excalidraw/mermaid-to-excalidraw@1.1.2` (#8830) --- packages/excalidraw/package.json | 2 +- yarn.lock | 29 ++++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index f9cff1b6a..0bec2a7c0 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -58,7 +58,7 @@ "dependencies": { "@braintree/sanitize-url": "6.0.2", "@excalidraw/laser-pointer": "1.3.1", - "@excalidraw/mermaid-to-excalidraw": "1.1.1", + "@excalidraw/mermaid-to-excalidraw": "1.1.2", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.0.3", "@radix-ui/react-tabs": "1.0.2", diff --git a/yarn.lock b/yarn.lock index c2814308a..d6dfc6568 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1891,10 +1891,10 @@ resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== -"@excalidraw/mermaid-to-excalidraw@1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.1.tgz#b06cfa64cb125aea12b9f229c897b06104c12bac" - integrity sha512-sYij5SOcMPNoWcvgfxSCjb3EMx1RvevLiLiOjkUy2nexZVhvfTnuJXmI4gAJeVxXf4e5tEPqUChdCveC9dWoPw== +"@excalidraw/mermaid-to-excalidraw@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.2.tgz#74d9507971976a7d3d960a1b2e8fb49a9f1f0d22" + integrity sha512-hAFv/TTIsOdoy0dL5v+oBd297SQ+Z88gZ5u99fCIFuEMHfQuPgLhU/ztKhFSTs7fISwVo6fizny/5oQRR3d4tQ== dependencies: "@excalidraw/markdown-to-text" "0.1.2" mermaid "10.9.3" @@ -3892,6 +3892,11 @@ ansi-regex@^5.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== +ansi-regex@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654" + integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== + ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -9743,13 +9748,27 @@ stringify-object@^3.3.0: dependencies: ansi-regex "^5.0.1" -strip-ansi@6.0.1, strip-ansi@^3.0.0, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1: +strip-ansi@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg== + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: ansi-regex "^5.0.1" +strip-ansi@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" From d21e0008ddb4470e5c8d9f5d5e33c033a7edc1c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Thu, 21 Nov 2024 15:18:18 +0100 Subject: [PATCH 21/29] fix: Make some events expllicitly active to avoid console warnings (#8757) Avoid chrome/edge reporting of by-default blocking event handlers --- packages/excalidraw/components/App.tsx | 47 +++++++++++++------ packages/excalidraw/components/SearchMenu.tsx | 1 + 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 611634dad..44d49cd0a 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2557,19 +2557,27 @@ class App extends React.Component { { passive: false }, ), addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false), - addEventListener(document, EVENT.POINTER_UP, this.removePointer), // #3553 - addEventListener(document, EVENT.COPY, this.onCopy), + addEventListener(document, EVENT.POINTER_UP, this.removePointer, { + passive: false, + }), // #3553 + addEventListener(document, EVENT.COPY, this.onCopy, { passive: false }), addEventListener(document, EVENT.KEYUP, this.onKeyUp, { passive: true }), addEventListener( document, EVENT.POINTER_MOVE, this.updateCurrentCursorPosition, + { passive: false }, ), // rerender text elements on font load to fix #637 && #1553 - addEventListener(document.fonts, "loadingdone", (event) => { - const fontFaces = (event as FontFaceSetLoadEvent).fontfaces; - this.fonts.onLoaded(fontFaces); - }), + addEventListener( + document.fonts, + "loadingdone", + (event) => { + const fontFaces = (event as FontFaceSetLoadEvent).fontfaces; + this.fonts.onLoaded(fontFaces); + }, + { passive: false }, + ), // Safari-only desktop pinch zoom addEventListener( document, @@ -2589,12 +2597,17 @@ class App extends React.Component { this.onGestureEnd as any, false, ), - addEventListener(window, EVENT.FOCUS, () => { - this.maybeCleanupAfterMissingPointerUp(null); - // browsers (chrome?) tend to free up memory a lot, which results - // in canvas context being cleared. Thus re-render on focus. - this.triggerRender(true); - }), + addEventListener( + window, + EVENT.FOCUS, + () => { + this.maybeCleanupAfterMissingPointerUp(null); + // browsers (chrome?) tend to free up memory a lot, which results + // in canvas context being cleared. Thus re-render on focus. + this.triggerRender(true); + }, + { passive: false }, + ), ); if (this.state.viewModeEnabled) { @@ -2610,9 +2623,12 @@ class App extends React.Component { document, EVENT.FULLSCREENCHANGE, this.onFullscreenChange, + { passive: false }, ), - addEventListener(document, EVENT.PASTE, this.pasteFromClipboard), - addEventListener(document, EVENT.CUT, this.onCut), + addEventListener(document, EVENT.PASTE, this.pasteFromClipboard, { + passive: false, + }), + addEventListener(document, EVENT.CUT, this.onCut, { passive: false }), addEventListener(window, EVENT.RESIZE, this.onResize, false), addEventListener(window, EVENT.UNLOAD, this.onUnload, false), addEventListener(window, EVENT.BLUR, this.onBlur, false), @@ -2620,6 +2636,7 @@ class App extends React.Component { this.excalidrawContainerRef.current, EVENT.WHEEL, this.handleWheel, + { passive: false }, ), addEventListener( this.excalidrawContainerRef.current, @@ -2641,6 +2658,7 @@ class App extends React.Component { getNearestScrollableContainer(this.excalidrawContainerRef.current!), EVENT.SCROLL, this.onScroll, + { passive: false }, ), ); } @@ -9806,6 +9824,7 @@ class App extends React.Component { this.interactiveCanvas.addEventListener( EVENT.TOUCH_START, this.onTouchStart, + { passive: false }, ); this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTouchEnd); // ----------------------------------------------------------------------- diff --git a/packages/excalidraw/components/SearchMenu.tsx b/packages/excalidraw/components/SearchMenu.tsx index 36922b0a5..b4bb91b90 100644 --- a/packages/excalidraw/components/SearchMenu.tsx +++ b/packages/excalidraw/components/SearchMenu.tsx @@ -294,6 +294,7 @@ export const SearchMenu = () => { // as well as to handle events before App ones return addEventListener(window, EVENT.KEYDOWN, eventHandler, { capture: true, + passive: false, }); }, [setAppState, stableState, app]); From ab8b3537b386403f7a09288eb5925ce1aae5f21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Thu, 21 Nov 2024 15:19:00 +0100 Subject: [PATCH 22/29] fix: Optimize frameToHighlight state change and snapLines state change (#8763) Fix case when frame interactions recursively call setState() without any change. --- packages/excalidraw/components/App.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 44d49cd0a..c4b4f71e2 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7940,10 +7940,14 @@ class App extends React.Component { isFrameLikeElement(e), ); const topLayerFrame = this.getTopLayerFrameAtSceneCoords(pointerCoords); - this.setState({ - frameToHighlight: - topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null, - }); + const frameToHighlight = + topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null; + // Only update the state if there is a difference + if (this.state.frameToHighlight !== frameToHighlight) { + flushSync(() => { + this.setState({ frameToHighlight }); + }); + } // Marking that click was used for dragging to check // if elements should be deselected on pointerup @@ -8090,7 +8094,9 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), ); - this.setState({ snapLines }); + flushSync(() => { + this.setState({ snapLines }); + }); // when we're editing the name of a frame, we want the user to be // able to select and interact with the text input From b2a6a87b10f31babd1ee1a69b02a3f3f24ba492f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Thu, 21 Nov 2024 15:19:20 +0100 Subject: [PATCH 23/29] chore: Remove @tldraw/vec (#8762) Not needed. --- packages/excalidraw/package.json | 1 - yarn.lock | 5 ----- 2 files changed, 6 deletions(-) diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 0bec2a7c0..a58bd185a 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -62,7 +62,6 @@ "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.0.3", "@radix-ui/react-tabs": "1.0.2", - "@tldraw/vec": "1.7.1", "browser-fs-access": "0.29.1", "canvas-roundrect-polyfill": "0.0.1", "clsx": "1.1.1", diff --git a/yarn.lock b/yarn.lock index d6dfc6568..c67e3d8b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3068,11 +3068,6 @@ dependencies: "@babel/runtime" "^7.12.5" -"@tldraw/vec@1.7.1": - version "1.7.1" - resolved "https://registry.yarnpkg.com/@tldraw/vec/-/vec-1.7.1.tgz#5bfac9a56e11ad890cbd1c620293d7fcb23bf1ea" - integrity sha512-qM6Z9RvkLFFEzr91mmsA4HI14msyDgDDOu36csIzG5BYu2bFmEz5siQ8WntHgDtUjzJHP+VSSOTbAXhklEZHLA== - "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" From a758aaf8f658d083277465e2ba7e571e8ddc86d5 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 26 Nov 2024 17:42:25 +0100 Subject: [PATCH 24/29] fix: update old blog links & add canonical url (#8846) --- README.md | 2 +- dev-docs/docusaurus.config.js | 4 ++-- excalidraw-app/components/EncryptedIcon.tsx | 2 +- excalidraw-app/index.html | 2 ++ .../tests/packages/__snapshots__/excalidraw.test.tsx.snap | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e8cd3b06f..3c7265a80 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Excalidraw Editor | - Blog | + Blog | Documentation | Excalidraw+

diff --git a/dev-docs/docusaurus.config.js b/dev-docs/docusaurus.config.js index a246522c1..7899df164 100644 --- a/dev-docs/docusaurus.config.js +++ b/dev-docs/docusaurus.config.js @@ -66,7 +66,7 @@ const config = { label: "Docs", }, { - to: "https://blog.excalidraw.com", + to: "https://plus.excalidraw.com/blog", label: "Blog", position: "left", }, @@ -111,7 +111,7 @@ const config = { items: [ { label: "Blog", - to: "https://blog.excalidraw.com", + to: "https://plus.excalidraw.com/blog", }, { label: "GitHub", diff --git a/excalidraw-app/components/EncryptedIcon.tsx b/excalidraw-app/components/EncryptedIcon.tsx index 3b8655eff..8d2dd88f4 100644 --- a/excalidraw-app/components/EncryptedIcon.tsx +++ b/excalidraw-app/components/EncryptedIcon.tsx @@ -8,7 +8,7 @@ export const EncryptedIcon = () => { return ( + +