From ed5ce8d3ded0c4797ce729cc857c7eaab5c5b74a Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 8 May 2024 17:56:05 +0200 Subject: [PATCH 01/50] fix: command palette filter (#7981) --- .../excalidraw/components/CommandPalette/CommandPalette.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 4147ca085..36c9a9a68 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -540,7 +540,7 @@ function CommandPaletteInner({ ...command, icon: command.icon || boltIcon, order: command.order ?? getCategoryOrder(command.category), - haystack: `${deburr(command.label)} ${ + haystack: `${deburr(command.label.toLocaleLowerCase())} ${ command.keywords?.join(" ") || "" }`, }; @@ -777,7 +777,9 @@ function CommandPaletteInner({ return; } - const _query = deburr(commandSearch.replace(/[<>-_| ]/g, "")); + const _query = deburr( + commandSearch.toLocaleLowerCase().replace(/[<>_| -]/g, ""), + ); matchingCommands = fuzzy .filter(_query, matchingCommands, { extract: (command) => command.haystack, From 301e83805dc6a14ff622624ef9a68a560dde7230 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 8 May 2024 22:02:28 +0200 Subject: [PATCH 02/50] feat: add install-PWA to command palette (#7935) --- excalidraw-app/App.tsx | 47 +++++++++++++++++++++++++++++ packages/excalidraw/locales/en.json | 3 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 25fcbd69d..ec091d95b 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -126,6 +126,38 @@ polyfill(); window.EXCALIDRAW_THROTTLE_RENDER = true; +declare global { + interface BeforeInstallPromptEventChoiceResult { + outcome: "accepted" | "dismissed"; + } + + interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; + userChoice: Promise; + } + + interface WindowEventMap { + beforeinstallprompt: BeforeInstallPromptEvent; + } +} + +let pwaEvent: BeforeInstallPromptEvent | null = null; + +// Adding a listener outside of the component as it may (?) need to be +// subscribed early to catch the event. +// +// Also note that it will fire only if certain heuristics are met (user has +// used the app for some time, etc.) +window.addEventListener( + "beforeinstallprompt", + (event: BeforeInstallPromptEvent) => { + // prevent Chrome <= 67 from automatically showing the prompt + event.preventDefault(); + // cache for later use + pwaEvent = event; + }, +); + let isSelfEmbedding = false; if (window.self !== window.top) { @@ -1100,6 +1132,21 @@ const ExcalidrawWrapper = () => { ); }, }, + { + label: t("labels.installPWA"), + category: DEFAULT_CATEGORIES.app, + predicate: () => !!pwaEvent, + perform: () => { + if (pwaEvent) { + pwaEvent.prompt(); + pwaEvent.userChoice.then(() => { + // event cannot be reused, but we'll hopefully + // grab new one as the event should be fired again + pwaEvent = null; + }); + } + }, + }, ]} /> diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 512ec4ea0..eac6a3f66 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -148,7 +148,8 @@ "discordChat": "Discord chat", "zoomToFitViewport": "Zoom to fit in viewport", "zoomToFitSelection": "Zoom to fit selection", - "zoomToFit": "Zoom to fit all elements" + "zoomToFit": "Zoom to fit all elements", + "installPWA": "Install Excalidraw locally (PWA)" }, "library": { "noItems": "No items added yet...", From 273ba803d96322a7a2fff61d684ac8dc77fcb636 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 10 May 2024 16:37:46 +0200 Subject: [PATCH 03/50] fix: font not rendered correctly on init (#8002) --- packages/excalidraw/components/App.tsx | 32 +++++++++---------- packages/excalidraw/components/LayerUI.tsx | 2 +- .../components/canvases/InteractiveCanvas.tsx | 6 ++-- .../components/canvases/StaticCanvas.tsx | 6 ++-- packages/excalidraw/element/mutateElement.ts | 6 ++-- packages/excalidraw/element/resizeElements.ts | 4 +-- packages/excalidraw/element/textWysiwyg.tsx | 2 +- packages/excalidraw/scene/Fonts.ts | 28 ++++------------ packages/excalidraw/scene/Renderer.ts | 7 ++-- packages/excalidraw/scene/Scene.ts | 24 ++++++++------ 10 files changed, 53 insertions(+), 64 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b461d4d16..d405b7213 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -714,10 +714,7 @@ class App extends React.Component { id: this.id, }; - this.fonts = new Fonts({ - scene: this.scene, - onSceneUpdated: this.onSceneUpdated, - }); + this.fonts = new Fonts({ scene: this.scene }); this.history = new History(); this.actionManager.registerAll(actions); @@ -940,7 +937,7 @@ class App extends React.Component { }); if (updated) { - this.scene.informMutation(); + this.scene.triggerUpdate(); } // GC @@ -1452,10 +1449,10 @@ class App extends React.Component { const selectedElements = this.scene.getSelectedElements(this.state); const { renderTopRightUI, renderCustomStats } = this.props; - const versionNonce = this.scene.getVersionNonce(); + const sceneNonce = this.scene.getSceneNonce(); const { elementsMap, visibleElements } = this.renderer.getRenderableElements({ - versionNonce, + sceneNonce, zoom: this.state.zoom, offsetLeft: this.state.offsetLeft, offsetTop: this.state.offsetTop, @@ -1673,7 +1670,7 @@ class App extends React.Component { elementsMap={elementsMap} allElementsMap={allElementsMap} visibleElements={visibleElements} - versionNonce={versionNonce} + sceneNonce={sceneNonce} selectionNonce={ this.state.selectionElement?.versionNonce } @@ -1695,7 +1692,7 @@ class App extends React.Component { elementsMap={elementsMap} visibleElements={visibleElements} selectedElements={selectedElements} - versionNonce={versionNonce} + sceneNonce={sceneNonce} selectionNonce={ this.state.selectionElement?.versionNonce } @@ -1819,7 +1816,7 @@ class App extends React.Component { ); } this.magicGenerations.set(frameElement.id, data); - this.onSceneUpdated(); + this.triggerRender(); }; private getTextFromElements(elements: readonly ExcalidrawElement[]) { @@ -2444,7 +2441,7 @@ class App extends React.Component { this.history.record(increment.elementsChange, increment.appStateChange); }); - this.scene.addCallback(this.onSceneUpdated); + this.scene.onUpdate(this.triggerRender); this.addEventListeners(); if (this.props.autoFocus && this.excalidrawContainerRef.current) { @@ -2489,6 +2486,7 @@ class App extends React.Component { public componentWillUnmount() { this.renderer.destroy(); this.scene = new Scene(); + this.fonts = new Fonts({ scene: this.scene }); this.renderer = new Renderer(this.scene); this.files = {}; this.imageCache.clear(); @@ -3670,7 +3668,7 @@ class App extends React.Component { ShapeCache.delete(element); } }); - this.scene.informMutation(); + this.scene.triggerUpdate(); this.addNewImagesToImageCache(); }, @@ -3730,7 +3728,7 @@ class App extends React.Component { }, ); - private onSceneUpdated = () => { + private triggerRender = () => { this.setState({}); }; @@ -5577,7 +5575,7 @@ class App extends React.Component { } this.elementsPendingErasure = new Set(this.elementsPendingErasure); - this.onSceneUpdated(); + this.triggerRender(); } }; @@ -8069,7 +8067,7 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), ); - this.scene.informMutation(); + this.scene.triggerUpdate(); } } } @@ -8564,7 +8562,7 @@ class App extends React.Component { private restoreReadyToEraseElements = () => { this.elementsPendingErasure = new Set(); - this.onSceneUpdated(); + this.triggerRender(); }; private eraseElements = () => { @@ -8978,7 +8976,7 @@ class App extends React.Component { files, ); if (updatedFiles.size) { - this.scene.informMutation(); + this.scene.triggerUpdate(); } } }; diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index dd34f433b..a623891c6 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -444,7 +444,7 @@ const LayerUI = ({ ); ShapeCache.delete(element); } - Scene.getScene(selectedElements[0])?.informMutation(); + Scene.getScene(selectedElements[0])?.triggerUpdate(); } else if (colorPickerType === "elementBackground") { setAppState({ currentItemBackgroundColor: color, diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index c8eb799a5..ceed879b7 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -19,7 +19,7 @@ type InteractiveCanvasProps = { elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[]; - versionNonce: number | undefined; + sceneNonce: number | undefined; selectionNonce: number | undefined; scale: number; appState: InteractiveCanvasAppState; @@ -206,10 +206,10 @@ const areEqual = ( // This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation if ( prevProps.selectionNonce !== nextProps.selectionNonce || - prevProps.versionNonce !== nextProps.versionNonce || + prevProps.sceneNonce !== nextProps.sceneNonce || prevProps.scale !== nextProps.scale || // we need to memoize on elementsMap because they may have renewed - // even if versionNonce didn't change (e.g. we filter elements out based + // even if sceneNonce didn't change (e.g. we filter elements out based // on appState) prevProps.elementsMap !== nextProps.elementsMap || prevProps.visibleElements !== nextProps.visibleElements || diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index ef54bf33f..5d73a57b1 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -19,7 +19,7 @@ type StaticCanvasProps = { elementsMap: RenderableElementsMap; allElementsMap: NonDeletedSceneElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; - versionNonce: number | undefined; + sceneNonce: number | undefined; selectionNonce: number | undefined; scale: number; appState: StaticCanvasAppState; @@ -112,10 +112,10 @@ const areEqual = ( nextProps: StaticCanvasProps, ) => { if ( - prevProps.versionNonce !== nextProps.versionNonce || + prevProps.sceneNonce !== nextProps.sceneNonce || prevProps.scale !== nextProps.scale || // we need to memoize on elementsMap because they may have renewed - // even if versionNonce didn't change (e.g. we filter elements out based + // even if sceneNonce didn't change (e.g. we filter elements out based // on appState) prevProps.elementsMap !== nextProps.elementsMap || prevProps.visibleElements !== nextProps.visibleElements diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index 6bff90384..de0adeeff 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -98,7 +98,7 @@ export const mutateElement = >( element.updated = getUpdatedTimestamp(); if (informMutation) { - Scene.getScene(element)?.informMutation(); + Scene.getScene(element)?.triggerUpdate(); } return element; @@ -107,6 +107,8 @@ export const mutateElement = >( export const newElementWith = ( element: TElement, updates: ElementUpdate, + /** pass `true` to always regenerate */ + force = false, ): TElement => { let didChange = false; for (const key in updates) { @@ -123,7 +125,7 @@ export const newElementWith = ( } } - if (!didChange) { + if (!didChange && !force) { return element; } diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index db2f49625..3630fafd0 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -876,7 +876,7 @@ export const resizeMultipleElements = ( } } - Scene.getScene(elementsAndUpdates[0].element)?.informMutation(); + Scene.getScene(elementsAndUpdates[0].element)?.triggerUpdate(); }; const rotateMultipleElements = ( @@ -938,7 +938,7 @@ const rotateMultipleElements = ( } }); - Scene.getScene(elements[0])?.informMutation(); + Scene.getScene(elements[0])?.triggerUpdate(); }; export const getResizeOffsetXY = ( diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index bfcea2348..e738e27ee 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -644,7 +644,7 @@ export const textWysiwyg = ({ }; // handle updates of textElement properties of editing element - const unbindUpdate = Scene.getScene(element)!.addCallback(() => { + const unbindUpdate = Scene.getScene(element)!.onUpdate(() => { updateWysiwygStyle(); const isColorPickerActive = !!document.activeElement?.closest( ".color-picker-content", diff --git a/packages/excalidraw/scene/Fonts.ts b/packages/excalidraw/scene/Fonts.ts index c2f0c38ae..ff241a40f 100644 --- a/packages/excalidraw/scene/Fonts.ts +++ b/packages/excalidraw/scene/Fonts.ts @@ -1,7 +1,5 @@ -import { isTextElement, refreshTextDimensions } from "../element"; +import { isTextElement } from "../element"; import { newElementWith } from "../element/mutateElement"; -import { getContainerElement } from "../element/textElement"; -import { isBoundToContainer } from "../element/typeChecks"; import type { ExcalidrawElement, ExcalidrawTextElement, @@ -12,17 +10,9 @@ import { ShapeCache } from "./ShapeCache"; export class Fonts { private scene: Scene; - private onSceneUpdated: () => void; - constructor({ - scene, - onSceneUpdated, - }: { - scene: Scene; - onSceneUpdated: () => void; - }) { + constructor({ scene }: { scene: Scene }) { this.scene = scene; - this.onSceneUpdated = onSceneUpdated; } // it's ok to track fonts across multiple instances only once, so let's use @@ -57,22 +47,16 @@ export class Fonts { let didUpdate = false; this.scene.mapElements((element) => { - if (isTextElement(element) && !isBoundToContainer(element)) { - ShapeCache.delete(element); + if (isTextElement(element)) { didUpdate = true; - return newElementWith(element, { - ...refreshTextDimensions( - element, - getContainerElement(element, this.scene.getNonDeletedElementsMap()), - this.scene.getNonDeletedElementsMap(), - ), - }); + ShapeCache.delete(element); + return newElementWith(element, {}, true); } return element; }); if (didUpdate) { - this.onSceneUpdated(); + this.scene.triggerUpdate(); } }; diff --git a/packages/excalidraw/scene/Renderer.ts b/packages/excalidraw/scene/Renderer.ts index 754bb7d76..63b7e7da7 100644 --- a/packages/excalidraw/scene/Renderer.ts +++ b/packages/excalidraw/scene/Renderer.ts @@ -107,9 +107,8 @@ export class Renderer { width, editingElement, pendingImageElementId, - // unused but serves we cache on it to invalidate elements if they - // get mutated - versionNonce: _versionNonce, + // cache-invalidation nonce + sceneNonce: _sceneNonce, }: { zoom: AppState["zoom"]; offsetLeft: AppState["offsetLeft"]; @@ -120,7 +119,7 @@ export class Renderer { width: AppState["width"]; editingElement: AppState["editingElement"]; pendingImageElementId: AppState["pendingImageElementId"]; - versionNonce: ReturnType["getVersionNonce"]>; + sceneNonce: ReturnType["getSceneNonce"]>; }) => { const elements = this.scene.getNonDeletedElements(); diff --git a/packages/excalidraw/scene/Scene.ts b/packages/excalidraw/scene/Scene.ts index 2e46d77f5..105ef3d34 100644 --- a/packages/excalidraw/scene/Scene.ts +++ b/packages/excalidraw/scene/Scene.ts @@ -138,7 +138,17 @@ class Scene { elements: null, cache: new Map(), }; - private versionNonce: number | undefined; + /** + * Random integer regenerated each scene update. + * + * Does not relate to elements versions, it's only a renderer + * cache-invalidation nonce at the moment. + */ + private sceneNonce: number | undefined; + + getSceneNonce() { + return this.sceneNonce; + } getNonDeletedElementsMap() { return this.nonDeletedElementsMap; @@ -214,10 +224,6 @@ class Scene { return (this.elementsMap.get(id) as T | undefined) || null; } - getVersionNonce() { - return this.versionNonce; - } - getNonDeletedElement( id: ExcalidrawElement["id"], ): NonDeleted | null { @@ -286,18 +292,18 @@ class Scene { this.frames = nextFrameLikes; this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements; - this.informMutation(); + this.triggerUpdate(); } - informMutation() { - this.versionNonce = randomInteger(); + triggerUpdate() { + this.sceneNonce = randomInteger(); for (const callback of Array.from(this.callbacks)) { callback(); } } - addCallback(cb: SceneStateCallback): SceneStateCallbackRemover { + onUpdate(cb: SceneStateCallback): SceneStateCallbackRemover { if (this.callbacks.has(cb)) { throw new Error(); } From dc66261c19778270f8bba32548799a17fd0bc653 Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Mon, 13 May 2024 16:38:21 +0100 Subject: [PATCH 04/50] fix: re-introduce wysiwyg width offset (#8014) --- packages/excalidraw/element/textWysiwyg.tsx | 2 ++ .../tests/__snapshots__/linearElementEditor.test.tsx.snap | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index e738e27ee..15cedc001 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -226,6 +226,8 @@ export const textWysiwyg = ({ if (!container) { maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; textElementWidth = Math.min(textElementWidth, maxWidth); + } else { + textElementWidth += 0.5; } // Make sure text editor height doesn't go beyond viewport diff --git a/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap index 1fd7106bd..41e9f2b12 100644 --- a/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap @@ -5,7 +5,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; 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: 10px; height: 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: Virgil, 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: 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: Virgil, Segoe UI Emoji;" tabindex="0" wrap="off" /> From 79257a1923a044f4f2753b2686b9e119ec70ce5e Mon Sep 17 00:00:00 2001 From: Guillaume Grossetie Date: Tue, 14 May 2024 10:01:02 +0200 Subject: [PATCH 05/50] fix: correctly resolve the package version (#8016) The property name is `VITE_PKG_VERSION` (not `PKG_VERSION`) Resolves #7984 --- packages/excalidraw/scene/export.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index c00a8968f..ab51bad8c 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -339,7 +339,7 @@ export const exportToSvg = async ( assetPath = window.EXCALIDRAW_ASSET_PATH || `https://unpkg.com/${import.meta.env.VITE_PKG_NAME}@${ - import.meta.env.PKG_VERSION + import.meta.env.VITE_PKG_VERSION }`; if (assetPath?.startsWith("/")) { From cc4c51996c11f231edcc177d9a19d69e812dd3c9 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 14 May 2024 10:45:27 +0200 Subject: [PATCH 06/50] build: specify `packageManager` field (#8010) --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 50e210b3f..7f8b73de2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "private": true, "name": "excalidraw-monorepo", + "packageManager": "yarn@1.22.22", "workspaces": [ "excalidraw-app", "packages/excalidraw", From 971b4d4ae61bad7fd1c73e7238bf3d56ef40f543 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Wed, 15 May 2024 21:04:53 +0800 Subject: [PATCH 07/50] feat: text wrapping (#7999) * resize single elements from the side * fix lint * do not resize texts from the sides (for we want to wrap/unwrap) * omit side handles for frames too * upgrade types * enable resizing from the sides for multiple elements as well * fix lint * maintain aspect ratio when elements are not of the same angle * lint * always resize proportionally for multiple elements * increase side resizing padding * code cleanup * adaptive handles * do not resize for linear elements with only two points * prioritize point dragging over edge resizing * lint * allow free resizing for multiple elements at degree 0 * always resize from the sides * reduce hit threshold * make small multiple elements movable * lint * show side handles on touch screen and mobile devices * differentiate touchscreens * keep proportional with text in multi-element resizing * update snapshot * update multi elements resizing logic * lint * reduce side resizing padding * bound texts do not scale in normal cases * lint * test sides for texts * wrap text * do not update text size when changing its alignment * keep text wrapped/unwrapped when editing * change wrapped size to auto size from context menu * fix test * lint * increase min width for wrapped texts * wrap wrapped text in container * unwrap when binding text to container * rename `wrapped` to `autoResize` * fix lint * revert: use `center` align when wrapping text in container * update snaps * fix lint * simplify logic on autoResize * lint and test * snapshots * remove unnecessary code * snapshots * fix: defaults not set correctly * tests for wrapping texts when resized * tests for text wrapping when edited * fix autoResize refactor * include autoResize flag check * refactor * feat: rename action label & change contextmenu position * fix: update version on `autoResize` action * fix infinite loop when editing text in a container * simplify * always maintain `width` if `!autoResize` * maintain `x` if `!autoResize` * maintain `y` pos after fontSize change if `!autoResize` * refactor * when editing, do not wrap text in textWysiwyg * simplify text editor * make test more readable * comment * rename action to match file name * revert function signature change * only update in app --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- .../excalidraw/actions/actionBoundText.tsx | 4 +- .../excalidraw/actions/actionProperties.tsx | 2 +- .../actions/actionTextAutoResize.ts | 48 ++++++ packages/excalidraw/actions/types.ts | 3 +- packages/excalidraw/components/App.tsx | 44 +++--- .../data/__snapshots__/transform.test.ts.snap | 25 ++++ packages/excalidraw/data/restore.ts | 2 +- packages/excalidraw/element/index.ts | 1 - packages/excalidraw/element/newElement.ts | 74 ++++----- packages/excalidraw/element/resizeElements.ts | 141 +++++++++++++++--- packages/excalidraw/element/resizeTest.ts | 8 +- packages/excalidraw/element/textElement.ts | 14 +- .../excalidraw/element/textWysiwyg.test.tsx | 124 +++++++++++++-- packages/excalidraw/element/textWysiwyg.tsx | 25 ++-- .../excalidraw/element/transformHandles.ts | 10 -- packages/excalidraw/element/types.ts | 7 + packages/excalidraw/locales/en.json | 3 +- .../__snapshots__/contextmenu.test.tsx.snap | 55 +++++++ .../tests/__snapshots__/history.test.tsx.snap | 25 ++++ .../data/__snapshots__/restore.test.ts.snap | 2 + .../tests/linearElementEditor.test.tsx | 16 +- packages/excalidraw/tests/resize.test.tsx | 106 +++++++++++++ 22 files changed, 596 insertions(+), 143 deletions(-) create mode 100644 packages/excalidraw/actions/actionTextAutoResize.ts diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index f3d93fcf1..f47346036 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -1,8 +1,8 @@ import { BOUND_TEXT_PADDING, ROUNDNESS, - VERTICAL_ALIGN, TEXT_ALIGN, + VERTICAL_ALIGN, } from "../constants"; import { isTextElement, newElement } from "../element"; import { mutateElement } from "../element/mutateElement"; @@ -142,6 +142,7 @@ export const actionBindText = register({ containerId: container.id, verticalAlign: VERTICAL_ALIGN.MIDDLE, textAlign: TEXT_ALIGN.CENTER, + autoResize: true, }); mutateElement(container, { boundElements: (container.boundElements || []).concat({ @@ -296,6 +297,7 @@ export const actionWrapTextInContainer = register({ verticalAlign: VERTICAL_ALIGN.MIDDLE, boundElements: null, textAlign: TEXT_ALIGN.CENTER, + autoResize: true, }, false, ); diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index b26e12de0..d48f78ba4 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -167,7 +167,7 @@ const offsetElementAfterFontResize = ( prevElement: ExcalidrawTextElement, nextElement: ExcalidrawTextElement, ) => { - if (isBoundToContainer(nextElement)) { + if (isBoundToContainer(nextElement) || !nextElement.autoResize) { return nextElement; } return mutateElement( diff --git a/packages/excalidraw/actions/actionTextAutoResize.ts b/packages/excalidraw/actions/actionTextAutoResize.ts new file mode 100644 index 000000000..3093f3090 --- /dev/null +++ b/packages/excalidraw/actions/actionTextAutoResize.ts @@ -0,0 +1,48 @@ +import { isTextElement } from "../element"; +import { newElementWith } from "../element/mutateElement"; +import { measureText } from "../element/textElement"; +import { getSelectedElements } from "../scene"; +import { StoreAction } from "../store"; +import type { AppClassProperties } from "../types"; +import { getFontString } from "../utils"; +import { register } from "./register"; + +export const actionTextAutoResize = register({ + name: "autoResize", + label: "labels.autoResize", + icon: null, + trackEvent: { category: "element" }, + predicate: (elements, appState, _: unknown, app: AppClassProperties) => { + const selectedElements = getSelectedElements(elements, appState); + return ( + selectedElements.length === 1 && + isTextElement(selectedElements[0]) && + !selectedElements[0].autoResize + ); + }, + perform: (elements, appState, _, app) => { + const selectedElements = getSelectedElements(elements, appState); + + return { + appState, + elements: elements.map((element) => { + if (element.id === selectedElements[0].id && isTextElement(element)) { + const metrics = measureText( + element.originalText, + getFontString(element), + element.lineHeight, + ); + + return newElementWith(element, { + autoResize: true, + width: metrics.width, + height: metrics.height, + text: element.originalText, + }); + } + return element; + }), + storeAction: StoreAction.CAPTURE, + }; + }, +}); diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 401fe7432..28034bdb6 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -134,7 +134,8 @@ export type ActionName = | "setEmbeddableAsActiveTool" | "createContainerFromText" | "wrapTextInContainer" - | "commandPalette"; + | "commandPalette" + | "autoResize"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d405b7213..deb33c568 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -114,7 +114,7 @@ import { newTextElement, newImageElement, transformElements, - updateTextElement, + refreshTextDimensions, redrawTextBoundingBox, getElementAbsoluteCoords, } from "../element"; @@ -429,6 +429,7 @@ import { isPointHittingLinkIcon, } from "./hyperlink/helpers"; import { getShortcutFromShortcutName } from "../actions/shortcuts"; +import { actionTextAutoResize } from "../actions/actionTextAutoResize"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -4298,25 +4299,22 @@ class App extends React.Component { ) { const elementsMap = this.scene.getElementsMapIncludingDeleted(); - const updateElement = ( - text: string, - originalText: string, - isDeleted: boolean, - ) => { + const updateElement = (nextOriginalText: string, isDeleted: boolean) => { this.scene.replaceAllElements([ // Not sure why we include deleted elements as well hence using deleted elements map ...this.scene.getElementsIncludingDeleted().map((_element) => { if (_element.id === element.id && isTextElement(_element)) { - return updateTextElement( - _element, - getContainerElement(_element, elementsMap), - elementsMap, - { - text, - isDeleted, - originalText, - }, - ); + return newElementWith(_element, { + originalText: nextOriginalText, + isDeleted: isDeleted ?? _element.isDeleted, + // returns (wrapped) text and new dimensions + ...refreshTextDimensions( + _element, + getContainerElement(_element, elementsMap), + elementsMap, + nextOriginalText, + ), + }); } return _element; }), @@ -4339,15 +4337,15 @@ class App extends React.Component { viewportY - this.state.offsetTop, ]; }, - onChange: withBatchedUpdates((text) => { - updateElement(text, text, false); + onChange: withBatchedUpdates((nextOriginalText) => { + updateElement(nextOriginalText, false); if (isNonDeletedElement(element)) { updateBoundElements(element, elementsMap); } }), - onSubmit: withBatchedUpdates(({ text, viaKeyboard, originalText }) => { - const isDeleted = !text.trim(); - updateElement(text, originalText, isDeleted); + onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { + const isDeleted = !nextOriginalText.trim(); + updateElement(nextOriginalText, isDeleted); // select the created text element only if submitting via keyboard // (when submitting via click it should act as signal to deselect) if (!isDeleted && viaKeyboard) { @@ -4392,7 +4390,7 @@ class App extends React.Component { // do an initial update to re-initialize element position since we were // modifying element's x/y for sake of editor (case: syncing to remote) - updateElement(element.text, element.originalText, false); + updateElement(element.originalText, false); } private deselectElements() { @@ -9631,6 +9629,7 @@ class App extends React.Component { } return [ + CONTEXT_MENU_SEPARATOR, actionCut, actionCopy, actionPaste, @@ -9643,6 +9642,7 @@ class App extends React.Component { actionPasteStyles, CONTEXT_MENU_SEPARATOR, actionGroup, + actionTextAutoResize, actionUnbindText, actionBindText, actionWrapTextInContainer, diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index adb5b0372..46105b967 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -228,6 +228,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": [ { @@ -273,6 +274,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 2`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": [ { @@ -378,6 +380,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 4`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id48", @@ -478,6 +481,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id37", @@ -652,6 +656,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id41", @@ -692,6 +697,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": [ { @@ -737,6 +743,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": [ { @@ -1194,6 +1201,7 @@ exports[`Test Transform > should transform regular shapes 6`] = ` exports[`Test Transform > should transform text element 1`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": null, @@ -1234,6 +1242,7 @@ exports[`Test Transform > should transform text element 1`] = ` exports[`Test Transform > should transform text element 2`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": null, @@ -1566,6 +1575,7 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 7`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "B", @@ -1608,6 +1618,7 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 8`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "A", @@ -1650,6 +1661,7 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 9`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "Alice", @@ -1692,6 +1704,7 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 10`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "Bob", @@ -1734,6 +1747,7 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 11`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "Bob_Alice", @@ -1774,6 +1788,7 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 12`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "Bob_B", @@ -2022,6 +2037,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide exports[`Test Transform > should transform to labelled arrows when label provided for arrows 5`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id25", @@ -2062,6 +2078,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide exports[`Test Transform > should transform to labelled arrows when label provided for arrows 6`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id26", @@ -2102,6 +2119,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide exports[`Test Transform > should transform to labelled arrows when label provided for arrows 7`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id27", @@ -2143,6 +2161,7 @@ LABELLED ARROW", exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id28", @@ -2406,6 +2425,7 @@ exports[`Test Transform > should transform to text containers when label provide exports[`Test Transform > should transform to text containers when label provided 7`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id13", @@ -2446,6 +2466,7 @@ exports[`Test Transform > should transform to text containers when label provide exports[`Test Transform > should transform to text containers when label provided 8`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id14", @@ -2487,6 +2508,7 @@ CONTAINER", exports[`Test Transform > should transform to text containers when label provided 9`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id15", @@ -2530,6 +2552,7 @@ CONTAINER", exports[`Test Transform > should transform to text containers when label provided 10`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id16", @@ -2571,6 +2594,7 @@ TEXT CONTAINER", exports[`Test Transform > should transform to text containers when label provided 11`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id17", @@ -2613,6 +2637,7 @@ CONTAINER", exports[`Test Transform > should transform to text containers when label provided 12`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id18", diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index e93593155..70d209cc2 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -208,7 +208,7 @@ const restoreElement = ( verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, containerId: element.containerId ?? null, originalText: element.originalText || text, - + autoResize: element.autoResize ?? true, lineHeight, }); diff --git a/packages/excalidraw/element/index.ts b/packages/excalidraw/element/index.ts index b9c203a36..35661608e 100644 --- a/packages/excalidraw/element/index.ts +++ b/packages/excalidraw/element/index.ts @@ -9,7 +9,6 @@ import { isLinearElementType } from "./typeChecks"; export { newElement, newTextElement, - updateTextElement, refreshTextDimensions, newLinearElement, newImageElement, diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index bdee048e8..3fa203f49 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -240,24 +240,28 @@ export const newTextElement = ( metrics, ); - const textElement = newElementWith( - { - ..._newElementBase("text", opts), - text, - fontSize, - fontFamily, - textAlign, - verticalAlign, - x: opts.x - offsets.x, - y: opts.y - offsets.y, - width: metrics.width, - height: metrics.height, - containerId: opts.containerId || null, - originalText: text, - lineHeight, - }, + const textElementProps: ExcalidrawTextElement = { + ..._newElementBase("text", opts), + text, + fontSize, + fontFamily, + textAlign, + verticalAlign, + x: opts.x - offsets.x, + y: opts.y - offsets.y, + width: metrics.width, + height: metrics.height, + containerId: opts.containerId || null, + originalText: text, + autoResize: true, + lineHeight, + }; + + const textElement: ExcalidrawTextElement = newElementWith( + textElementProps, {}, ); + return textElement; }; @@ -271,18 +275,25 @@ const getAdjustedDimensions = ( width: number; height: number; } => { - const { width: nextWidth, height: nextHeight } = measureText( + let { width: nextWidth, height: nextHeight } = measureText( nextText, getFontString(element), element.lineHeight, ); + + // wrapped text + if (!element.autoResize) { + nextWidth = element.width; + } + const { textAlign, verticalAlign } = element; let x: number; let y: number; if ( textAlign === "center" && verticalAlign === VERTICAL_ALIGN.MIDDLE && - !element.containerId + !element.containerId && + element.autoResize ) { const prevMetrics = measureText( element.text, @@ -343,38 +354,19 @@ export const refreshTextDimensions = ( if (textElement.isDeleted) { return; } - if (container) { + if (container || !textElement.autoResize) { text = wrapText( text, getFontString(textElement), - getBoundTextMaxWidth(container, textElement), + container + ? getBoundTextMaxWidth(container, textElement) + : textElement.width, ); } const dimensions = getAdjustedDimensions(textElement, elementsMap, text); return { text, ...dimensions }; }; -export const updateTextElement = ( - textElement: ExcalidrawTextElement, - container: ExcalidrawTextContainer | null, - elementsMap: ElementsMap, - { - text, - isDeleted, - originalText, - }: { - text: string; - isDeleted?: boolean; - originalText: string; - }, -): ExcalidrawTextElement => { - return newElementWith(textElement, { - originalText, - isDeleted: isDeleted ?? textElement.isDeleted, - ...refreshTextDimensions(textElement, container, elementsMap, originalText), - }); -}; - export const newFreeDrawElement = ( opts: { type: "freedraw"; diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 3630fafd0..debac0840 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -1,4 +1,8 @@ -import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants"; +import { + BOUND_TEXT_PADDING, + MIN_FONT_SIZE, + SHIFT_LOCKING_ANGLE, +} from "../constants"; import { rescalePoints } from "../points"; import { rotate, centerPoint, rotatePoint } from "../math"; @@ -45,6 +49,9 @@ import { handleBindTextResize, getBoundTextMaxWidth, getApproxMinLineHeight, + wrapText, + measureText, + getMinCharWidth, } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; import { isInGroup } from "../groups"; @@ -84,14 +91,9 @@ export const transformElements = ( shouldRotateWithDiscreteAngle, ); updateBoundElements(element, elementsMap); - } else if ( - isTextElement(element) && - (transformHandleType === "nw" || - transformHandleType === "ne" || - transformHandleType === "sw" || - transformHandleType === "se") - ) { + } else if (isTextElement(element) && transformHandleType) { resizeSingleTextElement( + originalElements, element, elementsMap, transformHandleType, @@ -223,9 +225,10 @@ const measureFontSizeFromWidth = ( }; const resizeSingleTextElement = ( + originalElements: PointerDownState["originalElements"], element: NonDeleted, elementsMap: ElementsMap, - transformHandleType: "nw" | "ne" | "sw" | "se", + transformHandleType: TransformHandleDirection, shouldResizeFromCenter: boolean, pointerX: number, pointerY: number, @@ -245,17 +248,19 @@ const resizeSingleTextElement = ( let scaleX = 0; let scaleY = 0; - if (transformHandleType.includes("e")) { - scaleX = (rotatedX - x1) / (x2 - x1); - } - if (transformHandleType.includes("w")) { - scaleX = (x2 - rotatedX) / (x2 - x1); - } - if (transformHandleType.includes("n")) { - scaleY = (y2 - rotatedY) / (y2 - y1); - } - if (transformHandleType.includes("s")) { - scaleY = (rotatedY - y1) / (y2 - y1); + if (transformHandleType !== "e" && transformHandleType !== "w") { + if (transformHandleType.includes("e")) { + scaleX = (rotatedX - x1) / (x2 - x1); + } + if (transformHandleType.includes("w")) { + scaleX = (x2 - rotatedX) / (x2 - x1); + } + if (transformHandleType.includes("n")) { + scaleY = (y2 - rotatedY) / (y2 - y1); + } + if (transformHandleType.includes("s")) { + scaleY = (rotatedY - y1) / (y2 - y1); + } } const scale = Math.max(scaleX, scaleY); @@ -318,6 +323,102 @@ const resizeSingleTextElement = ( y: nextY, }); } + + if (transformHandleType === "e" || transformHandleType === "w") { + const stateAtResizeStart = originalElements.get(element.id)!; + const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords( + stateAtResizeStart, + stateAtResizeStart.width, + stateAtResizeStart.height, + true, + ); + const startTopLeft: Point = [x1, y1]; + const startBottomRight: Point = [x2, y2]; + const startCenter: Point = centerPoint(startTopLeft, startBottomRight); + + const rotatedPointer = rotatePoint( + [pointerX, pointerY], + startCenter, + -stateAtResizeStart.angle, + ); + + const [esx1, , esx2] = getResizedElementAbsoluteCoords( + element, + element.width, + element.height, + true, + ); + + const boundsCurrentWidth = esx2 - esx1; + + const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0]; + const minWidth = + getMinCharWidth(getFontString(element)) + BOUND_TEXT_PADDING * 2; + + let scaleX = atStartBoundsWidth / boundsCurrentWidth; + + if (transformHandleType.includes("e")) { + scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth; + } + if (transformHandleType.includes("w")) { + scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth; + } + + const newWidth = + element.width * scaleX < minWidth ? minWidth : element.width * scaleX; + + const text = wrapText( + element.originalText, + getFontString(element), + Math.abs(newWidth), + ); + const metrics = measureText( + text, + getFontString(element), + element.lineHeight, + ); + + const eleNewHeight = metrics.height; + + const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] = + getResizedElementAbsoluteCoords( + stateAtResizeStart, + newWidth, + eleNewHeight, + true, + ); + const newBoundsWidth = newBoundsX2 - newBoundsX1; + const newBoundsHeight = newBoundsY2 - newBoundsY1; + + let newTopLeft = [...startTopLeft] as [number, number]; + if (["n", "w", "nw"].includes(transformHandleType)) { + newTopLeft = [ + startBottomRight[0] - Math.abs(newBoundsWidth), + startTopLeft[1], + ]; + } + + // adjust topLeft to new rotation point + const angle = stateAtResizeStart.angle; + const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle); + const newCenter: Point = [ + newTopLeft[0] + Math.abs(newBoundsWidth) / 2, + newTopLeft[1] + Math.abs(newBoundsHeight) / 2, + ]; + const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle); + newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); + + const resizedElement: Partial = { + width: Math.abs(newWidth), + height: Math.abs(metrics.height), + x: newTopLeft[0], + y: newTopLeft[1], + text, + autoResize: false, + }; + + mutateElement(element, resizedElement); + } }; export const resizeSingleElement = ( diff --git a/packages/excalidraw/element/resizeTest.ts b/packages/excalidraw/element/resizeTest.ts index 3fea7d960..74ebd8e5d 100644 --- a/packages/excalidraw/element/resizeTest.ts +++ b/packages/excalidraw/element/resizeTest.ts @@ -87,12 +87,8 @@ export const resizeTest = ( elementsMap, ); - // Note that for a text element, when "resized" from the side - // we should make it wrap/unwrap - if ( - element.type !== "text" && - !(isLinearElement(element) && element.points.length <= 2) - ) { + // do not resize from the sides for linear elements with only two points + if (!(isLinearElement(element) && element.points.length <= 2)) { const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; const sides = getSelectionBorders( [x1 - SPACING, y1 - SPACING], diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index a81fe9cec..aabcba3bf 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -48,7 +48,7 @@ export const redrawTextBoundingBox = ( textElement: ExcalidrawTextElement, container: ExcalidrawElement | null, elementsMap: ElementsMap, - informMutation: boolean = true, + informMutation = true, ) => { let maxWidth = undefined; const boundTextUpdates = { @@ -62,21 +62,27 @@ export const redrawTextBoundingBox = ( boundTextUpdates.text = textElement.text; - if (container) { - maxWidth = getBoundTextMaxWidth(container, textElement); + if (container || !textElement.autoResize) { + maxWidth = container + ? getBoundTextMaxWidth(container, textElement) + : textElement.width; boundTextUpdates.text = wrapText( textElement.originalText, getFontString(textElement), maxWidth, ); } + const metrics = measureText( boundTextUpdates.text, getFontString(textElement), textElement.lineHeight, ); - boundTextUpdates.width = metrics.width; + // Note: only update width for unwrapped text and bound texts (which always have autoResize set to true) + if (textElement.autoResize) { + boundTextUpdates.width = metrics.width; + } boundTextUpdates.height = metrics.height; if (container) { diff --git a/packages/excalidraw/element/textWysiwyg.test.tsx b/packages/excalidraw/element/textWysiwyg.test.tsx index 78849376d..5691874fb 100644 --- a/packages/excalidraw/element/textWysiwyg.test.tsx +++ b/packages/excalidraw/element/textWysiwyg.test.tsx @@ -236,6 +236,117 @@ describe("textWysiwyg", () => { }); }); + describe("Test text wrapping", () => { + const { h } = window; + const dimensions = { height: 400, width: 800 }; + + beforeAll(() => { + mockBoundingClientRect(dimensions); + }); + + beforeEach(async () => { + await render(); + // @ts-ignore + h.app.refreshViewportBreakpoints(); + // @ts-ignore + h.app.refreshEditorBreakpoints(); + + h.elements = []; + }); + + afterAll(() => { + restoreOriginalGetBoundingClientRect(); + }); + + it("should keep width when editing a wrapped text", async () => { + const text = API.createElement({ + type: "text", + text: "Excalidraw\nEditor", + }); + + h.elements = [text]; + + const prevWidth = text.width; + const prevHeight = text.height; + const prevText = text.text; + + // text is wrapped + UI.resize(text, "e", [-20, 0]); + expect(text.width).not.toEqual(prevWidth); + expect(text.height).not.toEqual(prevHeight); + expect(text.text).not.toEqual(prevText); + expect(text.autoResize).toBe(false); + + const wrappedWidth = text.width; + const wrappedHeight = text.height; + const wrappedText = text.text; + + // edit text + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + const editor = await getTextEditor(textEditorSelector); + expect(editor).not.toBe(null); + expect(h.state.editingElement?.id).toBe(text.id); + expect(h.elements.length).toBe(1); + + const nextText = `${wrappedText} is great!`; + updateTextEditor(editor, nextText); + await new Promise((cb) => setTimeout(cb, 0)); + editor.blur(); + + expect(h.elements[0].width).toEqual(wrappedWidth); + expect(h.elements[0].height).toBeGreaterThan(wrappedHeight); + + // remove all texts and then add it back editing + updateTextEditor(editor, ""); + await new Promise((cb) => setTimeout(cb, 0)); + updateTextEditor(editor, nextText); + await new Promise((cb) => setTimeout(cb, 0)); + editor.blur(); + + expect(h.elements[0].width).toEqual(wrappedWidth); + }); + + it("should restore original text after unwrapping a wrapped text", async () => { + const originalText = "Excalidraw\neditor\nis great!"; + const text = API.createElement({ + type: "text", + text: originalText, + }); + h.elements = [text]; + + // wrap + UI.resize(text, "e", [-40, 0]); + // enter text editing mode + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + const editor = await getTextEditor(textEditorSelector); + editor.blur(); + // restore after unwrapping + UI.resize(text, "e", [40, 0]); + expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText); + + // wrap again and add a new line + UI.resize(text, "e", [-30, 0]); + const wrappedText = text.text; + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + updateTextEditor(editor, `${wrappedText}\nA new line!`); + await new Promise((cb) => setTimeout(cb, 0)); + editor.blur(); + // remove the newly added line + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + updateTextEditor(editor, wrappedText); + await new Promise((cb) => setTimeout(cb, 0)); + editor.blur(); + // unwrap + UI.resize(text, "e", [30, 0]); + // expect the text to be restored the same + expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText); + }); + }); + describe("Test container-unbound text", () => { const { h } = window; const dimensions = { height: 400, width: 800 }; @@ -800,26 +911,15 @@ describe("textWysiwyg", () => { mouse.down(); const text = h.elements[1] as ExcalidrawTextElementWithContainer; - let editor = await getTextEditor(textEditorSelector, true); + const editor = await getTextEditor(textEditorSelector, true); await new Promise((r) => setTimeout(r, 0)); updateTextEditor(editor, "Hello World!"); editor.blur(); expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil); - UI.clickTool("text"); - mouse.clickAt( - rectangle.x + rectangle.width / 2, - rectangle.y + rectangle.height / 2, - ); - mouse.down(); - editor = await getTextEditor(textEditorSelector, true); - - editor.select(); fireEvent.click(screen.getByTitle(/code/i)); - await new Promise((r) => setTimeout(r, 0)); - editor.blur(); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, ).toEqual(FONT_FAMILY.Cascadia); diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index 15cedc001..b0cb497d9 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -79,12 +79,14 @@ export const textWysiwyg = ({ app, }: { id: ExcalidrawElement["id"]; - onChange?: (text: string) => void; - onSubmit: (data: { - text: string; - viaKeyboard: boolean; - originalText: string; - }) => void; + /** + * textWysiwyg only deals with `originalText` + * + * Note: `text`, which can be wrapped and therefore different from `originalText`, + * is derived from `originalText` + */ + onChange?: (nextOriginalText: string) => void; + onSubmit: (data: { viaKeyboard: boolean; nextOriginalText: string }) => void; getViewportCoords: (x: number, y: number) => [number, number]; element: ExcalidrawTextElement; canvas: HTMLCanvasElement; @@ -129,11 +131,8 @@ export const textWysiwyg = ({ app.scene.getNonDeletedElementsMap(), ); let maxWidth = updatedTextElement.width; - let maxHeight = updatedTextElement.height; let textElementWidth = updatedTextElement.width; - // Set to element height by default since that's - // what is going to be used for unbounded text const textElementHeight = updatedTextElement.height; if (container && updatedTextElement.containerId) { @@ -262,6 +261,7 @@ export const textWysiwyg = ({ if (isTestEnv()) { editable.style.fontFamily = getFontFamilyString(updatedTextElement); } + mutateElement(updatedTextElement, { x: coordX, y: coordY }); } }; @@ -278,7 +278,7 @@ export const textWysiwyg = ({ let whiteSpace = "pre"; let wordBreak = "normal"; - if (isBoundToContainer(element)) { + if (isBoundToContainer(element) || !element.autoResize) { whiteSpace = "pre-wrap"; wordBreak = "break-word"; } @@ -501,14 +501,12 @@ export const textWysiwyg = ({ if (!updateElement) { return; } - let text = editable.value; const container = getContainerElement( updateElement, app.scene.getNonDeletedElementsMap(), ); if (container) { - text = updateElement.text; if (editable.value.trim()) { const boundTextElementId = getBoundTextElementId(container); if (!boundTextElementId || boundTextElementId !== element.id) { @@ -540,9 +538,8 @@ export const textWysiwyg = ({ } onSubmit({ - text, viaKeyboard: submittedViaKeyboard, - originalText: editable.value, + nextOriginalText: editable.value, }); }; diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts index a72dcf78a..0b642b274 100644 --- a/packages/excalidraw/element/transformHandles.ts +++ b/packages/excalidraw/element/transformHandles.ts @@ -9,7 +9,6 @@ import type { Bounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds"; import { rotate } from "../math"; import type { Device, InteractiveCanvasAppState, Zoom } from "../types"; -import { isTextElement } from "."; import { isFrameLikeElement, isLinearElement } from "./typeChecks"; import { DEFAULT_TRANSFORM_HANDLE_SPACING, @@ -65,13 +64,6 @@ export const OMIT_SIDES_FOR_FRAME = { rotation: true, }; -const OMIT_SIDES_FOR_TEXT_ELEMENT = { - e: true, - s: true, - n: true, - w: true, -}; - const OMIT_SIDES_FOR_LINE_SLASH = { e: true, s: true, @@ -290,8 +282,6 @@ export const getTransformHandles = ( omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH; } } - } else if (isTextElement(element)) { - omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT; } else if (isFrameLikeElement(element)) { omitSides = { ...omitSides, diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 6b7ac57b4..700b7ed6c 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -193,6 +193,13 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase & verticalAlign: VerticalAlign; containerId: ExcalidrawGenericElement["id"] | null; originalText: string; + /** + * If `true` the width will fit the text. If `false`, the text will + * wrap to fit the width. + * + * @default true + */ + autoResize: boolean; /** * Unitless line height (aligned to W3C). To get line height in px, multiply * with font size (using `getLineHeightInPx` helper). diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index eac6a3f66..a5745420f 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -149,7 +149,8 @@ "zoomToFitViewport": "Zoom to fit in viewport", "zoomToFitSelection": "Zoom to fit selection", "zoomToFit": "Zoom to fit all elements", - "installPWA": "Install Excalidraw locally (PWA)" + "installPWA": "Install Excalidraw locally (PWA)", + "autoResize": "Enable text auto-resizing" }, "library": { "noItems": "No items added yet...", diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 7f860ae6f..f032e9672 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -12,6 +12,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "collaborators": Map {}, "contextMenu": { "items": [ + "separator", { "icon":
{children}
diff --git a/packages/excalidraw/components/ContextMenu.tsx b/packages/excalidraw/components/ContextMenu.tsx index 7353c56c6..517e5faed 100644 --- a/packages/excalidraw/components/ContextMenu.tsx +++ b/packages/excalidraw/components/ContextMenu.tsx @@ -105,6 +105,7 @@ export const ContextMenu = React.memo( }} > diff --git a/packages/excalidraw/components/FollowMode/FollowMode.tsx b/packages/excalidraw/components/FollowMode/FollowMode.tsx index 302f9d73e..89581bfa4 100644 --- a/packages/excalidraw/components/FollowMode/FollowMode.tsx +++ b/packages/excalidraw/components/FollowMode/FollowMode.tsx @@ -27,7 +27,11 @@ const FollowMode = ({ {userToFollow.username} - diff --git a/packages/excalidraw/components/IconPicker.tsx b/packages/excalidraw/components/IconPicker.tsx index 3295c4a04..4d6e95af5 100644 --- a/packages/excalidraw/components/IconPicker.tsx +++ b/packages/excalidraw/components/IconPicker.tsx @@ -108,6 +108,7 @@ function Picker({
{options.map((option, i) => (
@@ -542,17 +556,6 @@ const LayerUI = ({ showExitZenModeBtn={showExitZenModeBtn} renderWelcomeScreen={renderWelcomeScreen} /> - {appState.showStats && ( - { - actionManager.executeAction(actionToggleStats); - }} - renderCustomStats={renderCustomStats} - /> - )} {appState.scrolledOutside && ( + ); + }, +); diff --git a/packages/excalidraw/components/ButtonIconSelect.tsx b/packages/excalidraw/components/ButtonIconSelect.tsx index 6933f0304..c3a390257 100644 --- a/packages/excalidraw/components/ButtonIconSelect.tsx +++ b/packages/excalidraw/components/ButtonIconSelect.tsx @@ -1,4 +1,5 @@ import clsx from "clsx"; +import { ButtonIcon } from "./ButtonIcon"; // TODO: It might be "clever" to add option.icon to the existing component export const ButtonIconSelect = ( @@ -24,21 +25,17 @@ export const ButtonIconSelect = ( } ), ) => ( -
+
{props.options.map((option) => props.type === "button" ? ( - + testId={option.testId} + active={option.active ?? props.value === option.value} + onClick={(event) => props.onClick(option.value, event)} + /> ) : (