From 89a9badc27d30947882be7f2d6230d95141dd18b Mon Sep 17 00:00:00 2001 From: Christopher Tangonan <161169629+cTangonan123@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:33:52 -0800 Subject: [PATCH 01/62] fix: update arrowhead property defaultValue handling (#10778) --- packages/excalidraw/actions/actionProperties.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 2d6b040a34..d15f9de166 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1683,7 +1683,8 @@ export const actionChangeArrowhead = register<{ ? element.startArrowhead : appState.currentItemStartArrowhead, true, - appState.currentItemStartArrowhead, + (hasSelection) => + hasSelection ? null : appState.currentItemStartArrowhead, )} onChange={(value) => updateData({ position: "start", type: value })} numberOfOptionsToAlwaysShow={4} @@ -1700,7 +1701,8 @@ export const actionChangeArrowhead = register<{ ? element.endArrowhead : appState.currentItemEndArrowhead, true, - appState.currentItemEndArrowhead, + (hasSelection) => + hasSelection ? null : appState.currentItemEndArrowhead, )} onChange={(value) => updateData({ position: "end", type: value })} numberOfOptionsToAlwaysShow={4} From 46ddd60948b82f804c36b7c6325b6144187185cf Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:19:11 +0100 Subject: [PATCH 02/62] feat(editor): support embedding google drive videos (#10788) --- packages/element/src/embeddable.ts | 81 +++++++++++++++++++++- packages/element/tests/embeddable.test.ts | 82 ++++++++++++++++++++++- 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/packages/element/src/embeddable.ts b/packages/element/src/embeddable.ts index 71c75cc23a..917ca0a7af 100644 --- a/packages/element/src/embeddable.ts +++ b/packages/element/src/embeddable.ts @@ -56,7 +56,7 @@ const RE_REDDIT = const RE_REDDIT_EMBED = /^ { +const parseYouTubeLikeTimestamp = (url: string): number => { let timeParam: string | null | undefined; try { @@ -85,11 +85,57 @@ const parseYouTubeTimestamp = (url: string): number => { return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds); }; +const parseGoogleDriveVideoLink = ( + url: string, +): { fileId: string; resourceKey?: string; timestamp?: number } | null => { + try { + const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`); + const hostname = urlObj.hostname.replace(/^www\./, ""); + if (hostname !== "drive.google.com") { + return null; + } + + let fileId: string | null = null; + const pathMatch = urlObj.pathname.match(/^\/file\/d\/([^/]+)(?:\/|$)/); + if (pathMatch?.[1]) { + fileId = pathMatch[1]; + } else if (urlObj.pathname === "/open" || urlObj.pathname === "/uc") { + // Shared Drive links can be emitted as: + // - /open?id= (common "open in Drive" format) + // - /uc?...&id= (download/export endpoint often seen in copied links) + fileId = urlObj.searchParams.get("id"); + } + + if (!fileId || !/^[a-zA-Z0-9_-]+$/.test(fileId)) { + return null; + } + + // Some Drive share links include `resourcekey` for access to link-shared + // files; preserve it in the preview URL so embeds keep working. + const resourceKey = urlObj.searchParams.get("resourcekey"); + const timestamp = parseYouTubeLikeTimestamp(urlObj.toString()); + + return { + fileId, + resourceKey: + resourceKey && /^[a-zA-Z0-9_-]+$/.test(resourceKey) + ? resourceKey + : undefined, + // Drive accepts YouTube-like `t` formats (e.g. `t=90`, `t=1m30s`); + // normalize to seconds for a stable preview URL. + timestamp: timestamp > 0 ? timestamp : undefined, + }; + } catch (error) { + return null; + } +}; + const ALLOWED_DOMAINS = new Set([ "youtube.com", "youtu.be", "vimeo.com", "player.vimeo.com", + "drive.google.com", "figma.com", "link.excalidraw.com", "gist.github.com", @@ -108,6 +154,7 @@ const ALLOW_SAME_ORIGIN = new Set([ "youtu.be", "vimeo.com", "player.vimeo.com", + "drive.google.com", "figma.com", "twitter.com", "x.com", @@ -142,7 +189,7 @@ export const getEmbedLink = ( let aspectRatio = { w: 560, h: 840 }; const ytLink = link.match(RE_YOUTUBE); if (ytLink?.[2]) { - const startTime = parseYouTubeTimestamp(originalLink); + const startTime = parseYouTubeLikeTimestamp(originalLink); const time = startTime > 0 ? `&start=${startTime}` : ``; const isPortrait = link.includes("shorts"); type = "video"; @@ -201,6 +248,36 @@ export const getEmbedLink = ( }; } + const googleDriveVideo = parseGoogleDriveVideoLink(link); + if (googleDriveVideo) { + type = "video"; + const searchParams = new URLSearchParams(); + if (googleDriveVideo.resourceKey) { + searchParams.set("resourcekey", googleDriveVideo.resourceKey); + } + if (googleDriveVideo.timestamp) { + searchParams.set("t", `${googleDriveVideo.timestamp}`); + } + + const search = searchParams.toString(); + link = `https://drive.google.com/file/d/${googleDriveVideo.fileId}/preview${ + search ? `?${search}` : "" + }`; + aspectRatio = { w: 560, h: 315 }; + embeddedLinkCache.set(originalLink, { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }); + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; + } + const figmaLink = link.match(RE_FIGMA); if (figmaLink) { type = "generic"; diff --git a/packages/element/tests/embeddable.test.ts b/packages/element/tests/embeddable.test.ts index 7f585e866f..35870a86f2 100644 --- a/packages/element/tests/embeddable.test.ts +++ b/packages/element/tests/embeddable.test.ts @@ -1,4 +1,4 @@ -import { getEmbedLink } from "../src/embeddable"; +import { embeddableURLValidator, getEmbedLink } from "../src/embeddable"; describe("YouTube timestamp parsing", () => { it("should parse YouTube URLs with timestamp in seconds", () => { @@ -151,3 +151,83 @@ describe("YouTube timestamp parsing", () => { } }); }); + +describe("Google Drive video embedding", () => { + it.each([ + { + url: "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?usp=sharing", + expectedLink: + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview", + }, + { + url: "https://drive.google.com/open?id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456", + expectedLink: + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview", + }, + { + url: "https://drive.google.com/uc?export=download&id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456", + expectedLink: + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview", + }, + ])("should normalize Google Drive link: $url", ({ url, expectedLink }) => { + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toBe(expectedLink); + } + expect(result?.intrinsicSize).toEqual({ w: 560, h: 315 }); + }); + + it("should preserve resourcekey when available", () => { + const url = + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toBe( + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456", + ); + } + }); + + it("should preserve timestamp when available", () => { + const url = + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?t=9"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toBe( + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?t=9", + ); + } + }); + + it("should preserve resourcekey and timestamp together", () => { + const url = + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456&t=9"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toBe( + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456&t=9", + ); + } + }); + + it("should validate Google Drive domain by default", () => { + expect( + embeddableURLValidator( + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view", + undefined, + ), + ).toBe(true); + }); +}); From ffcb67b21f3693821b0202bf54707bcee21a8ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Mon, 16 Feb 2026 22:25:28 +0100 Subject: [PATCH 03/62] fix: Inside-inside bound arrow endpoint drag trigger focus point editor (#10771) fix: Inside-inside binding arrow endpoint drag trigger focus point editor Signed-off-by: Mark Tolmacs --- packages/element/src/arrows/focus.ts | 30 ++++++++++++++----- .../excalidraw/renderer/interactiveScene.ts | 1 + 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/element/src/arrows/focus.ts b/packages/element/src/arrows/focus.ts index ae02793f72..e0abd75f66 100644 --- a/packages/element/src/arrows/focus.ts +++ b/packages/element/src/arrows/focus.ts @@ -42,6 +42,7 @@ export const isFocusPointVisible = ( isBindingEnabled: AppState["isBindingEnabled"]; zoom: AppState["zoom"]; }, + startOrEnd: "start" | "end", ignoreOverlap = false, ): boolean => { // No focus point management for elbow arrows, because elbow arrows @@ -76,14 +77,25 @@ export const isFocusPointVisible = ( } } - // Check if the focus point is within the element's shape bounds - return hitElementItself({ - element: bindableElement, + const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + startOrEnd === "end" ? arrow.points.length - 1 : 0, elementsMap, - point: focusPoint, - threshold: getBindingGap(bindableElement, arrow), - overrideShouldTestInside: true, - }); + ); + + // Check if the focus point is within the element's shape bounds + // Endpoint dragging takes precedence + return ( + pointDistance(focusPoint, arrowPoint) >= + (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value && + hitElementItself({ + element: bindableElement, + elementsMap, + point: focusPoint, + threshold: getBindingGap(bindableElement, arrow), + overrideShouldTestInside: true, + }) + ); }; // Updates the arrow endpoints in "orbit" configuration @@ -353,6 +365,7 @@ export const handleFocusPointPointerDown = ( bindableElement, elementsMap, appState, + "start", ) && pointDistance(pointerPos, focusPoint) <= hitThreshold ) { @@ -387,6 +400,7 @@ export const handleFocusPointPointerDown = ( bindableElement, elementsMap, appState, + "end", ) && pointDistance(pointerPos, focusPoint) <= hitThreshold ) { @@ -501,6 +515,7 @@ export const handleFocusPointHover = ( bindableElement, elementsMap, appState, + "start", ) && pointDistance(pointerPos, focusPoint) <= hitThreshold ) { @@ -529,6 +544,7 @@ export const handleFocusPointHover = ( bindableElement, elementsMap, appState, + "end", ) && pointDistance(pointerPos, focusPoint) <= hitThreshold ) { diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 85b4a9a369..fd243eee3b 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -1269,6 +1269,7 @@ const renderFocusPointIndicator = ({ bindableElement, elementsMap, appState, + type, ) ) { return; From c1e00c44f57841b18ea14bde30b8d1f5aff95c7e Mon Sep 17 00:00:00 2001 From: Christopher Tangonan <161169629+cTangonan123@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:57:09 -0800 Subject: [PATCH 04/62] fix: convert ArrowheadNoneIcon to component matching arrowhead icon pattern (#10789) --- .../excalidraw/actions/actionProperties.tsx | 2 +- packages/excalidraw/components/icons.tsx | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index d15f9de166..7fb9ed0e86 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1555,7 +1555,7 @@ const getArrowheadOptions = (flip: boolean) => { value: null, text: t("labels.arrowhead_none"), keyBinding: "q", - icon: ArrowheadNoneIcon, + icon: , }, { value: "arrow", diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 6a7a934083..4a1691863f 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -1287,13 +1287,21 @@ export const EdgeRoundIcon = createIcon( tablerIconProps, ); -export const ArrowheadNoneIcon = createIcon( - - - - - , - tablerIconProps, +export const ArrowheadNoneIcon = React.memo( + ({ flip = false }: { flip?: boolean }) => + createIcon( + + + + + , + tablerIconProps, + ), ); export const ArrowheadArrowIcon = React.memo( From 5852d0d41005e67ca00124079b9eee91e4ab716d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Tue, 17 Feb 2026 14:10:05 +0100 Subject: [PATCH 05/62] fix: Arrow overlap arrow behavior (#10732) * fix(arrow): Overlap arrow behavior Signed-off-by: Mark Tolmacs * fix: Lint Signed-off-by: Mark Tolmacs * feat(editor): reduce binding gap (#10739) * feat(editor): reduce binding gap to 7px * feat(editor): reduce binding gap to 5px * feat(editor): reduce binding gap to 3px * go back to 5px * update tests * feat: Simplified update bind points Signed-off-by: Mark Tolmacs * fix: Remove non-needed export Signed-off-by: Mark Tolmacs * fix. Possessed arrows #1 Signed-off-by: Mark Tolmacs * fix: Focus point projection stabilization Signed-off-by: Mark Tolmacs * fix: Remove arrow stability hack Signed-off-by: Mark Tolmacs * fix: Unbound other endpoint Signed-off-by: Mark Tolmacs * feat(editor): visualize binding midpoints + support for simple arrows (#10611) * feat: Force exact center focus point When the projected point is close to center snap it to the exact center. Signed-off-by: Mark Tolmacs * fix: Tests Signed-off-by: Mark Tolmacs * fix: Snap to center around side mid point. Signed-off-by: Mark Tolmacs * Trigger CI * fix: Midpoint outline focus point Signed-off-by: Mark Tolmacs * fix: Tests Signed-off-by: Mark Tolmacs * fix: Dragging existing arrow reset focus point on outline Signed-off-by: Mark Tolmacs * fix: Tests Signed-off-by: Mark Tolmacs * feat: Midpoint indicator Signed-off-by: Mark Tolmacs * fix: Rotated mid points Signed-off-by: Mark Tolmacs * fix: No hole Signed-off-by: Mark Tolmacs * feat: Cache hits and scene lookups Signed-off-by: Mark Tolmacs * chore: Remove debug Signed-off-by: Mark Tolmacs * fix: Consider hit threshold and inside override too Signed-off-by: Mark Tolmacs * fix: Increase outline midpoint sticky distance Signed-off-by: Mark Tolmacs * fix: Don't show midpoint indicator when no snapping is possible Signed-off-by: Mark Tolmacs * feat: Indicate lock-in Signed-off-by: Mark Tolmacs * chore: Remove Map caching Signed-off-by: Mark Tolmacs * fix: incorrect threshold Signed-off-by: Mark Tolmacs * fix: threshold setting Signed-off-by: Mark Tolmacs * fix: Hit caching Signed-off-by: Mark Tolmacs * fix: Simple arrow mid point selection inconsistency Signed-off-by: Mark Tolmacs * fix: cache override Signed-off-by: Mark Tolmacs * fix: Precise know dragging with midpoint refactor Signed-off-by: Mark Tolmacs * fear: Frame support Signed-off-by: Mark Tolmacs * fix: Crossing arrow won't trigger mid point Signed-off-by: Mark Tolmacs * fix: Arrow creation point highlight Signed-off-by: Mark Tolmacs * fix: Restore types & tests Signed-off-by: Mark Tolmacs * chore: Restore restore.ts Signed-off-by: Mark Tolmacs * fix: restore.ts Signed-off-by: Mark Tolmacs * fix: Elbow arrows reliably highlight center point Signed-off-by: Mark Tolmacs * fix: Highlight point ordering Signed-off-by: Mark Tolmacs * feat: Bind with focus point across shape Signed-off-by: Mark Tolmacs * fix: Lint * fix: Midpoint and binding alignment Signed-off-by: Mark Tolmacs * chore: Indicator color Signed-off-by: Mark Tolmacs * chore: More knob tuning Signed-off-by: Mark Tolmacs * fix: Radius Signed-off-by: Mark Tolmacs * fix: Tests Signed-off-by: Mark Tolmacs * simplify point indicators --------- Signed-off-by: Mark Tolmacs Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> * fix: Tests Signed-off-by: Mark Tolmacs * fix: Snapshots Signed-off-by: Mark Tolmacs * fix: Target point selection Signed-off-by: Mark Tolmacs * chore: Remove non-needed change Signed-off-by: Mark Tolmacs * chore: Try again removing non-needed modification Signed-off-by: Mark Tolmacs * fix: Inside-inside binding arrow endpoint drag trigger focus point editor Signed-off-by: Mark Tolmacs * fix: Area based edge case Signed-off-by: Mark Tolmacs * fix: Overlapping new arrow jump Signed-off-by: Mark Tolmacs --------- Signed-off-by: Mark Tolmacs Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com> --- packages/element/src/align.ts | 1 - packages/element/src/arrows/focus.ts | 1 + packages/element/src/binding.ts | 314 +++++++++--------- packages/element/src/linearElementEditor.ts | 20 +- packages/element/src/utils.ts | 31 +- packages/excalidraw/components/App.tsx | 4 +- .../tests/__snapshots__/history.test.tsx.snap | 236 ++++++------- .../tests/__snapshots__/move.test.tsx.snap | 10 +- 8 files changed, 322 insertions(+), 295 deletions(-) diff --git a/packages/element/src/align.ts b/packages/element/src/align.ts index 17ebc3a97c..3068aee8d1 100644 --- a/packages/element/src/align.ts +++ b/packages/element/src/align.ts @@ -43,7 +43,6 @@ export const alignElements = ( // update bound elements updateBoundElements(element, scene, { simultaneouslyUpdated: group, - indirectArrowUpdate: true, }); return updatedEle; }); diff --git a/packages/element/src/arrows/focus.ts b/packages/element/src/arrows/focus.ts index e0abd75f66..fa8018adbe 100644 --- a/packages/element/src/arrows/focus.ts +++ b/packages/element/src/arrows/focus.ts @@ -141,6 +141,7 @@ const focusPointUpdate = ( currentBinding, bindableElement, elementsMap, + true, ); if (newPoint) { diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index e7bc98c04f..86041b4aba 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -27,11 +27,7 @@ import type { AppState } from "@excalidraw/excalidraw/types"; import type { MapEntry, Mutable } from "@excalidraw/common/utility-types"; import type { Bounds } from "@excalidraw/common"; -import { - doBoundsIntersect, - getCenterForBounds, - getElementBounds, -} from "./bounds"; +import { getCenterForBounds } from "./bounds"; import { getAllHoveredElementAtPoint, getHoveredElementForBinding, @@ -116,6 +112,7 @@ export type BindingStrategy = */ export const BASE_BINDING_GAP = 5; export const BASE_BINDING_GAP_ELBOW = 5; +export const BASE_ARROW_MIN_LENGTH = 10; export const FOCUS_POINT_SIZE = 10 / 1.5; export const getBindingGap = ( @@ -810,13 +807,23 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( startDragged ? -1 : 0, elementsMap, ); - + const pointIsCloseToOtherElement = + otherFocusPoint && + otherBindableElement && + hitElementItself({ + point: globalPoint, + element: otherBindableElement, + elementsMap, + threshold: maxBindingDistance_simple(appState.zoom), + overrideShouldTestInside: true, + }); const otherNeverOverride = opts?.newArrow ? appState.selectedLinearElement?.initialState.arrowStartIsInside : otherBinding?.mode === "inside"; const other: BindingStrategy = !otherNeverOverride ? otherBindableElement && !otherFocusPointIsInElement && + !pointIsCloseToOtherElement && appState.selectedLinearElement?.initialState.altFocusPoint ? { mode: "orbit", @@ -1083,7 +1090,6 @@ export const updateBoundElements = ( options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; changedElements?: Map; - indirectArrowUpdate?: boolean; }, ) => { if (!isBindableElement(changedElement)) { @@ -1178,11 +1184,6 @@ export const updateBoundElements = ( }; boundElementsVisitor(elementsMap, changedElement, visitor); - - if (options?.indirectArrowUpdate) { - boundElementsVisitor(elementsMap, changedElement, visitor); - boundElementsVisitor(elementsMap, changedElement, visitor); - } }; const updateArrowBindings = ( @@ -1692,10 +1693,41 @@ export const snapToMid = ( return undefined; }; -const compareElementArea = ( - a: ExcalidrawBindableElement, - b: ExcalidrawBindableElement, -) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2); +const extractBinding = ( + arrow: ExcalidrawArrowElement, + startOrEnd: "startBinding" | "endBinding", + elementsMap: ElementsMap, +) => { + const binding = arrow[startOrEnd]; + if (!binding) { + return { + element: null, + fixedPoint: null, + focusPoint: null, + binding, + mode: null, + }; + } + + const element = elementsMap.get( + binding.elementId, + ) as ExcalidrawBindableElement; + + return { + element, + fixedPoint: binding.fixedPoint, + focusPoint: getGlobalFixedPointForBindableElement( + normalizeFixedPoint(binding.fixedPoint), + element, + elementsMap, + ), + binding, + mode: binding.mode, + }; +}; + +const elementArea = (element: ExcalidrawBindableElement) => + element.width * element.height; export const updateBoundPoint = ( arrow: NonDeleted, @@ -1703,9 +1735,7 @@ export const updateBoundPoint = ( binding: FixedPointBinding | null | undefined, bindableElement: ExcalidrawBindableElement, elementsMap: ElementsMap, - opts?: { - customIntersector?: LineSegment; - }, + dragging?: boolean, ): LocalPoint | null => { if ( binding == null || @@ -1720,150 +1750,136 @@ export const updateBoundPoint = ( return null; } - const global = getGlobalFixedPointForBindableElement( + const focusPoint = getGlobalFixedPointForBindableElement( normalizeFixedPoint(binding.fixedPoint), bindableElement, elementsMap, ); - const pointIndex = - startOrEnd === "startBinding" ? 0 : arrow.points.length - 1; - const elbowed = isElbowArrow(arrow); - const otherBinding = - startOrEnd === "startBinding" ? arrow.endBinding : arrow.startBinding; - const otherBindableElement = - otherBinding && - (elementsMap.get(otherBinding.elementId)! as ExcalidrawBindableElement); - const bounds = getElementBounds(bindableElement, elementsMap); - const otherBounds = - otherBindableElement && getElementBounds(otherBindableElement, elementsMap); - const isLargerThanOther = - otherBindableElement && - compareElementArea(bindableElement, otherBindableElement) < - // if both shapes the same size, pretend the other is larger - (startOrEnd === "endBinding" ? 1 : 0); - const isOverlapping = otherBounds && doBoundsIntersect(bounds, otherBounds); - // GOAL: If the arrow becomes too short, we want to jump the arrow endpoints - // to the exact focus points on the elements. - // INTUITION: We're not interested in the exacts length of the arrow (which - // will change if we change where we route it), we want to know the length of - // the part which lies outside of both shapes and consider that as a trigger - // to change where we point the arrow. Avoids jumping the arrow in and out - // at every frame. - let arrowTooShort = false; - if ( - !isOverlapping && - !elbowed && - arrow.startBinding && - arrow.endBinding && - otherBindableElement && - arrow.points.length === 2 - ) { - const startFocusPoint = getGlobalFixedPointForBindableElement( - arrow.startBinding.fixedPoint, - startOrEnd === "startBinding" ? bindableElement : otherBindableElement, - elementsMap, - ); - const endFocusPoint = getGlobalFixedPointForBindableElement( - arrow.endBinding.fixedPoint, - startOrEnd === "endBinding" ? bindableElement : otherBindableElement, - elementsMap, - ); - const segment = lineSegment(startFocusPoint, endFocusPoint); - const startIntersection = intersectElementWithLineSegment( - startOrEnd === "endBinding" ? bindableElement : otherBindableElement, - elementsMap, - segment, - 0, - true, - ); - const endIntersection = intersectElementWithLineSegment( - startOrEnd === "startBinding" ? bindableElement : otherBindableElement, - elementsMap, - segment, - 0, - true, - ); - if (startIntersection.length > 0 && endIntersection.length > 0) { - const len = pointDistance(startIntersection[0], endIntersection[0]); - arrowTooShort = len < 40; - } - } - - const isNested = (arrowTooShort || isOverlapping) && isLargerThanOther; - - let _customIntersector = opts?.customIntersector; - if (!elbowed && !_customIntersector) { - const [x1, y1, x2, y2] = LinearElementEditor.getElementAbsoluteCoords( + // 0. Short-circuit for inside binding as it doesn't require any + // calculations and is not affected by other bindings + if (binding.mode === "inside") { + return LinearElementEditor.createPointAt( arrow, elementsMap, - ); - const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2); - const edgePoint = global; - const adjacentPoint = pointRotateRads( - pointFrom( - arrow.x + - arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][0], - arrow.y + - arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][1], - ), - center, - arrow.angle as Radians, - ); - const bindingGap = getBindingGap(bindableElement, arrow); - const halfVector = vectorScale( - vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)), - pointDistance(edgePoint, adjacentPoint) + - Math.max(bindableElement.width, bindableElement.height) + - bindingGap * 2, - ); - _customIntersector = lineSegment( - pointFromVector(halfVector, adjacentPoint), - pointFromVector(vectorScale(halfVector, -1), adjacentPoint), + focusPoint[0], + focusPoint[1], + null, ); } - const maybeOutlineGlobal = - binding.mode === "orbit" && bindableElement - ? isNested - ? global - : bindPointToSnapToElementOutline( - { - ...arrow, - points: [ - pointIndex === 0 - ? LinearElementEditor.createPointAt( - arrow, - elementsMap, - global[0], - global[1], - null, - ) - : arrow.points[0], - ...arrow.points.slice(1, -1), - pointIndex === arrow.points.length - 1 - ? LinearElementEditor.createPointAt( - arrow, - elementsMap, - global[0], - global[1], - null, - ) - : arrow.points[arrow.points.length - 1], - ], - }, - bindableElement, - pointIndex === 0 ? "start" : "end", - elementsMap, - _customIntersector, - ) - : global; + const { element: otherBindable, focusPoint: otherFocusPoint } = + extractBinding( + arrow, + startOrEnd === "startBinding" ? "endBinding" : "startBinding", + elementsMap, + ); + const otherArrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + startOrEnd === "startBinding" ? -1 : 0, + elementsMap, + ); + const otherFocusPointOrArrowPoint = otherFocusPoint || otherArrowPoint; + const intersector = + otherFocusPointOrArrowPoint && + lineSegment(focusPoint, otherFocusPointOrArrowPoint); + const otherOutlinePoint = + otherBindable && + intersector && + intersectElementWithLineSegment( + otherBindable, + elementsMap, + intersector, + getBindingGap(otherBindable, arrow), + ).sort( + (a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint), + )[0]; + const outlinePoint = + intersector && + intersectElementWithLineSegment( + bindableElement, + elementsMap, + intersector, + getBindingGap(bindableElement, arrow), + ).sort( + (a, b) => + pointDistanceSq(a, otherFocusPointOrArrowPoint) - + pointDistanceSq(b, otherFocusPointOrArrowPoint), + )[0]; + const startHasArrowhead = arrow.startArrowhead !== null; + const endHasArrowhead = arrow.endArrowhead !== null; + const resolvedTarget = + (!startHasArrowhead && !endHasArrowhead) || + (startOrEnd === "startBinding" && startHasArrowhead) || + (startOrEnd === "endBinding" && endHasArrowhead) + ? focusPoint + : outlinePoint || focusPoint; + // 1. Handle case when the outline point (or focus point) is inside + // the other shape by short-circuiting to the focus point, otherwise + // the arrow would invert + if ( + otherBindable && + outlinePoint && + !dragging && + // Arbitrary threshold to handle wireframing use cases + elementArea(otherBindable) < elementArea(bindableElement) * 2 && + hitElementItself({ + element: otherBindable, + point: outlinePoint, + elementsMap, + threshold: getBindingGap(otherBindable, arrow), + overrideShouldTestInside: true, + }) + ) { + return LinearElementEditor.createPointAt( + arrow, + elementsMap, + resolvedTarget[0], + resolvedTarget[1], + null, + ); + } + + const otherTargetPoint = otherBindable + ? otherOutlinePoint || otherFocusPoint || otherArrowPoint + : otherArrowPoint; + const arrowTooShort = + pointDistance(otherTargetPoint, outlinePoint || focusPoint) <= + BASE_ARROW_MIN_LENGTH; + + // 2. If the arrow is unconnected at the other end, just check arrow size + // and short-circuit to the focus point if the arrow is too short to + // avoid inversion + if (!otherBindable) { + return LinearElementEditor.createPointAt( + arrow, + elementsMap, + arrowTooShort ? focusPoint[0] : outlinePoint?.[0] ?? focusPoint[0], + arrowTooShort ? focusPoint[1] : outlinePoint?.[1] ?? focusPoint[1], + null, + ); + } + + // 3. If the arrow is too short while connected on both ends and + // the other arrow endpoint will not be inside the bindable, just + // check the arrow size and make a decision based on that + if (arrowTooShort) { + return LinearElementEditor.createPointAt( + arrow, + elementsMap, + resolvedTarget?.[0] || focusPoint[0], + resolvedTarget?.[1] || focusPoint[1], + null, + ); + } + + // 4. In the general case, snap to the outline if possible return LinearElementEditor.createPointAt( arrow, elementsMap, - maybeOutlineGlobal[0], - maybeOutlineGlobal[1], + outlinePoint?.[0] || focusPoint[0], + outlinePoint?.[1] || focusPoint[1], null, ); }; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 1664a578e2..e3d3f2e510 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -9,7 +9,6 @@ import { vectorFromPoint, curveLength, curvePointAtLength, - lineSegment, } from "@excalidraw/math"; import { getCurvePathOps } from "@excalidraw/utils/shape"; @@ -2339,19 +2338,6 @@ const pointDraggingUpdates = ( : updates.endBinding, }; - // We need to use a custom intersector to ensure that if there is a big "jump" - // in the arrow's position, we can position it with outline avoidance - // pixel-perfectly and avoid "dancing" arrows. - // NOTE: Direction matters here, so we create two intersectors - const startCustomIntersector = - start.focusPoint && end.focusPoint - ? lineSegment(start.focusPoint, end.focusPoint) - : undefined; - const endCustomIntersector = - start.focusPoint && end.focusPoint - ? lineSegment(end.focusPoint, start.focusPoint) - : undefined; - // Needed to handle a special case where an existing arrow is dragged over // the same element it is bound to on the other side const startIsDraggingOverEndElement = @@ -2387,9 +2373,7 @@ const pointDraggingUpdates = ( nextArrow.endBinding, endBindable, elementsMap, - { - customIntersector: endCustomIntersector, - }, + endIsDragged, ) || nextArrow.points[nextArrow.points.length - 1] : nextArrow.points[nextArrow.points.length - 1]; @@ -2420,7 +2404,7 @@ const pointDraggingUpdates = ( nextArrow.startBinding, startBindable, elementsMap, - { customIntersector: startCustomIntersector }, + startIsDragged, ) || nextArrow.points[0] : nextArrow.points[0]; diff --git a/packages/element/src/utils.ts b/packages/element/src/utils.ts index ee341b310a..7013acf2f1 100644 --- a/packages/element/src/utils.ts +++ b/packages/element/src/utils.ts @@ -43,6 +43,11 @@ import { LinearElementEditor } from "./linearElementEditor"; import { isRectangularElement } from "./typeChecks"; import { maxBindingDistance_simple } from "./binding"; +import { + getGlobalFixedPointForBindableElement, + normalizeFixedPoint, +} from "./binding"; + import type { ElementsMap, ExcalidrawArrowElement, @@ -677,11 +682,35 @@ export const projectFixedPointOntoDiagonal = ( elementsMap, ); - const a = LinearElementEditor.getPointAtIndexGlobalCoordinates( + // To avoid working with stale arrow state, we use the opposite focus point + // of the current endpoint, which will always be unchanged during moving of + // the endpoint. This is only needed when the arrow has only two points. + let a = LinearElementEditor.getPointAtIndexGlobalCoordinates( arrow, startOrEnd === "start" ? 1 : arrow.points.length - 2, elementsMap, ); + if (arrow.points.length === 2) { + const otherBinding = + startOrEnd === "start" ? arrow.endBinding : arrow.startBinding; + const otherBindable = + otherBinding && + (elementsMap.get(otherBinding.elementId) as + | ExcalidrawBindableElement + | undefined); + const otherFocusPoint = + otherBinding && + otherBindable && + getGlobalFixedPointForBindableElement( + normalizeFixedPoint(otherBinding.fixedPoint), + otherBindable, + elementsMap, + ); + if (otherFocusPoint) { + a = otherFocusPoint; + } + } + const b = pointFromVector( vectorScale( vectorFromPoint(point, a), diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index fc8a400055..32bac77e1e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -9623,9 +9623,7 @@ class App extends React.Component { isBindableElement(element) && element.boundElements?.some((other) => other.type === "arrow") ) { - updateBoundElements(element, this.scene, { - indirectArrowUpdate: true, - }); + updateBoundElements(element, this.scene); } }); diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 1dd0f92cea..c8c427e6ae 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -227,7 +227,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 33, + "version": 29, "width": "94.00000", "x": 0, "y": 0, @@ -334,15 +334,15 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "height": "105.58873", + "height": "105.58874", "points": [ [ 0, 0, ], [ - 88, - "105.58873", + "88.00000", + "105.58874", ], ], "startBinding": { @@ -353,8 +353,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 32, - "width": 88, + "version": 28, + "width": "88.00000", }, "inserted": { "endBinding": { @@ -365,7 +365,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "height": "0.00000", + "height": 0, "points": [ [ 0, @@ -373,7 +373,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], [ "88.00000", - "0.00000", + 0, ], ], "startBinding": { @@ -384,7 +384,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 29, + "version": 25, "width": "88.00000", }, }, @@ -440,21 +440,21 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], ], "startBinding": null, - "version": 33, + "version": 29, "width": "94.00000", "x": 0, "y": 0, }, "inserted": { - "height": "105.58873", + "height": "105.58874", "points": [ [ 0, 0, ], [ - 88, - "105.58873", + "88.00000", + "105.58874", ], ], "startBinding": { @@ -465,10 +465,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 32, - "width": 88, + "version": 28, + "width": "88.00000", "x": 6, - "y": "7.09000", + "y": "7.20923", }, }, }, @@ -854,7 +854,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 30, + "version": 25, "width": 100, "x": 150, "y": 0, @@ -904,15 +904,15 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "id4": { "deleted": { "endBinding": null, - "height": "0.00293", + "height": "0.01000", "points": [ [ 0, 0, ], [ - "-44.00000", - "-0.00293", + -44, + "-0.01000", ], ], "startBinding": { @@ -923,9 +923,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 29, - "width": "44.00000", - "y": "0.00293", + "version": 24, + "width": 44, + "y": "0.01000", }, "inserted": { "endBinding": { @@ -943,7 +943,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "6.00000", + -100, 0, ], ], @@ -955,8 +955,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 27, - "width": "6.00000", + "version": 23, + "width": 100, "y": "0.01000", }, }, @@ -1004,21 +1004,21 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], ], "startBinding": null, - "version": 30, + "version": 25, "width": 100, "x": 150, "y": 0, }, "inserted": { - "height": "0.00293", + "height": "0.01000", "points": [ [ 0, 0, ], [ - "-44.00000", - "-0.00293", + -44, + "-0.01000", ], ], "startBinding": { @@ -1029,10 +1029,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 29, - "width": "44.00000", - "x": 144, - "y": "0.00293", + "version": 24, + "width": 44, + "x": 250, + "y": "0.01000", }, }, }, @@ -1335,7 +1335,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "29.36414", + "height": "29.32551", "id": "id4", "index": "Zz", "isDeleted": false, @@ -1350,7 +1350,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], [ 88, - "29.36414", + "29.32551", ], ], "roughness": 1, @@ -1369,10 +1369,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 10, + "version": 8, "width": 88, "x": 6, - "y": "2.00946", + "y": "2.00947", } `; @@ -1546,7 +1546,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], "mode": "orbit", }, - "version": 10, + "version": 8, }, "inserted": { "endBinding": null, @@ -1698,7 +1698,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "14.91372", + "height": "17.59718", "id": "id5", "index": "a0", "isDeleted": false, @@ -1712,8 +1712,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "88.00000", - "-14.91372", + 88, + "-17.59718", ], ], "roughness": 1, @@ -1732,10 +1732,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 11, - "width": "88.00000", + "version": 8, + "width": 88, "x": 6, - "y": "37.05219", + "y": "38.80379", } `; @@ -1846,7 +1846,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "14.91372", + "height": "17.59718", "index": "a0", "isDeleted": false, "link": null, @@ -1858,8 +1858,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "88.00000", - "-14.91372", + 88, + "-17.59718", ], ], "roughness": 1, @@ -1877,14 +1877,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 11, - "width": "88.00000", + "version": 8, + "width": 88, "x": 6, - "y": "37.05219", + "y": "38.80379", }, "inserted": { "isDeleted": true, - "version": 8, + "version": 7, }, }, }, @@ -2398,7 +2398,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "439.13521", + "height": "439.20000", "id": "id4", "index": "a2", "isDeleted": false, @@ -2413,7 +2413,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], [ 488, - "-439.13521", + "-439.20000", ], ], "roughness": 1, @@ -2434,10 +2434,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 13, + "version": 12, "width": 488, "x": 6, - "y": "-5.38920", + "y": "-5.39000", } `; @@ -2566,7 +2566,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "439.13521", + "height": "439.20000", "index": "a2", "isDeleted": false, "link": null, @@ -2579,7 +2579,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl ], [ 488, - "-439.13521", + "-439.20000", ], ], "roughness": 1, @@ -2599,14 +2599,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 13, + "version": 12, "width": 488, "x": 6, - "y": "-5.38920", + "y": "-5.39000", }, "inserted": { "isDeleted": true, - "version": 10, + "version": 9, }, }, }, @@ -16383,7 +16383,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00004", + "height": 0, "id": "id13", "index": "a3", "isDeleted": false, @@ -16398,7 +16398,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00004", + 0, ], ], "roughness": 1, @@ -16419,10 +16419,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 12, + "version": 10, "width": "88.00000", "x": 6, - "y": "0.00996", + "y": "0.01000", } `; @@ -16472,7 +16472,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 12, + "version": 10, }, "inserted": { "endBinding": { @@ -16492,7 +16492,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 9, + "version": 8, }, }, }, @@ -16801,7 +16801,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00936", + "height": "0.00120", "index": "a3", "isDeleted": false, "link": null, @@ -16813,8 +16813,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 88, - "0.00936", + "88.00000", + "0.00120", ], ], "roughness": 1, @@ -16834,14 +16834,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 8, - "width": 88, + "version": 7, + "width": "88.00000", "x": 6, - "y": 0, + "y": "0.00880", }, "inserted": { "isDeleted": true, - "version": 7, + "version": 6, }, }, }, @@ -17134,7 +17134,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00004", + "height": 0, "id": "id13", "index": "a3", "isDeleted": false, @@ -17149,7 +17149,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00004", + 0, ], ], "roughness": 1, @@ -17170,10 +17170,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 12, + "version": 10, "width": "88.00000", "x": 6, - "y": "0.00996", + "y": "0.01000", } `; @@ -17442,7 +17442,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00004", + "height": 0, "index": "a3", "isDeleted": false, "link": null, @@ -17455,7 +17455,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00004", + 0, ], ], "roughness": 1, @@ -17475,14 +17475,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 12, + "version": 10, "width": "88.00000", "x": 6, - "y": "0.00996", + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 9, + "version": 8, }, }, }, @@ -17783,7 +17783,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00004", + "height": 0, "id": "id13", "index": "a3", "isDeleted": false, @@ -17798,7 +17798,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00004", + 0, ], ], "roughness": 1, @@ -17819,10 +17819,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 12, + "version": 10, "width": "88.00000", "x": 6, - "y": "0.00996", + "y": "0.01000", } `; @@ -18091,7 +18091,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00004", + "height": 0, "index": "a3", "isDeleted": false, "link": null, @@ -18104,7 +18104,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00004", + 0, ], ], "roughness": 1, @@ -18124,14 +18124,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 12, + "version": 10, "width": "88.00000", "x": 6, - "y": "0.00996", + "y": "0.01000", }, "inserted": { "isDeleted": true, - "version": 9, + "version": 8, }, }, }, @@ -18430,7 +18430,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00004", + "height": 0, "id": "id13", "index": "a3", "isDeleted": false, @@ -18445,7 +18445,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00004", + 0, ], ], "roughness": 1, @@ -18466,10 +18466,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 12, + "version": 10, "width": "88.00000", "x": 6, - "y": "0.00996", + "y": "0.01000", } `; @@ -18535,7 +18535,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 12, + "version": 10, }, "inserted": { "endBinding": { @@ -18547,7 +18547,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "mode": "orbit", }, "startBinding": null, - "version": 9, + "version": 8, }, }, "id2": { @@ -18824,7 +18824,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00936", + "height": "0.00120", "index": "a3", "isDeleted": false, "link": null, @@ -18836,8 +18836,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 88, - "0.00936", + "88.00000", + "0.00120", ], ], "roughness": 1, @@ -18857,14 +18857,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 8, - "width": 88, + "version": 7, + "width": "88.00000", "x": 6, - "y": 0, + "y": "0.00880", }, "inserted": { "isDeleted": true, - "version": 7, + "version": 6, }, }, }, @@ -19185,7 +19185,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00004", + "height": 0, "id": "id13", "index": "a3", "isDeleted": false, @@ -19200,7 +19200,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], [ "88.00000", - "0.00004", + 0, ], ], "roughness": 1, @@ -19221,10 +19221,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 13, + "version": 11, "width": "88.00000", "x": 6, - "y": "0.00996", + "y": "0.01000", } `; @@ -19301,12 +19301,12 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding ], "mode": "orbit", }, - "version": 13, + "version": 11, }, "inserted": { "endBinding": null, "startBinding": null, - "version": 10, + "version": 9, }, }, }, @@ -19575,7 +19575,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "0.00936", + "height": "0.00120", "index": "a3", "isDeleted": false, "link": null, @@ -19587,8 +19587,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - 88, - "0.00936", + "88.00000", + "0.00120", ], ], "roughness": 1, @@ -19608,14 +19608,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "version": 8, - "width": 88, + "version": 7, + "width": "88.00000", "x": 6, - "y": 0, + "y": "0.00880", }, "inserted": { "isDeleted": true, - "version": 7, + "version": 6, }, }, }, diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index 5432a2d249..5857132962 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -182,14 +182,14 @@ exports[`move element > rectangles with binding arrow 7`] = ` "elementId": "id3", "fixedPoint": [ "-0.02000", - "0.47904", + "0.48010", ], "mode": "orbit", }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "90.02554", + "height": "90.01760", "id": "id6", "index": "a2", "isDeleted": false, @@ -204,7 +204,7 @@ exports[`move element > rectangles with binding arrow 7`] = ` ], [ 89, - "90.02554", + "90.01760", ], ], "roughness": 1, @@ -217,7 +217,7 @@ exports[`move element > rectangles with binding arrow 7`] = ` "elementId": "id0", "fixedPoint": [ "1.06000", - "0.55687", + "0.56011", ], "mode": "orbit", }, @@ -230,6 +230,6 @@ exports[`move element > rectangles with binding arrow 7`] = ` "versionNonce": 271613161, "width": 89, "x": 106, - "y": "55.68677", + "y": "56.01120", } `; From 4c3d037f9cddcec92472cb2f61c0840097649cc1 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:45:01 +0100 Subject: [PATCH 06/62] feat(editor): allow clicking on links and embeds with laser tool (#10797) Co-authored-by: Anvi Co-authored-by: Chris Tangonan --- packages/excalidraw/components/App.tsx | 222 +++++++++++++---------- packages/excalidraw/tests/laser.test.tsx | 109 +++++++++++ 2 files changed, 240 insertions(+), 91 deletions(-) create mode 100644 packages/excalidraw/tests/laser.test.tsx diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 32bac77e1e..14ebca6bce 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -442,10 +442,7 @@ import { searchItemInFocusAtom } from "./SearchMenu"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { StaticCanvas, InteractiveCanvas } from "./canvases"; import NewElementCanvas from "./canvases/NewElementCanvas"; -import { - isPointHittingLink, - isPointHittingLinkIcon, -} from "./hyperlink/helpers"; +import { isPointHittingLink } from "./hyperlink/helpers"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { Toast } from "./Toast"; @@ -1210,12 +1207,99 @@ class App extends React.Component { return this.iFrameRefs.get(element.id); } - private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) { + private handleIframeLikeElementHover = ({ + hitElement, + scenePointer, + moveEvent, + }: { + hitElement: NonDeleted | null; + scenePointer: { x: number; y: number }; + moveEvent: React.PointerEvent; + }): boolean => { if ( - this.state.activeEmbeddable?.element === element && + hitElement && + isIframeLikeElement(hitElement) && + this.isIframeLikeElementCenter( + hitElement, + moveEvent, + scenePointer.x, + scenePointer.y, + ) + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); + this.setState({ + activeEmbeddable: { element: hitElement, state: "hover" }, + }); + return true; + } else if (this.state.activeEmbeddable?.state === "hover") { + this.setState({ activeEmbeddable: null }); + } + return false; + }; + + /** @returns true if iframe-like element click handled */ + private handleIframeLikeCenterClick(): boolean { + if (!this.lastPointerDownEvent || !this.lastPointerUpEvent) { + return false; + } + + const scenePointerStart = viewportCoordsToSceneCoords( + { + clientX: this.lastPointerDownEvent.clientX, + clientY: this.lastPointerDownEvent.clientY, + }, + this.state, + ); + const scenePointerEnd = viewportCoordsToSceneCoords( + { + clientX: this.lastPointerUpEvent.clientX, + clientY: this.lastPointerUpEvent.clientY, + }, + this.state, + ); + + const hitElementStart = this.getElementAtPosition( + scenePointerStart.x, + scenePointerStart.y, + ); + + const hitElementEnd = this.getElementAtPosition( + scenePointerEnd.x, + scenePointerEnd.y, + ); + + if ( + !hitElementStart || + !hitElementEnd || + hitElementStart !== hitElementEnd || + this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp > + 300 || + gesture.pointers.size > 1 || + !isIframeLikeElement(hitElementStart) || + !isIframeLikeElement(hitElementEnd) || + !this.isIframeLikeElementCenter( + hitElementStart, + this.lastPointerUpEvent, + scenePointerStart.x, + scenePointerStart.y, + ) || + !this.isIframeLikeElementCenter( + hitElementEnd, + this.lastPointerUpEvent, + scenePointerEnd.x, + scenePointerEnd.y, + ) + ) { + return false; + } + + const iframeLikeElement = hitElementEnd; + + if ( + this.state.activeEmbeddable?.element === iframeLikeElement && this.state.activeEmbeddable?.state === "active" ) { - return; + return true; } // The delay serves two purposes @@ -1226,31 +1310,34 @@ class App extends React.Component { // in fullscreen mode setTimeout(() => { this.setState({ - activeEmbeddable: { element, state: "active" }, - selectedElementIds: { [element.id]: true }, + activeEmbeddable: { element: iframeLikeElement, state: "active" }, + selectedElementIds: { [iframeLikeElement.id]: true }, newElement: null, selectionElement: null, }); }, 100); - if (isIframeElement(element)) { - return; + if (isIframeElement(iframeLikeElement)) { + return true; } - const iframe = this.getHTMLIFrameElement(element); + const iframe = this.getHTMLIFrameElement(iframeLikeElement); if (!iframe?.contentWindow) { - return; + return true; } if (iframe.src.includes("youtube")) { - const state = YOUTUBE_VIDEO_STATES.get(element.id); + const state = YOUTUBE_VIDEO_STATES.get(iframeLikeElement.id); if (!state) { - YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED); + YOUTUBE_VIDEO_STATES.set( + iframeLikeElement.id, + YOUTUBE_STATES.UNSTARTED, + ); iframe.contentWindow.postMessage( JSON.stringify({ event: "listening", - id: element.id, + id: iframeLikeElement.id, }), "*", ); @@ -1287,6 +1374,8 @@ class App extends React.Component { "*", ); } + + return true; } private isIframeLikeElementCenter( @@ -6622,10 +6711,14 @@ class App extends React.Component { } } - const hasDeselectedButton = Boolean(event.buttons); + const isPressingAnyButton = Boolean(event.buttons); + const isLaserTool = this.state.activeTool.type === "laser"; if ( - hasDeselectedButton || - (this.state.activeTool.type !== "selection" && + isPressingAnyButton || + // checking against laser so that if you mouseover with a laser tool + // over a link/embeddable, we change the cursor + (!isLaserTool && + this.state.activeTool.type !== "selection" && this.state.activeTool.type !== "lasso" && this.state.activeTool.type !== "text" && this.state.activeTool.type !== "eraser") @@ -6745,6 +6838,14 @@ class App extends React.Component { ); } else { hideHyperlinkToolip(); + if (isLaserTool) { + this.handleIframeLikeElementHover({ + hitElement, + scenePointer, + moveEvent: event, + }); + return; + } if ( hitElement && (hitElement.link || isEmbeddableElement(hitElement)) && @@ -6777,24 +6878,15 @@ class App extends React.Component { !hitElement?.locked ) { if ( - hitElement && - isIframeLikeElement(hitElement) && - this.isIframeLikeElementCenter( + !this.handleIframeLikeElementHover({ hitElement, - event, - scenePointerX, - scenePointerY, - ) - ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); - this.setState({ - activeEmbeddable: { element: hitElement, state: "hover" }, - }); - } else if ( - !hitElement || - // Elbow arrows can only be moved when unconnected - !isElbowArrow(hitElement) || - !(hitElement.startBinding || hitElement.endBinding) + scenePointer, + moveEvent: event, + }) && + (!hitElement || + // Elbow arrows can only be moved when unconnected + !isElbowArrow(hitElement) || + !(hitElement.startBinding || hitElement.endBinding)) ) { if ( this.state.activeTool.type !== "lasso" || @@ -6802,9 +6894,6 @@ class App extends React.Component { ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } - if (this.state.activeEmbeddable?.state === "hover") { - this.setState({ activeEmbeddable: null }); - } } } } else { @@ -7456,26 +7545,9 @@ class App extends React.Component { x: scenePointerX, y: scenePointerY, }; - const clicklength = - event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0); - if (this.editorInterface.formFactor === "phone" && clicklength < 300) { - const hitElement = this.getElementAtPosition( - scenePointer.x, - scenePointer.y, - ); - if ( - isIframeLikeElement(hitElement) && - this.isIframeLikeElementCenter( - hitElement, - event, - scenePointer.x, - scenePointer.y, - ) - ) { - this.handleEmbeddableCenterClick(hitElement); - return; - } + if (this.handleIframeLikeCenterClick()) { + return; } if (this.editorInterface.isTouchScreen) { @@ -7496,20 +7568,7 @@ class App extends React.Component { this.hitLinkElement && !this.state.selectedElementIds[this.hitLinkElement.id] ) { - if ( - clicklength < 300 && - isIframeLikeElement(this.hitLinkElement) && - !isPointHittingLinkIcon( - this.hitLinkElement, - this.scene.getNonDeletedElementsMap(), - this.state, - pointFrom(scenePointer.x, scenePointer.y), - ) - ) { - this.handleEmbeddableCenterClick(this.hitLinkElement); - } else { - this.redirectToLink(event, this.editorInterface.isTouchScreen); - } + this.redirectToLink(event, this.editorInterface.isTouchScreen); } else if (this.state.viewModeEnabled) { this.setState({ activeEmbeddable: null, @@ -10852,25 +10911,6 @@ class App extends React.Component { suggestedBinding: null, }); } - - if ( - hitElement && - this.lastPointerUpEvent && - this.lastPointerDownEvent && - this.lastPointerUpEvent.timeStamp - - this.lastPointerDownEvent.timeStamp < - 300 && - gesture.pointers.size <= 1 && - isIframeLikeElement(hitElement) && - this.isIframeLikeElementCenter( - hitElement, - this.lastPointerUpEvent, - pointerDownState.origin.x, - pointerDownState.origin.y, - ) - ) { - this.handleEmbeddableCenterClick(hitElement); - } }); } diff --git a/packages/excalidraw/tests/laser.test.tsx b/packages/excalidraw/tests/laser.test.tsx new file mode 100644 index 0000000000..b8b6510259 --- /dev/null +++ b/packages/excalidraw/tests/laser.test.tsx @@ -0,0 +1,109 @@ +import { vi } from "vitest"; + +import { CURSOR_TYPE } from "@excalidraw/common"; +import { getElementAbsoluteCoords } from "@excalidraw/element"; + +import { Excalidraw } from "../index"; +import { getLinkHandleFromCoords } from "../components/hyperlink/helpers"; + +import { API } from "./helpers/api"; +import { Pointer } from "./helpers/ui"; +import { act, GlobalTestState, render, waitFor } from "./test-utils"; + +import type { ExcalidrawProps } from "../types"; + +describe("laser tool interactions", () => { + const h = window.h; + const mouse = new Pointer("mouse"); + + it("opens links while using the laser tool", async () => { + const onLinkOpenSpy = vi.fn(); + const onLinkOpen: NonNullable = ( + ...args + ) => { + onLinkOpenSpy(...args); + args[1].preventDefault(); + }; + await render(); + + const linkedRect = API.createElement({ + type: "rectangle", + x: 20, + y: 20, + width: 120, + height: 90, + }); + API.setElements([linkedRect]); + API.updateElement(linkedRect, { + link: "https://example.com", + }); + + act(() => { + h.app.setActiveTool({ type: "laser" }); + }); + + const elementsMap = h.app.scene.getNonDeletedElementsMap(); + const currentRect = API.getElement(linkedRect); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(currentRect, elementsMap); + const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords( + [x1, y1, x2, y2], + currentRect.angle, + h.state, + ); + const iconCenterX = linkX + linkWidth / 2; + const iconCenterY = linkY + linkHeight / 2; + + mouse.moveTo(iconCenterX, iconCenterY); + expect(GlobalTestState.interactiveCanvas.style.cursor).toBe( + CURSOR_TYPE.POINTER, + ); + + mouse.clickAt(iconCenterX, iconCenterY); + expect(onLinkOpenSpy).toHaveBeenCalledTimes(1); + }); + + it("activates embeddables on center click while using the laser tool", async () => { + await render(); + + const embeddable = API.createElement({ + type: "embeddable", + x: 40, + y: 40, + width: 300, + height: 180, + }); + API.setElements([embeddable]); + API.updateElement(embeddable, { + link: "https://www.youtube.com/watch?v=gkGMXY0wekg", + }); + + act(() => { + h.app.setActiveTool({ type: "laser" }); + }); + + const handleIframeLikeCenterClickSpy = vi.spyOn( + h.app as unknown as { + handleIframeLikeCenterClick: () => void; + }, + "handleIframeLikeCenterClick", + ); + + const centerX = embeddable.x + embeddable.width / 2; + const centerY = embeddable.y + embeddable.height / 2; + + mouse.moveTo(centerX, centerY); + expect(GlobalTestState.interactiveCanvas.style.cursor).toBe( + CURSOR_TYPE.POINTER, + ); + mouse.clickAt(centerX, centerY); + + expect(handleIframeLikeCenterClickSpy).toHaveBeenCalled(); + + await waitFor(() => { + expect(h.state.activeEmbeddable?.element.id).toBe(embeddable.id); + expect(h.state.activeEmbeddable?.state).toBe("active"); + }); + + handleIframeLikeCenterClickSpy.mockRestore(); + }); +}); From eb959128ac1543e7f6ea1c4f1935b9899a3e1b9b Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:49:46 +0100 Subject: [PATCH 07/62] feat(editor): allow laser-pointing in view mode (#10802) * feat(editor): allow laser pointing in view mode * feat: allow switching between laser/hand in view mode * fix lint * factor out to utils * fix: only handle primary clicks with the selection/laser tools --- packages/common/src/utils.ts | 11 +- packages/excalidraw/components/Actions.tsx | 8 +- packages/excalidraw/components/App.tsx | 298 ++++++++++-------- .../components/CommandPalette/types.ts | 2 +- packages/excalidraw/components/LayerUI.tsx | 9 - .../components/canvases/InteractiveCanvas.tsx | 9 +- packages/excalidraw/components/shapes.tsx | 29 ++ packages/excalidraw/tests/laser.test.tsx | 22 ++ packages/excalidraw/tests/selection.test.tsx | 4 +- packages/excalidraw/types.ts | 1 + packages/math/src/point.ts | 19 +- packages/math/src/types.ts | 22 +- 12 files changed, 287 insertions(+), 147 deletions(-) diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 3727e562d3..5bafa41813 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -1,5 +1,7 @@ import { average } from "@excalidraw/math"; +import type { GlobalCoord } from "@excalidraw/math"; + import type { FontFamilyValues, FontString } from "@excalidraw/element/types"; import type { @@ -441,7 +443,7 @@ export const viewportCoordsToSceneCoords = ( const x = (clientX - offsetLeft) / zoom.value - scrollX; const y = (clientY - offsetTop) / zoom.value - scrollY; - return { x, y }; + return { x, y } as GlobalCoord; }; export const sceneCoordsToViewportCoords = ( @@ -1330,3 +1332,10 @@ export const setFeatureFlag = ( console.error("unable to set feature flag", e); } }; + +export const oneOf = ( + needle: N, + haystack: readonly H[], +): needle is H => { + return haystack.includes(needle as any); +}; diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index d9f3415d64..9372e2b243 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1081,8 +1081,9 @@ export const ShapesSwitcher = ({ return ( <> {getToolbarTools(app).map( - ({ value, icon, key, numericKey, fillable }, index) => { + ({ value, icon, key, numericKey, fillable, toolbar }) => { if ( + toolbar === false || UIOptions.tools?.[ value as Extract< typeof value, @@ -1099,6 +1100,9 @@ export const ShapesSwitcher = ({ const shortcut = letter ? `${letter} ${t("helpDialog.or")} ${numericKey}` : `${numericKey}`; + const keybindingLabel = + value === "hand" ? undefined : numericKey || letter; + // when in compact styles panel mode (tablet) // use a ToolPopover for selection/lasso toggle as well if ( @@ -1143,7 +1147,7 @@ export const ShapesSwitcher = ({ checked={activeTool.type === value} name="editor-current-shape" title={`${capitalizeString(label)} — ${shortcut}`} - keyBindingLabel={numericKey || letter} + keyBindingLabel={keybindingLabel} aria-label={capitalizeString(label)} aria-keyshortcuts={shortcut} data-testid={`toolbar-${value}`} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 14ebca6bce..59a11f804c 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -108,6 +108,7 @@ import { loadDesktopUIModePreference, setDesktopUIMode, isSelectionLikeTool, + oneOf, } from "@excalidraw/common"; import { @@ -1219,12 +1220,14 @@ class App extends React.Component { if ( hitElement && isIframeLikeElement(hitElement) && - this.isIframeLikeElementCenter( - hitElement, - moveEvent, - scenePointer.x, - scenePointer.y, - ) + (this.state.viewModeEnabled || + this.state.activeTool.type === "laser" || + this.isIframeLikeElementCenter( + hitElement, + moveEvent, + scenePointer.x, + scenePointer.y, + )) ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); this.setState({ @@ -1239,61 +1242,72 @@ class App extends React.Component { /** @returns true if iframe-like element click handled */ private handleIframeLikeCenterClick(): boolean { - if (!this.lastPointerDownEvent || !this.lastPointerUpEvent) { - return false; - } - - const scenePointerStart = viewportCoordsToSceneCoords( - { - clientX: this.lastPointerDownEvent.clientX, - clientY: this.lastPointerDownEvent.clientY, - }, - this.state, - ); - const scenePointerEnd = viewportCoordsToSceneCoords( - { - clientX: this.lastPointerUpEvent.clientX, - clientY: this.lastPointerUpEvent.clientY, - }, - this.state, - ); - - const hitElementStart = this.getElementAtPosition( - scenePointerStart.x, - scenePointerStart.y, - ); - - const hitElementEnd = this.getElementAtPosition( - scenePointerEnd.x, - scenePointerEnd.y, - ); - if ( - !hitElementStart || - !hitElementEnd || - hitElementStart !== hitElementEnd || - this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp > - 300 || - gesture.pointers.size > 1 || - !isIframeLikeElement(hitElementStart) || - !isIframeLikeElement(hitElementEnd) || - !this.isIframeLikeElementCenter( - hitElementStart, - this.lastPointerUpEvent, - scenePointerStart.x, - scenePointerStart.y, - ) || - !this.isIframeLikeElementCenter( - hitElementEnd, - this.lastPointerUpEvent, - scenePointerEnd.x, - scenePointerEnd.y, - ) + !this.lastPointerDownEvent || + !this.lastPointerUpEvent || + // middle-click or something other than primary + this.lastPointerDownEvent.button !== POINTER_BUTTON.MAIN || + // panning + isHoldingSpace || + // wrong tool + !oneOf(this.state.activeTool.type, ["laser", "selection", "lasso"]) ) { return false; } - const iframeLikeElement = hitElementEnd; + const viewportClickStart_scenePoint = pointFrom( + viewportCoordsToSceneCoords( + { + clientX: this.lastPointerDownEvent.clientX, + clientY: this.lastPointerDownEvent.clientY, + }, + this.state, + ), + ); + const viewportClickEnd_scenePoint = pointFrom( + viewportCoordsToSceneCoords( + { + clientX: this.lastPointerUpEvent.clientX, + clientY: this.lastPointerUpEvent.clientY, + }, + this.state, + ), + ); + + const draggedDistance = pointDistance( + viewportClickStart_scenePoint, + viewportClickEnd_scenePoint, + ); + + if (draggedDistance > DRAGGING_THRESHOLD) { + return false; + } + + const hitElement = this.getElementAtPosition( + viewportClickStart_scenePoint[0], + viewportClickStart_scenePoint[1], + ); + + const shouldActivate = + hitElement && + this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp <= + 300 && + gesture.pointers.size < 2 && + isIframeLikeElement(hitElement) && + (this.state.viewModeEnabled || + this.state.activeTool.type === "laser" || + this.isIframeLikeElementCenter( + hitElement, + this.lastPointerUpEvent, + viewportClickEnd_scenePoint[0], + viewportClickEnd_scenePoint[1], + )); + + if (!shouldActivate) { + return false; + } + + const iframeLikeElement = hitElement; if ( this.state.activeEmbeddable?.element === iframeLikeElement && @@ -4844,6 +4858,74 @@ class App extends React.Component { return; } + // view mode hardcoded from upstream -> disable tool switching for now + const shouldPreventToolSwitching = this.props.viewModeEnabled === true; + + if ( + !shouldPreventToolSwitching && + this.state.viewModeEnabled && + event.key === KEYS.ESCAPE + ) { + this.setActiveTool({ type: "hand" }); + return; + } + + if ( + !shouldPreventToolSwitching && + !event.ctrlKey && + !event.altKey && + !event.metaKey && + !this.state.newElement && + !this.state.selectionElement && + !this.state.selectedElementsAreBeingDragged + ) { + const shape = findShapeByKey(event.key, this); + + if (this.state.viewModeEnabled && !oneOf(shape, ["laser", "hand"])) { + return; + } + + if (shape) { + if (this.state.activeTool.type !== shape) { + trackEvent( + "toolbar", + shape, + `keyboard (${ + this.editorInterface.formFactor === "phone" + ? "mobile" + : "desktop" + })`, + ); + } + if (shape === "arrow" && this.state.activeTool.type === "arrow") { + this.setState((prevState) => ({ + currentItemArrowType: + prevState.currentItemArrowType === ARROW_TYPE.sharp + ? ARROW_TYPE.round + : prevState.currentItemArrowType === ARROW_TYPE.round + ? ARROW_TYPE.elbow + : ARROW_TYPE.sharp, + })); + } + + if (shape === "lasso" && this.state.activeTool.type === "laser") { + this.setActiveTool({ + type: this.state.preferredSelectionTool.type, + }); + } else { + this.setActiveTool({ type: shape }); + } + + event.stopPropagation(); + + return; + } else if (event.key === KEYS.Q) { + this.toggleLock("keyboard"); + event.stopPropagation(); + return; + } + } + if (this.state.viewModeEnabled) { return; } @@ -4977,44 +5059,8 @@ class App extends React.Component { }); } } - } else if ( - !event.ctrlKey && - !event.altKey && - !event.metaKey && - !this.state.newElement && - !this.state.selectionElement && - !this.state.selectedElementsAreBeingDragged - ) { - const shape = findShapeByKey(event.key, this); - if (shape) { - if (this.state.activeTool.type !== shape) { - trackEvent( - "toolbar", - shape, - `keyboard (${ - this.editorInterface.formFactor === "phone" - ? "mobile" - : "desktop" - })`, - ); - } - if (shape === "arrow" && this.state.activeTool.type === "arrow") { - this.setState((prevState) => ({ - currentItemArrowType: - prevState.currentItemArrowType === ARROW_TYPE.sharp - ? ARROW_TYPE.round - : prevState.currentItemArrowType === ARROW_TYPE.round - ? ARROW_TYPE.elbow - : ARROW_TYPE.sharp, - })); - } - this.setActiveTool({ type: shape }); - event.stopPropagation(); - } else if (event.key === KEYS.Q) { - this.toggleLock("keyboard"); - event.stopPropagation(); - } } + if (event.key === KEYS.SPACE && gesture.pointers.size === 0) { isHoldingSpace = true; setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); @@ -5078,15 +5124,6 @@ class App extends React.Component { } } - if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) { - if (this.state.activeTool.type === "laser") { - this.setActiveTool({ type: this.state.preferredSelectionTool.type }); - } else { - this.setActiveTool({ type: "laser" }); - } - return; - } - if ( event[KEYS.CTRL_OR_CMD] && (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) @@ -5113,7 +5150,8 @@ class App extends React.Component { private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => { if (event.key === KEYS.SPACE) { if ( - this.state.viewModeEnabled || + (this.state.viewModeEnabled && + this.state.activeTool.type !== "laser") || this.state.openDialog?.name === "elementLinkSelector" ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); @@ -6227,9 +6265,8 @@ class App extends React.Component { } }; - private redirectToLink = ( + private handleElementLinkClick = ( event: React.PointerEvent, - isTouchScreen: boolean, ) => { const draggedDistance = pointDistance( pointFrom( @@ -6803,6 +6840,10 @@ class App extends React.Component { } } + if (isEraserActive(this.state)) { + return; + } + const hitElementMightBeLocked = this.getElementAtPosition( scenePointerX, scenePointerY, @@ -6819,18 +6860,25 @@ class App extends React.Component { hitElement = hitElementMightBeLocked; } - this.hitLinkElement = this.getElementLinkAtPosition( - scenePointer, - hitElementMightBeLocked, - ); - if (isEraserActive(this.state)) { - return; + if ( + !this.handleIframeLikeElementHover({ + hitElement, + scenePointer, + moveEvent: event, + }) + ) { + this.hitLinkElement = this.getElementLinkAtPosition( + scenePointer, + hitElementMightBeLocked, + ); } + if ( this.hitLinkElement && !this.state.selectedElementIds[this.hitLinkElement.id] ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); + showHyperlinkTooltip( this.hitLinkElement, this.state, @@ -6839,11 +6887,6 @@ class App extends React.Component { } else { hideHyperlinkToolip(); if (isLaserTool) { - this.handleIframeLikeElementHover({ - hitElement, - scenePointer, - moveEvent: event, - }); return; } if ( @@ -6878,15 +6921,10 @@ class App extends React.Component { !hitElement?.locked ) { if ( - !this.handleIframeLikeElementHover({ - hitElement, - scenePointer, - moveEvent: event, - }) && - (!hitElement || - // Elbow arrows can only be moved when unconnected - !isElbowArrow(hitElement) || - !(hitElement.startBinding || hitElement.endBinding)) + !hitElement || + // Elbow arrows can only be moved when unconnected + !isElbowArrow(hitElement) || + !(hitElement.startBinding || hitElement.endBinding) ) { if ( this.state.activeTool.type !== "lasso" || @@ -7568,7 +7606,7 @@ class App extends React.Component { this.hitLinkElement && !this.state.selectedElementIds[this.hitLinkElement.id] ) { - this.redirectToLink(event, this.editorInterface.isTouchScreen); + this.handleElementLinkClick(event); } else if (this.state.viewModeEnabled) { this.setState({ activeEmbeddable: null, @@ -7628,7 +7666,8 @@ class App extends React.Component { (event.button === POINTER_BUTTON.WHEEL || (event.button === POINTER_BUTTON.MAIN && isHoldingSpace) || isHandToolActive(this.state) || - this.state.viewModeEnabled) + (this.state.viewModeEnabled && + this.state.activeTool.type !== "laser")) ) ) { return false; @@ -7706,7 +7745,10 @@ class App extends React.Component { lastPointerUp = null; isPanning = false; if (!isHoldingSpace) { - if (this.state.viewModeEnabled) { + if ( + this.state.viewModeEnabled && + this.state.activeTool.type !== "laser" + ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); } else { setCursorForShape(this.interactiveCanvas, this.state); diff --git a/packages/excalidraw/components/CommandPalette/types.ts b/packages/excalidraw/components/CommandPalette/types.ts index 3eed838ce8..bb01d66b1e 100644 --- a/packages/excalidraw/components/CommandPalette/types.ts +++ b/packages/excalidraw/components/CommandPalette/types.ts @@ -15,7 +15,7 @@ export type CommandPaletteItem = { category: string; order?: number; predicate?: boolean | Action["predicate"]; - shortcut?: string; + shortcut?: string | null; /** if false, command will not show while in view mode */ viewMode?: boolean; perform: (data: { diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 17dffdfd9d..11447bbd31 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -20,7 +20,6 @@ import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import { actionToggleStats } from "../actions"; import { trackEvent } from "../analytics"; -import { isHandToolActive } from "../appState"; import { TunnelsContext, useInitializeTunnels } from "../context/tunnels"; import { UIAppStateContext } from "../context/ui-appState"; import { useAtom, useAtomValue } from "../editor-jotai"; @@ -55,7 +54,6 @@ import ElementLinkDialog from "./ElementLinkDialog"; import { ErrorDialog } from "./ErrorDialog"; import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper"; import { FixedSideContainer } from "./FixedSideContainer"; -import { HandButton } from "./HandButton"; import { HelpDialog } from "./HelpDialog"; import { HintViewer } from "./HintViewer"; import { ImageExportDialog } from "./ImageExportDialog"; @@ -359,13 +357,6 @@ const LayerUI = ({
- onHandToolToggle()} - title={t("toolBar.hand")} - isMobile - /> - { style={{ width: props.appState.width, height: props.appState.height, - cursor: props.appState.viewModeEnabled - ? CURSOR_TYPE.GRAB - : CURSOR_TYPE.AUTO, + cursor: + props.appState.viewModeEnabled && + props.appState.activeTool.type !== "laser" + ? CURSOR_TYPE.GRAB + : CURSOR_TYPE.AUTO, }} width={props.appState.width * props.scale} height={props.appState.height * props.scale} @@ -233,6 +235,7 @@ const getRelevantAppStateProps = ( width: appState.width, height: appState.height, viewModeEnabled: appState.viewModeEnabled, + activeTool: appState.activeTool, openDialog: appState.openDialog, editingGroupId: appState.editingGroupId, selectedElementIds: appState.selectedElementIds, diff --git a/packages/excalidraw/components/shapes.tsx b/packages/excalidraw/components/shapes.tsx index d46f08a311..999dfe1c14 100644 --- a/packages/excalidraw/components/shapes.tsx +++ b/packages/excalidraw/components/shapes.tsx @@ -11,17 +11,28 @@ import { TextIcon, ImageIcon, EraserIcon, + laserPointerToolIcon, + handIcon, } from "./icons"; import type { AppClassProperties } from "../types"; export const SHAPES = [ + { + icon: handIcon, + value: "hand", + key: KEYS.H, + numericKey: null, + fillable: false, + toolbar: true, + }, { icon: SelectionIcon, value: "selection", key: KEYS.V, numericKey: KEYS["1"], fillable: true, + toolbar: true, }, { icon: RectangleIcon, @@ -29,6 +40,7 @@ export const SHAPES = [ key: KEYS.R, numericKey: KEYS["2"], fillable: true, + toolbar: true, }, { icon: DiamondIcon, @@ -36,6 +48,7 @@ export const SHAPES = [ key: KEYS.D, numericKey: KEYS["3"], fillable: true, + toolbar: true, }, { icon: EllipseIcon, @@ -43,6 +56,7 @@ export const SHAPES = [ key: KEYS.O, numericKey: KEYS["4"], fillable: true, + toolbar: true, }, { icon: ArrowIcon, @@ -50,6 +64,7 @@ export const SHAPES = [ key: KEYS.A, numericKey: KEYS["5"], fillable: true, + toolbar: true, }, { icon: LineIcon, @@ -57,6 +72,7 @@ export const SHAPES = [ key: KEYS.L, numericKey: KEYS["6"], fillable: true, + toolbar: true, }, { icon: FreedrawIcon, @@ -64,6 +80,7 @@ export const SHAPES = [ key: [KEYS.P, KEYS.X], numericKey: KEYS["7"], fillable: false, + toolbar: true, }, { icon: TextIcon, @@ -71,6 +88,7 @@ export const SHAPES = [ key: KEYS.T, numericKey: KEYS["8"], fillable: false, + toolbar: true, }, { icon: ImageIcon, @@ -78,6 +96,7 @@ export const SHAPES = [ key: null, numericKey: KEYS["9"], fillable: false, + toolbar: true, }, { icon: EraserIcon, @@ -85,6 +104,15 @@ export const SHAPES = [ key: KEYS.E, numericKey: KEYS["0"], fillable: false, + toolbar: true, + }, + { + icon: laserPointerToolIcon, + value: "laser", + key: KEYS.K, + numericKey: null, + fillable: false, + toolbar: false, }, ] as const; @@ -97,6 +125,7 @@ export const getToolbarTools = (app: AppClassProperties) => { key: KEYS.V, numericKey: KEYS["1"], fillable: true, + toolbar: true, }, ...SHAPES.slice(1), ] as const) diff --git a/packages/excalidraw/tests/laser.test.tsx b/packages/excalidraw/tests/laser.test.tsx index b8b6510259..fc5c3aa5aa 100644 --- a/packages/excalidraw/tests/laser.test.tsx +++ b/packages/excalidraw/tests/laser.test.tsx @@ -106,4 +106,26 @@ describe("laser tool interactions", () => { handleIframeLikeCenterClickSpy.mockRestore(); }); + + it("doesn't pan in view mode when laser tool is active", async () => { + await render(); + + API.setAppState({ viewModeEnabled: true }); + act(() => { + h.app.setActiveTool({ type: "laser" }); + }); + + expect(GlobalTestState.interactiveCanvas.style.cursor).toContain(""); + + const initialScrollX = h.state.scrollX; + const initialScrollY = h.state.scrollY; + + mouse.downAt(100, 100); + mouse.moveTo(180, 160); + mouse.upAt(180, 160); + + expect(h.state.scrollX).toBe(initialScrollX); + expect(h.state.scrollY).toBe(initialScrollY); + expect(GlobalTestState.interactiveCanvas.style.cursor).toContain(""); + }); }); diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 86be835f7b..93135fb2e1 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -504,7 +504,9 @@ describe("tool locking & selection", () => { value !== "image" && value !== "selection" && value !== "eraser" && - value !== "arrow" + value !== "arrow" && + value !== "hand" && + value !== "laser" ) { const element = UI.createElement(value); expect(h.state.selectedElementIds[element.id]).not.toBe(true); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 6ce4ae9267..f7e86cc524 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -215,6 +215,7 @@ export type StaticCanvasAppState = Readonly< export type InteractiveCanvasAppState = Readonly< _CommonCanvasAppState & { + activeTool: AppState["activeTool"]; // renderInteractiveScene activeEmbeddable: AppState["activeEmbeddable"]; selectionElement: AppState["selectionElement"]; diff --git a/packages/math/src/point.ts b/packages/math/src/point.ts index 863febfd41..ed25a713d8 100644 --- a/packages/math/src/point.ts +++ b/packages/math/src/point.ts @@ -8,6 +8,8 @@ import type { Radians, Degrees, Vector, + GlobalCoord, + LocalCoord, } from "./types"; /** @@ -20,8 +22,23 @@ import type { export function pointFrom( x: number, y: number, +): Point; +// TODO remove the overload once we migrate to using Point tuples everywhere +export function pointFrom( + coords: Coord, +): Coord extends GlobalCoord ? GlobalPoint : LocalPoint; +// TODO remove the overload once we migrate to using Point tuples everywhere +export function pointFrom(coords: { + x: number; + y: number; +}): Point; +export function pointFrom( + xOrCoords: number | { x: number; y: number }, + y?: number, ): Point { - return [x, y] as Point; + return typeof xOrCoords === "object" + ? ([xOrCoords.x, xOrCoords.y] as Point) + : ([xOrCoords, y!] as Point); } /** diff --git a/packages/math/src/types.ts b/packages/math/src/types.ts index da7d5d6abd..9adb208291 100644 --- a/packages/math/src/types.ts +++ b/packages/math/src/types.ts @@ -28,13 +28,23 @@ export type InclusiveRange = [number, number] & { _brand: "excalimath_degree" }; // /** - * Represents a 2D position in world or canvas space. A + * Represents a 2D position in world/canvas/scene space. A * global coordinate. */ export type GlobalPoint = [x: number, y: number] & { _brand: "excalimath__globalpoint"; }; +/** + * Represents a 2D position in world/canvas/scene space. A + * global coordinate. + * + * TODO remove this once we migrate the codebase to use Point tuples everywhere + */ +export type GlobalCoord = { x: number; y: number } & { + _brand: "excalimath__globalcoord"; +}; + /** * Represents a 2D position in whatever local space it's * needed. A local coordinate. @@ -43,6 +53,16 @@ export type LocalPoint = [x: number, y: number] & { _brand: "excalimath__localpoint"; }; +/** + * Represents a 2D position in whatever local space it's needed. + * A local coordinate. + * + * TODO remove this once we migrate the codebase to use Point tuples everywhere + */ +export type LocalCoord = { x: number; y: number } & { + _brand: "excalimath__localcoord"; +}; + // Line /** From b0404b10b6d775d4a0bc48072947f95e5753e117 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:20:37 +0100 Subject: [PATCH 08/62] chore(debug): add debug.logChanged() and make easy to import (#10828) --- {excalidraw-app => packages/common}/debug.ts | 74 +++++++++++++++++--- packages/common/src/index.ts | 1 + 2 files changed, 67 insertions(+), 8 deletions(-) rename {excalidraw-app => packages/common}/debug.ts (77%) diff --git a/excalidraw-app/debug.ts b/packages/common/debug.ts similarity index 77% rename from excalidraw-app/debug.ts rename to packages/common/debug.ts index 1ef136fb02..efb6fefb46 100644 --- a/excalidraw-app/debug.ts +++ b/packages/common/debug.ts @@ -1,9 +1,3 @@ -declare global { - interface Window { - debug: typeof Debug; - } -} - const lessPrecise = (num: number, precision = 5) => parseFloat(num.toPrecision(precision)); @@ -157,6 +151,70 @@ export class Debug { return ret; }; }; + + private static CHANGED_CACHE: Record> = {}; + + public static logChanged(name: string, obj: Record) { + const prev = Debug.CHANGED_CACHE[name]; + + Debug.CHANGED_CACHE[name] = obj; + + if (!prev) { + return; + } + + const allKeys = new Set([...Object.keys(prev), ...Object.keys(obj)]); + const changed: Record = {}; + + for (const key of allKeys) { + const prevVal = prev[key]; + const nextVal = obj[key]; + if (!deepEqual(prevVal, nextVal)) { + changed[key] = { prev: prevVal, next: nextVal }; + } + } + + if (Object.keys(changed).length > 0) { + console.info(`[${name}] changed:`, changed); + } + } +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) { + return true; + } + + if ( + a === null || + b === null || + typeof a !== "object" || + typeof b !== "object" + ) { + return false; + } + + if (Array.isArray(a) !== Array.isArray(b)) { + return false; + } + + const keysA = Object.keys(a as Record); + const keysB = Object.keys(b as Record); + + if (keysA.length !== keysB.length) { + return false; + } + + for (const key of keysA) { + if ( + !deepEqual( + (a as Record)[key], + (b as Record)[key], + ) + ) { + return false; + } + } + + return true; } -//@ts-ignore -window.debug = Debug; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 7d6bf5b0dc..ca5397ddd1 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -12,3 +12,4 @@ export * from "./url"; export * from "./utils"; export * from "./emitter"; export * from "./editorInterface"; +export { Debug } from "../debug"; From 7ea3229e17696d2d64f9d0c4a0596b15d8cc81b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Mon, 23 Feb 2026 20:22:27 +0100 Subject: [PATCH 09/62] fix(editor): Hardened fixed point and bound element parsing in restore (#10816) * fix: Reinforce fixedPoint restore Signed-off-by: Mark Tolmacs * fix: Even more hardened boundElement in restore Signed-off-by: Mark Tolmacs * fix: Extract constant Signed-off-by: Mark Tolmacs * fix: Remove superfluous check from restore Signed-off-by: Mark Tolmacs * chore: Remove non-needed code path Signed-off-by: Mark Tolmacs * fix: More robust number test for fixedPoint parsing Signed-off-by: Mark Tolmacs * fix: Validate bindings for element being parsed Signed-off-by: Mark Tolmacs * unrelated type safety --------- Signed-off-by: Mark Tolmacs Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/element/src/binding.ts | 32 ++++++++++++++++++------ packages/excalidraw/data/restore.ts | 38 ++++++++++++++++++++--------- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 86041b4aba..ae84623c3b 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -2447,21 +2447,37 @@ export const getArrowLocalFixedPoints = ( ]; }; -export const normalizeFixedPoint = ( +export const isFixedPoint = ( + fixedPoint: any, +): fixedPoint is FixedPointBinding["fixedPoint"] => { + return ( + Array.isArray(fixedPoint) && + fixedPoint.length === 2 && + fixedPoint.every((coord) => Number.isFinite(coord)) + ); +}; + +export const normalizeFixedPoint = ( fixedPoint: T, -): T extends null ? null : FixedPoint => { +): FixedPoint => { + if (!isFixedPoint(fixedPoint)) { + return [0.5001, 0.5001]; + } + + const EPSILON = 0.0001; + // Do not allow a precise 0.5 for fixed point ratio // to avoid jumping arrow heading due to floating point imprecision if ( - fixedPoint && - (Math.abs(fixedPoint[0] - 0.5) < 0.0001 || - Math.abs(fixedPoint[1] - 0.5) < 0.0001) + Math.abs(fixedPoint[0] - 0.5) < EPSILON || + Math.abs(fixedPoint[1] - 0.5) < EPSILON ) { return fixedPoint.map((ratio) => - Math.abs(ratio - 0.5) < 0.0001 ? 0.5001 : ratio, - ) as T extends null ? null : FixedPoint; + Math.abs(ratio - 0.5) < EPSILON ? 0.5001 : ratio, + ) as FixedPoint; } - return fixedPoint as any as T extends null ? null : FixedPoint; + + return fixedPoint; }; type Side = diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 5c81c657f3..b77f010bd8 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -155,7 +155,7 @@ const repairBinding = ( | ExcalidrawElbowArrowElement["startBinding"] | ExcalidrawElbowArrowElement["endBinding"] = { ...binding, - fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), + fixedPoint: normalizeFixedPoint(binding.fixedPoint), mode: binding.mode || "orbit", }; @@ -176,7 +176,7 @@ const repairBinding = ( return { elementId: binding.elementId, mode: binding.mode, - fixedPoint: normalizeFixedPoint(binding.fixedPoint || [0.5, 0.5]), + fixedPoint: normalizeFixedPoint(binding.fixedPoint), } as FixedPointBinding | null; } return null; @@ -185,15 +185,14 @@ const repairBinding = ( // binding schema v1 (legacy) -> attempt to migrate to v2 // --------------------------------------------------------------------------- - const targetBoundElement = - (targetElementsMap.get(binding.elementId) as ExcalidrawBindableElement) || - undefined; + const targetBoundElement = targetElementsMap.get(binding.elementId) as + | ExcalidrawBindableElement + | undefined; const boundElement = targetBoundElement || - (existingElementsMap?.get( - binding.elementId, - ) as ExcalidrawBindableElement) || - undefined; + (existingElementsMap?.get(binding.elementId) as + | ExcalidrawBindableElement + | undefined); const elementsMap = targetBoundElement ? targetElementsMap : existingElementsMap; @@ -208,11 +207,28 @@ const repairBinding = ( const mode = isPointInElement(p, boundElement, elementsMap) ? "inside" : "orbit"; + const safeElement = { + ...element, + startBinding: element.startBinding?.elementId + ? { + ...element.startBinding, + mode, + fixedPoint: normalizeFixedPoint(element.startBinding.fixedPoint), + } + : null, + endBinding: element.endBinding?.elementId + ? { + ...element.endBinding, + mode, + fixedPoint: normalizeFixedPoint(element.endBinding.fixedPoint), + } + : null, + }; const focusPoint = mode === "inside" ? p : projectFixedPointOntoDiagonal( - element, + safeElement, p, boundElement, startOrEnd, @@ -220,7 +236,7 @@ const repairBinding = ( { value: 1 as NormalizedZoomValue }, ) || p; const { fixedPoint } = calculateFixedPointForNonElbowArrowBinding( - element, + safeElement, boundElement, startOrEnd, elementsMap, From 0b3a5e7cc4d6ba5bcc5105f694a59f721ee06fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Tue, 24 Feb 2026 13:32:44 +0100 Subject: [PATCH 10/62] fix: Multi-point arrow bound point update (#10831) Signed-off-by: Mark Tolmacs --- packages/element/src/binding.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index ae84623c3b..bf1eeb63c7 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1776,10 +1776,13 @@ export const updateBoundPoint = ( ); const otherArrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( arrow, - startOrEnd === "startBinding" ? -1 : 0, + startOrEnd === "startBinding" ? 1 : -2, elementsMap, ); - const otherFocusPointOrArrowPoint = otherFocusPoint || otherArrowPoint; + const otherFocusPointOrArrowPoint = + arrow.points.length === 2 + ? otherFocusPoint || otherArrowPoint + : otherArrowPoint; const intersector = otherFocusPointOrArrowPoint && lineSegment(focusPoint, otherFocusPointOrArrowPoint); From 2874f9e48c3bf9e7c4a46015b4bb65b177f18b13 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:11:46 +0100 Subject: [PATCH 11/62] fix(editor): simplify and fix midpoint highlighting (#10832) --- .../components/canvases/InteractiveCanvas.tsx | 1 + .../excalidraw/renderer/interactiveScene.ts | 111 ++++++++++-------- packages/excalidraw/types.ts | 1 + 3 files changed, 62 insertions(+), 51 deletions(-) diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index a49dd51fbb..15ecb61eaf 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -265,6 +265,7 @@ const getRelevantAppStateProps = ( frameRendering: appState.frameRendering, shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, exportScale: appState.exportScale, + currentItemArrowType: appState.currentItemArrowType, }); const areEqual = ( diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index fd243eee3b..fc9331e6ea 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -7,6 +7,7 @@ import { type Radians, bezierEquation, pointRotateRads, + pointDistance, } from "@excalidraw/math"; import { @@ -37,14 +38,9 @@ import { isImageElement, isLinearElement, isLineElement, + maxBindingDistance_simple, isTextElement, LinearElementEditor, - headingForPoint, - compareHeading, - HEADING_RIGHT, - HEADING_DOWN, - HEADING_LEFT, - HEADING_UP, } from "@excalidraw/element"; import { renderSelectionElement } from "@excalidraw/element"; @@ -419,9 +415,8 @@ const renderBindingHighlightForBindableElement_simple = ( const arrow = linearElement?.elementId && LinearElementEditor.getElement(linearElement?.elementId, elementsMap); - const insideBindable = + const cursorIsInsideBindable = pointerCoords && - arrow && hitElementItself({ point: pointerCoords, element: suggestedBinding.element, @@ -430,14 +425,17 @@ const renderBindingHighlightForBindableElement_simple = ( overrideShouldTestInside: true, }); - if (!insideBindable || isElbowArrow(arrow)) { - context.save(); - context.translate(suggestedBinding.element.x, suggestedBinding.element.y); + const isElbow = + (arrow && isElbowArrow(arrow)) || + (appState.activeTool.type === "arrow" && + appState.currentItemArrowType === "elbow"); + + if (!cursorIsInsideBindable || isElbow) { + context.save(); - const midpointRadius = 5 / appState.zoom.value; const center = elementCenterPoint(suggestedBinding.element, elementsMap); - let midpoints: LocalPoint[]; + let midpoints: GlobalPoint[]; if (suggestedBinding.element.type === "diamond") { const center = elementCenterPoint( suggestedBinding.element, @@ -452,10 +450,7 @@ const renderBindingHighlightForBindableElement_simple = ( suggestedBinding.element.angle, ); - return pointFrom( - rotatedPoint[0] - suggestedBinding.element.x, - rotatedPoint[1] - suggestedBinding.element.y, - ); + return pointFrom(rotatedPoint[0], rotatedPoint[1]); }, ); } else { @@ -481,47 +476,53 @@ const renderBindingHighlightForBindableElement_simple = ( center, suggestedBinding.element.angle, ); - return pointFrom( - rotatedPoint[0] - suggestedBinding.element.x, - rotatedPoint[1] - suggestedBinding.element.y, - ); + return pointFrom(rotatedPoint[0], rotatedPoint[1]); }); } - const highlightedPoint = - suggestedBinding.midPoint && - pointFrom( - suggestedBinding.midPoint[0] - suggestedBinding.element.x, - suggestedBinding.midPoint[1] - suggestedBinding.element.y, + + const hoveredMidpoint = + pointerCoords && + midpoints.reduce( + ( + closestIdx: { + idx: number; + distance: number; + }, + point, + idx, + ) => { + const distance = pointDistance(point, pointerCoords); + if (idx === -1 || distance < closestIdx.distance) { + return { idx, distance }; + } + return closestIdx; + }, + { + idx: -1, + distance: Infinity, + }, ); - const target = [HEADING_RIGHT, HEADING_DOWN, HEADING_LEFT, HEADING_UP]; + const midpointRadius = 4 / appState.zoom.value; + const highlightThreshold = + maxBindingDistance_simple(appState.zoom) + + suggestedBinding.element.strokeWidth / 2; + midpoints.forEach((midpoint, idx) => { const isHighlighted = - highlightedPoint && - compareHeading( - headingForPoint( - pointRotateRads( - pointFrom( - highlightedPoint[0] + suggestedBinding.element.x, - highlightedPoint[1] + suggestedBinding.element.y, - ), - center, - suggestedBinding.element.angle as Radians, - ), - center, - ), - target[idx], - ); + (!cursorIsInsideBindable || isElbow) && + hoveredMidpoint?.idx === idx && + hoveredMidpoint.distance <= highlightThreshold; - if (!isHighlighted) { - context.fillStyle = - appState.theme === THEME.DARK - ? `rgba(0, 0, 0, 0.5)` - : `rgba(65, 65, 65, 0.4)`; - context.beginPath(); - context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI); - context.fill(); - } else { + // also render midpoint if cursor close but not highlighted + // (for elbows, always show all points) + const isShown = + !isHighlighted && + (isElbow || + (idx === hoveredMidpoint?.idx && + hoveredMidpoint.distance <= highlightThreshold * 2)); + + if (isHighlighted) { context.fillStyle = appState.theme === THEME.DARK ? `rgba(3, 93, 161, 1)` @@ -530,6 +531,14 @@ const renderBindingHighlightForBindableElement_simple = ( context.beginPath(); context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI); context.fill(); + } else if (isShown) { + context.fillStyle = + appState.theme === THEME.DARK + ? `rgba(0, 0, 0, 0.8)` + : `rgba(65, 65, 65, 0.5)`; + context.beginPath(); + context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI); + context.fill(); } }); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index f7e86cc524..02f70f62b4 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -244,6 +244,7 @@ export type InteractiveCanvasAppState = Readonly< frameRendering: AppState["frameRendering"]; shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"]; exportScale: AppState["exportScale"]; + currentItemArrowType: AppState["currentItemArrowType"]; } >; From cae9d2bcbd3e6c70c579a52574d91ea8898a80bb Mon Sep 17 00:00:00 2001 From: zsviczian Date: Thu, 26 Feb 2026 12:55:13 +0100 Subject: [PATCH 12/62] fix: "hand" tool active after exiting view mode if laser point was used (#10841) --- packages/excalidraw/components/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 59a11f804c..285421e357 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -4866,7 +4866,7 @@ class App extends React.Component { this.state.viewModeEnabled && event.key === KEYS.ESCAPE ) { - this.setActiveTool({ type: "hand" }); + this.setActiveTool({ type: "selection" }); return; } From 60b275880da7d1eb7dff3782e8d491188790597a Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:13:15 +0100 Subject: [PATCH 13/62] feat(editor): support radar chart and multiple series for other chart types (#10824) --- packages/common/src/colors.ts | 29 +- packages/element/src/types.ts | 2 +- packages/excalidraw/actions/actionCanvas.tsx | 1 - packages/excalidraw/appState.ts | 4 - packages/excalidraw/charts.test.ts | 1079 ++++++++++++++++- packages/excalidraw/charts.ts | 481 -------- packages/excalidraw/charts/charts.bar.ts | 103 ++ .../excalidraw/charts/charts.constants.ts | 63 + packages/excalidraw/charts/charts.helpers.ts | 865 +++++++++++++ packages/excalidraw/charts/charts.line.ts | 130 ++ packages/excalidraw/charts/charts.parse.ts | 174 +++ packages/excalidraw/charts/charts.radar.ts | 199 +++ packages/excalidraw/charts/charts.types.ts | 18 + packages/excalidraw/charts/index.ts | 38 + packages/excalidraw/clipboard.test.ts | 63 - packages/excalidraw/clipboard.ts | 28 - packages/excalidraw/components/App.tsx | 23 +- packages/excalidraw/components/LayerUI.tsx | 8 +- .../components/PasteChartDialog.scss | 90 +- .../components/PasteChartDialog.tsx | 199 ++- packages/excalidraw/index.tsx | 6 + packages/excalidraw/locales/en.json | 4 + .../tests/__snapshots__/charts.test.tsx.snap | 19 +- .../__snapshots__/contextmenu.test.tsx.snap | 85 -- .../tests/__snapshots__/history.test.tsx.snap | 315 ----- .../regressionTests.test.tsx.snap | 260 ---- packages/excalidraw/tests/charts.test.tsx | 151 +++ packages/excalidraw/types.ts | 14 +- .../tests/__snapshots__/export.test.ts.snap | 5 - 29 files changed, 3102 insertions(+), 1354 deletions(-) delete mode 100644 packages/excalidraw/charts.ts create mode 100644 packages/excalidraw/charts/charts.bar.ts create mode 100644 packages/excalidraw/charts/charts.constants.ts create mode 100644 packages/excalidraw/charts/charts.helpers.ts create mode 100644 packages/excalidraw/charts/charts.line.ts create mode 100644 packages/excalidraw/charts/charts.parse.ts create mode 100644 packages/excalidraw/charts/charts.radar.ts create mode 100644 packages/excalidraw/charts/charts.types.ts create mode 100644 packages/excalidraw/charts/index.ts diff --git a/packages/common/src/colors.ts b/packages/common/src/colors.ts index 763510646b..567093c7d9 100644 --- a/packages/common/src/colors.ts +++ b/packages/common/src/colors.ts @@ -240,22 +240,21 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = { // ----------------------------------------------------------------------------- // !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!! -export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) => - [ - // 2nd row - COLOR_PALETTE.cyan[index], - COLOR_PALETTE.blue[index], - COLOR_PALETTE.violet[index], - COLOR_PALETTE.grape[index], - COLOR_PALETTE.pink[index], +export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) => [ + // 2nd row + COLOR_PALETTE.cyan[index], + COLOR_PALETTE.blue[index], + COLOR_PALETTE.violet[index], + COLOR_PALETTE.grape[index], + COLOR_PALETTE.pink[index], - // 3rd row - COLOR_PALETTE.green[index], - COLOR_PALETTE.teal[index], - COLOR_PALETTE.yellow[index], - COLOR_PALETTE.orange[index], - COLOR_PALETTE.red[index], - ] as const; + // 3rd row + COLOR_PALETTE.green[index], + COLOR_PALETTE.teal[index], + COLOR_PALETTE.yellow[index], + COLOR_PALETTE.orange[index], + COLOR_PALETTE.red[index], +]; // ----------------------------------------------------------------------------- // other helpers diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts index 8067342a20..58e4469706 100644 --- a/packages/element/src/types.ts +++ b/packages/element/src/types.ts @@ -15,7 +15,7 @@ import type { ValueOf, } from "@excalidraw/common/utility-types"; -export type ChartType = "bar" | "line"; +export type ChartType = "bar" | "line" | "radar"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; export type FontFamilyKeys = keyof typeof FONT_FAMILY; export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys]; diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index f9c57a2851..d6986a17af 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -118,7 +118,6 @@ export const actionClearCanvas = register({ gridStep: appState.gridStep, gridModeEnabled: appState.gridModeEnabled, stats: appState.stats, - pasteDialog: appState.pasteDialog, activeTool: appState.activeTool.type === "image" ? { diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 087b1b795e..18e303db29 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -27,7 +27,6 @@ export const getDefaultAppState = (): Omit< showWelcomeScreen: false, theme: THEME.LIGHT, collaborators: new Map(), - currentChartType: "bar", currentItemBackgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor, currentItemEndArrowhead: "arrow", currentItemFillStyle: DEFAULT_ELEMENT_PROPS.fillStyle, @@ -83,7 +82,6 @@ export const getDefaultAppState = (): Omit< openPopup: null, openSidebar: null, openDialog: null, - pasteDialog: { shown: false, data: null }, previousSelectedElementIds: {}, resizingElement: null, scrolledOutside: false, @@ -150,7 +148,6 @@ const APP_STATE_STORAGE_CONF = (< showWelcomeScreen: { browser: true, export: false, server: false }, theme: { browser: true, export: false, server: false }, collaborators: { browser: false, export: false, server: false }, - currentChartType: { browser: true, export: false, server: false }, currentItemBackgroundColor: { browser: true, export: false, server: false }, currentItemEndArrowhead: { browser: true, export: false, server: false }, currentItemFillStyle: { browser: true, export: false, server: false }, @@ -212,7 +209,6 @@ const APP_STATE_STORAGE_CONF = (< openPopup: { browser: false, export: false, server: false }, openSidebar: { browser: true, export: false, server: false }, openDialog: { browser: false, export: false, server: false }, - pasteDialog: { browser: false, export: false, server: false }, previousSelectedElementIds: { browser: true, export: false, server: false }, resizingElement: { browser: false, export: false, server: false }, scrolledOutside: { browser: true, export: false, server: false }, diff --git a/packages/excalidraw/charts.test.ts b/packages/excalidraw/charts.test.ts index 94fa92fa0c..16e161ca40 100644 --- a/packages/excalidraw/charts.test.ts +++ b/packages/excalidraw/charts.test.ts @@ -1,8 +1,40 @@ -import { tryParseCells, tryParseNumber, VALID_SPREADSHEET } from "./charts"; +import { FONT_FAMILY } from "@excalidraw/common"; +import { + DEFAULT_CHART_COLOR_INDEX, + getAllColorsSpecificShade, +} from "@excalidraw/common"; + +import type { + ExcalidrawLineElement, + ExcalidrawTextElement, +} from "@excalidraw/element/types"; + +import { + isSpreadsheetValidForChartType, + renderSpreadsheet, + tryParseCells, + tryParseNumber, +} from "./charts"; import type { Spreadsheet } from "./charts"; describe("charts", () => { + const getRotatedBounds = (element: ExcalidrawTextElement) => { + const cos = Math.abs(Math.cos(element.angle)); + const sin = Math.abs(Math.sin(element.angle)); + const rotatedWidth = element.width * cos + element.height * sin; + const rotatedHeight = element.width * sin + element.height * cos; + const centerX = element.x + element.width / 2; + const centerY = element.y + element.height / 2; + return { + left: centerX - rotatedWidth / 2, + right: centerX + rotatedWidth / 2, + top: centerY - rotatedHeight / 2, + bottom: centerY + rotatedHeight / 2, + centerX, + }; + }; + describe("tryParseNumber", () => { it.each<[string, number]>([ ["1", 1], @@ -42,11 +74,11 @@ describe("charts", () => { const result = tryParseCells(spreadsheet); - expect(result.type).toBe(VALID_SPREADSHEET); + expect(result.ok).toBe(true); - const { title, labels, values } = ( - result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet } - ).spreadsheet; + const { title, labels, series } = ( + result as { ok: true; data: Spreadsheet } + ).data; expect(title).toEqual("value"); expect(labels).toEqual([ @@ -57,7 +89,9 @@ describe("charts", () => { "05:00", "06:00", ]); - expect(values).toEqual([61, -60, 85, -67, 54, 95]); + expect(series).toEqual([ + { title: "value", values: [61, -60, 85, -67, 54, 95] }, + ]); }); it("Uses the second column as the label if it is not a number", () => { @@ -73,11 +107,11 @@ describe("charts", () => { const result = tryParseCells(spreadsheet); - expect(result.type).toBe(VALID_SPREADSHEET); + expect(result.ok).toBe(true); - const { title, labels, values } = ( - result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet } - ).spreadsheet; + const { title, labels, series } = ( + result as { ok: true; data: Spreadsheet } + ).data; expect(title).toEqual("value"); expect(labels).toEqual([ @@ -88,7 +122,9 @@ describe("charts", () => { "05:00", "06:00", ]); - expect(values).toEqual([61, -60, 85, -67, 54, 95]); + expect(series).toEqual([ + { title: "value", values: [61, -60, 85, -67, 54, 95] }, + ]); }); it("treats the first column as labels if both columns are numbers", () => { @@ -104,15 +140,1026 @@ describe("charts", () => { const result = tryParseCells(spreadsheet); - expect(result.type).toBe(VALID_SPREADSHEET); + expect(result.ok).toBe(true); - const { title, labels, values } = ( - result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet } - ).spreadsheet; + const { title, labels, series } = ( + result as { ok: true; data: Spreadsheet } + ).data; expect(title).toEqual("value"); expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]); - expect(values).toEqual([61, -60, 85, -67, 54, 95]); + expect(series).toEqual([ + { title: "value", values: [61, -60, 85, -67, 54, 95] }, + ]); + }); + + it("parses multi-series cells for radar charts", () => { + const spreadsheet = [ + ["Metric", "Player A", "Player B", "Player C"], + ["Speed", "80", "60", "75"], + ["Strength", "65", "85", "70"], + ["Agility", "90", "70", "88"], + ["Intelligence", "70", "88", "92"], + ["Stamina", "85", "75", "80"], + ]; + + const result = tryParseCells(spreadsheet); + + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toEqual("Metric"); + expect(parsed.labels).toEqual([ + "Speed", + "Strength", + "Agility", + "Intelligence", + "Stamina", + ]); + expect(parsed.series).toEqual([ + { title: "Player A", values: [80, 65, 90, 70, 85] }, + { title: "Player B", values: [60, 85, 70, 88, 75] }, + { title: "Player C", values: [75, 70, 88, 92, 80] }, + ]); + }); + + it("treats first row as title+series headers only when all cells are non-numeric", () => { + const spreadsheet = [ + ["Trait", "10", "20"], + ["Physical Strength", "4", "8"], + ["Strategy", "6", "9"], + ["Charisma", "7", "5"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toBeNull(); + expect(parsed.labels?.[0]).toEqual("Trait"); + expect(parsed.series[0].title).toEqual("Series 1"); + expect(parsed.series[1].title).toEqual("Series 2"); + }); + + it("supports header row with series labels but no chart title", () => { + const spreadsheet = [ + ["", "Dunk", "Egg"], + ["Physical Strength", "10", "2"], + ["Swordsmanship", "8", "1"], + ["Political Instinct", "3", "9"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toBeNull(); + expect(parsed.labels).toEqual([ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + ]); + expect(parsed.series).toEqual([ + { title: "Dunk", values: [10, 8, 3] }, + { title: "Egg", values: [2, 1, 9] }, + ]); + }); + + it("parses 2-row multi-series data with header row", () => { + const spreadsheet = [ + ["trait", "Dunk", "Egg"], + ["Physical Strength", "10", "2"], + ["Swordsmanship skill", "8", "1"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toEqual("trait"); + expect(parsed.labels).toEqual([ + "Physical Strength", + "Swordsmanship skill", + ]); + expect(parsed.series).toEqual([ + { title: "Dunk", values: [10, 8] }, + { title: "Egg", values: [2, 1] }, + ]); + }); + + it("parses 2-row multi-series data without header and keeps first column as labels", () => { + const spreadsheet = [ + ["Physical Strength", "10", "2"], + ["Swordsmanship skill", "8", "1"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toBeNull(); + expect(parsed.labels).toEqual([ + "Physical Strength", + "Swordsmanship skill", + ]); + expect(parsed.series).toEqual([ + { title: "Series 1", values: [10, 8] }, + { title: "Series 2", values: [2, 1] }, + ]); + }); + + it("always interprets 2-column data as label in first column and numeric value in second", () => { + const spreadsheet = [ + ["10", "2"], + ["8", "Swordsmanship skill"], + ["6", "3"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result).toEqual({ + ok: false, + reason: "Value is not numeric", + }); + }); + }); + + describe("isSpreadsheetValidForChartType", () => { + it("rejects radar charts with only 2 dimensions", () => { + const spreadsheet: Spreadsheet = { + title: "trait", + labels: ["Physical Strength", "Swordsmanship skill"], + series: [ + { title: "Dunk", values: [10, 8] }, + { title: "Egg", values: [2, 1] }, + ], + }; + + expect(isSpreadsheetValidForChartType(spreadsheet, "radar")).toBe(false); + expect(isSpreadsheetValidForChartType(spreadsheet, "bar")).toBe(true); + expect(isSpreadsheetValidForChartType(spreadsheet, "line")).toBe(true); + }); + + it("accepts radar charts with 3 or more dimensions", () => { + const spreadsheet: Spreadsheet = { + title: "trait", + labels: [ + "Physical Strength", + "Swordsmanship skill", + "Political Instinct", + ], + series: [ + { title: "Dunk", values: [10, 8, 3] }, + { title: "Egg", values: [2, 1, 9] }, + ], + }; + + expect(isSpreadsheetValidForChartType(spreadsheet, "radar")).toBe(true); + }); + }); + + describe("renderSpreadsheet", () => { + it("renders grouped bars and legend for multi-series bar charts", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5] }, + { title: "Egg", values: [2, 1, 9, 8, 9] }, + { title: "Aerion", values: [7, 8, 7, 4, 5] }, + ], + }; + + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + const bars = elements!.filter( + (element) => + element.type === "rectangle" && + element.strokeWidth === 1 && + element.opacity === 100 && + !element.roundness, + ); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const axisLabels = textElements.filter((element) => + spreadsheet.labels?.includes(element.originalText || ""), + ); + const legendLabels = textElements.filter((element) => + spreadsheet.series.some( + (series) => series.title === element.originalText, + ), + ); + + const axisBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + const legendTopY = Math.min( + ...legendLabels.map((legendLabel) => legendLabel.y), + ); + + expect(bars).toHaveLength( + spreadsheet.series.length * spreadsheet.series[0].values.length, + ); + expect(legendLabels).toHaveLength(spreadsheet.series.length); + expect(legendTopY).toBeGreaterThan(axisBottomY + 2); + }); + + it("spreads grouped bar series colors across palette", () => { + const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "S1", values: [1, 2, 3, 4, 5] }, + { title: "S2", values: [2, 3, 4, 5, 1] }, + { title: "S3", values: [3, 4, 5, 1, 2] }, + { title: "S4", values: [4, 5, 1, 2, 3] }, + ], + }; + + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + randomSpy.mockRestore(); + + const bars = elements!.filter( + (element) => + element.type === "rectangle" && + element.strokeWidth === 1 && + element.opacity === 100 && + !element.roundness, + ); + const uniqueColors = Array.from( + new Set(bars.map((bar) => bar.backgroundColor)), + ); + const colorIndices = uniqueColors.map((color) => + palette.findIndex((paletteColor) => paletteColor === color), + ); + + expect(uniqueColors).toHaveLength(spreadsheet.series.length); + expect(colorIndices.every((index) => index >= 0)).toBe(true); + + const circularDistance = (first: number, second: number) => { + const absoluteDistance = Math.abs(first - second); + return Math.min(absoluteDistance, palette.length - absoluteDistance); + }; + const minDistance = Math.min( + ...colorIndices.flatMap((index, i) => + colorIndices + .slice(i + 1) + .map((other) => circularDistance(index, other)), + ), + ); + expect(minDistance).toBeGreaterThan(1); + }); + + it("renders grouped bars for parsed multi-series cells without header row", () => { + const cells = [ + ["Physical Strength", "10", "2", "7"], + ["Swordsmanship", "8", "1", "8"], + ["Political Instinct", "3", "9", "7"], + ["Book Knowledge", "2.5", "8", "4"], + ]; + const parsedResult = tryParseCells(cells); + expect(parsedResult.ok).toBe(true); + const parsedSpreadsheet = ( + parsedResult as { + ok: true; + data: Spreadsheet; + } + ).data; + + const elements = renderSpreadsheet("bar", parsedSpreadsheet, 0, 0); + const bars = elements!.filter( + (element) => + element.type === "rectangle" && + element.strokeWidth === 1 && + element.opacity === 100 && + !element.roundness, + ); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const legendLabels = textElements + .map((element) => element.originalText) + .filter((text): text is string => typeof text === "string"); + + expect(bars).toHaveLength( + parsedSpreadsheet.series[0].values.length * + parsedSpreadsheet.series.length, + ); + expect(legendLabels).toContain("Series 1"); + expect(legendLabels).toContain("Series 2"); + expect(legendLabels).toContain("Series 3"); + }); + + it("makes multi-series bar charts wider than single-series bar charts", () => { + const singleSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [{ title: "Trait", values: [10, 8, 3, 2.5] }], + }; + const multiSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5] }, + { title: "Egg", values: [2, 1, 9, 8] }, + { title: "Aerion", values: [7, 8, 7, 4] }, + ], + }; + + const singleElements = renderSpreadsheet("bar", singleSeries, 0, 0); + const multiElements = renderSpreadsheet("bar", multiSeries, 0, 0); + const getXAxisWidth = (elements: ReturnType) => + elements!.find( + (element): element is ExcalidrawLineElement => + element.type === "line" && + element.strokeStyle === "solid" && + element.points[0][1] === 0 && + element.points[1][1] === 0 && + element.points[1][0] > 0, + )?.width || 0; + + expect(getXAxisWidth(multiElements)).toBeGreaterThan( + getXAxisWidth(singleElements), + ); + }); + + it("makes multi-series line charts wider than single-series line charts", () => { + const singleSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [{ title: "Trait", values: [10, 8, 3, 2.5] }], + }; + const multiSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5] }, + { title: "Egg", values: [2, 1, 9, 8] }, + { title: "Aerion", values: [7, 8, 7, 4] }, + ], + }; + + const singleElements = renderSpreadsheet("line", singleSeries, 0, 0); + const multiElements = renderSpreadsheet("line", multiSeries, 0, 0); + const getXAxisWidth = (elements: ReturnType) => + elements!.find( + (element): element is ExcalidrawLineElement => + element.type === "line" && + element.strokeStyle === "solid" && + element.points[0][1] === 0 && + element.points[1][1] === 0 && + element.points[1][0] > 0, + )?.width || 0; + + expect(getXAxisWidth(multiElements)).toBeGreaterThan( + getXAxisWidth(singleElements), + ); + }); + + it("wraps grouped bar labels with spaces and still ellipsifies long single words", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Supercalifragilisticexpialidocious", + "Data Flow", + "Logic Layer", + ], + series: [ + { title: "Dunk", values: [8, 3, 2.5] }, + { title: "Egg", values: [1, 9, 8] }, + { title: "Aerion", values: [8, 7, 4] }, + ], + }; + + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + const longWordLabel = elements!.find( + (element): element is ExcalidrawTextElement => + element.type === "text" && + Math.abs(element.angle) > 0 && + element.text.includes("..."), + ); + const spacedLabels = elements!.filter( + (element): element is ExcalidrawTextElement => + element.type === "text" && + (element.originalText === "Data Flow" || + element.originalText === "Logic Layer"), + ); + + expect(longWordLabel).toBeDefined(); + expect(longWordLabel?.text).toContain("..."); + expect(longWordLabel?.originalText).toBe(longWordLabel?.text); + expect( + (longWordLabel?.text || "").replace("...", "").length, + ).toBeGreaterThan(0); + expect(spacedLabels.some((label) => label.text.includes("\n"))).toBe( + true, + ); + expect( + spacedLabels.every( + (label) => !!label.originalText && !label.originalText.includes("\n"), + ), + ).toBe(true); + }); + + it("keeps single-series bar x-axis labels below axis and avoids neighbor overlap", () => { + const spreadsheet: Spreadsheet = { + title: "Dunk", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "charisma", + "courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [{ title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }], + }; + + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + const axisLabels = elements!.filter( + (element): element is ExcalidrawTextElement => + element.type === "text" && Math.abs(element.angle) > 0, + ); + + expect(axisLabels).toHaveLength(spreadsheet.labels!.length); + + const bounds = axisLabels.map(getRotatedBounds); + for (const bound of bounds) { + expect(bound.top).toBeGreaterThan(0); + } + + const sortedBounds = bounds.sort( + (left, right) => left.centerX - right.centerX, + ); + for (let index = 1; index < sortedBounds.length; index++) { + expect(sortedBounds[index - 1].right).toBeLessThanOrEqual( + sortedBounds[index].left + 2, + ); + } + }); + + it("renders one line per series and one dot per data point for multi-series line charts", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { title: "Team A", values: [42150, 8300, 95400, 7820, 310500] }, + { title: "Team B", values: [63400, 3150, 51200, 4670, 125800] }, + ], + }; + + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + const seriesLines = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && element.strokeWidth === 2, + ); + const dots = elements!.filter( + (element) => element.type === "ellipse" && element.strokeWidth === 2, + ); + + expect(seriesLines).toHaveLength(spreadsheet.series.length); + expect(dots).toHaveLength( + spreadsheet.series.length * spreadsheet.series[0].values.length, + ); + }); + + it("spreads line series colors across palette to avoid similar adjacent colors", () => { + const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "S1", values: [1, 2, 3, 4, 5] }, + { title: "S2", values: [2, 3, 4, 5, 1] }, + { title: "S3", values: [3, 4, 5, 1, 2] }, + { title: "S4", values: [4, 5, 1, 2, 3] }, + ], + }; + + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + randomSpy.mockRestore(); + + const seriesLines = elements!.filter( + (element) => element.type === "line" && element.strokeWidth === 2, + ); + const colorIndices = seriesLines.map((line) => + palette.findIndex((color) => color === line.strokeColor), + ); + + expect(colorIndices.every((index) => index >= 0)).toBe(true); + + const circularDistance = (first: number, second: number) => { + const absoluteDistance = Math.abs(first - second); + return Math.min(absoluteDistance, palette.length - absoluteDistance); + }; + const minDistance = Math.min( + ...colorIndices.flatMap((index, i) => + colorIndices + .slice(i + 1) + .map((other) => circularDistance(index, other)), + ), + ); + + expect(minDistance).toBeGreaterThan(1); + }); + + it("uses colorSeed to deterministically pick chart colors", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [ + { title: "S1", values: [1, 2, 3, 4] }, + { title: "S2", values: [4, 3, 2, 1] }, + { title: "S3", values: [2, 3, 4, 1] }, + ], + }; + + const getSeriesLineColors = (seed: number) => { + const elements = renderSpreadsheet("line", spreadsheet, 0, 0, seed); + return elements! + .filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && element.strokeWidth === 2, + ) + .map((line) => line.strokeColor); + }; + + expect(getSeriesLineColors(0.125)).toEqual(getSeriesLineColors(0.125)); + expect(getSeriesLineColors(0.125)).not.toEqual( + getSeriesLineColors(0.875), + ); + }); + + it("renders multi-series line legend below axis labels with clearance", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { title: "Team A", values: [42150, 8300, 95400, 12600, 310500] }, + { title: "Team B", values: [63400, 3150, 51200, 9200, 125800] }, + ], + }; + + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const axisLabels = textElements.filter((element) => + spreadsheet.labels?.includes(element.originalText || ""), + ); + const legendLabels = textElements.filter((element) => + spreadsheet.series.some( + (series) => series.title === element.originalText, + ), + ); + + const axisBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + const legendTopY = Math.min( + ...legendLabels.map((legendLabel) => legendLabel.y), + ); + + expect(axisLabels.length).toBeGreaterThan(0); + expect(legendLabels.length).toBe(2); + expect(legendTopY).toBeGreaterThan(axisBottomY + 2); + }); + + it("keeps multi-series line x-axis labels below axis and avoids neighbor overlap", () => { + const spreadsheet: Spreadsheet = { + title: "trait", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "charisma", + "courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }, + { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] }, + ], + }; + + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + const axisLabels = elements!.filter( + (element): element is ExcalidrawTextElement => + element.type === "text" && Math.abs(element.angle) > 0, + ); + + expect(axisLabels).toHaveLength(spreadsheet.labels!.length); + + const bounds = axisLabels.map(getRotatedBounds); + for (const bound of bounds) { + expect(bound.top).toBeGreaterThan(0); + } + + const sortedBounds = bounds.sort( + (left, right) => left.centerX - right.centerX, + ); + for (let index = 1; index < sortedBounds.length; index++) { + expect(sortedBounds[index - 1].right).toBeLessThanOrEqual( + sortedBounds[index].left + 2, + ); + } + }); + + it("renders one closed polygon line per radar series", () => { + const spreadsheet: Spreadsheet = { + title: "Metric", + labels: ["Speed", "Strength", "Agility", "Intelligence", "Stamina"], + series: [ + { title: "Player A", values: [80, 65, 90, 70, 85] }, + { title: "Player B", values: [60, 85, 70, 88, 75] }, + { title: "Player C", values: [75, 70, 88, 92, 80] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const seriesPolygons = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + + expect(seriesPolygons).toHaveLength(3); + for (const polygon of seriesPolygons) { + expect(polygon.points[0]).toEqual( + polygon.points[polygon.points.length - 1], + ); + } + }); + + it("normalizes multi-series radar values with global scale", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { title: "Series 1", values: [40000, 8300, 95400, 7820, 5000000] }, + { title: "Series 2", values: [76000, 3150, 51200, 4670, 60000] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const seriesPolygons = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + + const series1 = seriesPolygons[0]; + const series2 = seriesPolygons[1]; + const getRadius = (point: readonly [number, number]) => + Math.hypot(point[0], point[1]); + + // On alpha axis, second series is about ~1.9x first series. + const alphaRatio = + getRadius(series2.points[0]!) / getRadius(series1.points[0]!); + expect(alphaRatio).toBeCloseTo(76000 / 40000, 1); + + // On epsilon axis, first series should dominate strongly. + const epsilonRatio = + getRadius(series1.points[4]!) / getRadius(series2.points[4]!); + expect(epsilonRatio).toBeGreaterThan(50); + }); + + // it("always renders radar step rings regardless of axis scale ratio", () => { + // const spreadsheet: Spreadsheet = { + // title: "Scores", + // labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + // series: [ + // { title: "Series 1", values: [40000, 8300, 95400, 7820, 5000000] }, + // { title: "Series 2", values: [76000, 3150, 51200, 4670, 60000] }, + // ], + // }; + + // const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + // const stepRings = elements!.filter( + // (element) => + // element.type === "line" && + // "polygon" in element && + // element.polygon && + // element.strokeStyle === "solid" && + // element.strokeWidth === 1, + // ); + + // expect(stepRings).toHaveLength(4); + // }); + + it("uses log normalization for highly skewed single-series radar data", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { + title: "Scores", + values: [40000, 8300, 95400, 7820, 5000000], + }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const seriesPolygons = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + + const polygon = seriesPolygons[0]; + const getRadius = (point: readonly [number, number]) => + Math.hypot(point[0], point[1]); + + const alphaRadius = getRadius(polygon.points[0]!); + const epsilonRadius = getRadius(polygon.points[4]!); + + // With linear scaling this would collapse near 0; log keeps it visible. + expect(alphaRadius).toBeGreaterThan(40); + expect(epsilonRadius).toBeGreaterThan(alphaRadius); + }); + + it("does not render 0/max value labels for radar charts", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { + title: "Scores", + values: [40000, 8300, 95400, 7820, 5000000], + }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + + expect(textElements.some((element) => element.text === "0")).toBe(false); + expect( + textElements.some( + (element) => + element.text === + Math.max(...spreadsheet.series[0].values).toLocaleString(), + ), + ).toBe(false); + }); + + it("wraps long radar axis labels instead of ellipsifying", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "Charisma", + "Courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }, + { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const wrappedAxisLabels = textElements.filter( + (element) => + element.text.includes("\n") && + element.text !== "Trait" && + element.text !== "Dunk" && + element.text !== "Egg", + ); + + expect(wrappedAxisLabels.length).toBeGreaterThan(0); + expect( + wrappedAxisLabels.every( + (element) => + typeof element.originalText === "string" && + !element.originalText.includes("\n"), + ), + ).toBe(true); + expect( + textElements.some( + (element) => element.text.includes("...") && element.text !== "Dunk", + ), + ).toBe(false); + expect( + textElements.some( + (element) => + element.originalText === "Stubbornness" && + !element.text.includes("\n") && + element.text === "Stubbornness", + ), + ).toBe(true); + expect( + textElements.some( + (element) => + element.originalText === "Physical Strength" && + element.text.includes("Physical\nStrength"), + ), + ).toBe(true); + + const topLabel = textElements.find( + (element) => element.originalText === "Physical Strength", + ); + const topSpokeY = Math.min( + ...elements! + .filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + !element.polygon && + element.strokeStyle === "solid" && + element.strokeWidth === 1, + ) + .map((element) => element.y + element.points[1][1]), + ); + expect(topLabel).toBeDefined(); + expect(topLabel!.y + topLabel!.height).toBeLessThan(topSpokeY - 2); + }); + + it("renders radar title and series legend labels in Lilita One", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["Physical Strength", "Swordsmanship", "Strategy", "Charisma"], + series: [ + { title: "Dunk", values: [10, 8, 5, 7] }, + { title: "Egg", values: [2, 1, 9, 8] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const title = textElements.find((element) => + element.text.includes("Trait"), + ); + const dunkLabel = textElements.find((element) => element.text === "Dunk"); + const eggLabel = textElements.find((element) => element.text === "Egg"); + + expect(title?.fontFamily).toBe(FONT_FAMILY["Lilita One"]); + expect(title?.originalText).toBe("Trait"); + expect(dunkLabel?.fontFamily).toBe(FONT_FAMILY["Lilita One"]); + expect(eggLabel?.fontFamily).toBe(FONT_FAMILY["Lilita One"]); + }); + + it("positions radar title with vertical clearance above axis labels", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "Charisma", + "Courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }, + { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const title = textElements.find( + (element) => element.fontFamily === FONT_FAMILY["Lilita One"], + ); + const axisLabels = textElements.filter( + (element) => + element.fontFamily === FONT_FAMILY.Excalifont && + element.text !== "Dunk" && + element.text !== "Egg", + ); + const topAxisLabelY = Math.min(...axisLabels.map((element) => element.y)); + + expect(title).toBeDefined(); + expect(title!.y + title!.height).toBeLessThan(topAxisLabelY - 4); + }); + + it("spreads radar series colors across palette to avoid similar adjacent colors", () => { + const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "S1", values: [1, 2, 3, 4, 5] }, + { title: "S2", values: [2, 3, 4, 5, 1] }, + { title: "S3", values: [3, 4, 5, 1, 2] }, + { title: "S4", values: [4, 5, 1, 2, 3] }, + ], + }; + + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + randomSpy.mockRestore(); + + const seriesPolygons = elements!.filter( + (element) => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + const colorIndices = seriesPolygons.map((polygon) => + palette.findIndex((color) => color === polygon.strokeColor), + ); + + expect(colorIndices.every((index) => index >= 0)).toBe(true); + + const circularDistance = (first: number, second: number) => { + const absoluteDistance = Math.abs(first - second); + return Math.min(absoluteDistance, palette.length - absoluteDistance); + }; + const minDistance = Math.min( + ...colorIndices.flatMap((index, i) => + colorIndices + .slice(i + 1) + .map((other) => circularDistance(index, other)), + ), + ); + + expect(minDistance).toBeGreaterThan(1); + }); + + it("positions series legend below the lowest axis label with clearance", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Psychological Warfare", + "Divine Favor", + "Confidence", + "Morale", + "Armor Protection long wrapped label from above", + "Accuracy", + "Agility", + "Weapon Reach", + ], + series: [ + { title: "David", values: [6, 7, 8, 9, 7, 8, 6, 9] }, + { title: "Goliath", values: [9, 3, 2, 6, 10, 2, 8, 1] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const axisLabels = textElements.filter((element) => + spreadsheet.labels?.includes(element.originalText), + ); + const legendLabels = textElements.filter((element) => + spreadsheet.series.some( + (series) => series.title === element.originalText, + ), + ); + + const axisBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + const legendTopY = Math.min( + ...legendLabels.map((legendLabel) => legendLabel.y), + ); + + expect(axisLabels.length).toBeGreaterThan(0); + expect(legendLabels.length).toBeGreaterThan(0); + expect(legendTopY).toBeGreaterThan(axisBottomY + 2); }); }); }); diff --git a/packages/excalidraw/charts.ts b/packages/excalidraw/charts.ts deleted file mode 100644 index 26f8802936..0000000000 --- a/packages/excalidraw/charts.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { pointFrom } from "@excalidraw/math"; - -import { - COLOR_PALETTE, - DEFAULT_CHART_COLOR_INDEX, - getAllColorsSpecificShade, - DEFAULT_FONT_FAMILY, - DEFAULT_FONT_SIZE, - VERTICAL_ALIGN, - randomId, - isDevEnv, - FONT_SIZES, -} from "@excalidraw/common"; - -import { - newTextElement, - newLinearElement, - newElement, -} from "@excalidraw/element"; - -import type { Radians } from "@excalidraw/math"; - -import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; - -export type ChartElements = readonly NonDeletedExcalidrawElement[]; - -const BAR_WIDTH = 32; -const BAR_GAP = 12; -const BAR_HEIGHT = 256; -const GRID_OPACITY = 50; - -export interface Spreadsheet { - title: string | null; - labels: string[] | null; - values: number[]; -} - -export const NOT_SPREADSHEET = "NOT_SPREADSHEET"; -export const VALID_SPREADSHEET = "VALID_SPREADSHEET"; - -type ParseSpreadsheetResult = - | { type: typeof NOT_SPREADSHEET; reason: string } - | { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }; - -/** - * @private exported for testing - */ -export const tryParseNumber = (s: string): number | null => { - const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s); - if (!match) { - return null; - } - return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, "")); -}; - -const isNumericColumn = (lines: string[][], columnIndex: number) => - lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null); - -/** - * @private exported for testing - */ -export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => { - const numCols = cells[0].length; - - if (numCols > 2) { - return { type: NOT_SPREADSHEET, reason: "More than 2 columns" }; - } - - if (numCols === 1) { - if (!isNumericColumn(cells, 0)) { - return { type: NOT_SPREADSHEET, reason: "Value is not numeric" }; - } - - const hasHeader = tryParseNumber(cells[0][0]) === null; - const values = (hasHeader ? cells.slice(1) : cells).map((line) => - tryParseNumber(line[0]), - ); - - if (values.length < 2) { - return { type: NOT_SPREADSHEET, reason: "Less than two rows" }; - } - - return { - type: VALID_SPREADSHEET, - spreadsheet: { - title: hasHeader ? cells[0][0] : null, - labels: null, - values: values as number[], - }, - }; - } - - const labelColumnNumeric = isNumericColumn(cells, 0); - const valueColumnNumeric = isNumericColumn(cells, 1); - - if (!labelColumnNumeric && !valueColumnNumeric) { - return { type: NOT_SPREADSHEET, reason: "Value is not numeric" }; - } - - const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric - ? [0, 1] - : [1, 0]; - const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null; - const rows = hasHeader ? cells.slice(1) : cells; - - if (rows.length < 2) { - return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" }; - } - - return { - type: VALID_SPREADSHEET, - spreadsheet: { - title: hasHeader ? cells[0][valueColumnIndex] : null, - labels: rows.map((row) => row[labelColumnIndex]), - values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!), - }, - }; -}; - -const transposeCells = (cells: string[][]) => { - const nextCells: string[][] = []; - for (let col = 0; col < cells[0].length; col++) { - const nextCellRow: string[] = []; - for (let row = 0; row < cells.length; row++) { - nextCellRow.push(cells[row][col]); - } - nextCells.push(nextCellRow); - } - return nextCells; -}; - -export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => { - // Copy/paste from excel, spreadsheets, tsv, csv. - // For now we only accept 2 columns with an optional header - - // Check for tab separated values - let lines = text - .trim() - .split("\n") - .map((line) => line.trim().split("\t")); - - // Check for comma separated files - if (lines.length && lines[0].length !== 2) { - lines = text - .trim() - .split("\n") - .map((line) => line.trim().split(",")); - } - - if (lines.length === 0) { - return { type: NOT_SPREADSHEET, reason: "No values" }; - } - - const numColsFirstLine = lines[0].length; - const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine); - - if (!isSpreadsheet) { - return { - type: NOT_SPREADSHEET, - reason: "All rows don't have same number of columns", - }; - } - - const result = tryParseCells(lines); - if (result.type !== VALID_SPREADSHEET) { - const transposedResults = tryParseCells(transposeCells(lines)); - if (transposedResults.type === VALID_SPREADSHEET) { - return transposedResults; - } - } - return result; -}; - -const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); - -// Put all the common properties here so when the whole chart is selected -// the properties dialog shows the correct selected values -const commonProps = { - fillStyle: "hachure", - fontFamily: DEFAULT_FONT_FAMILY, - fontSize: DEFAULT_FONT_SIZE, - opacity: 100, - roughness: 1, - strokeColor: COLOR_PALETTE.black, - roundness: null, - strokeStyle: "solid", - strokeWidth: 1, - verticalAlign: VERTICAL_ALIGN.MIDDLE, - locked: false, -} as const; - -const getChartDimensions = (spreadsheet: Spreadsheet) => { - const chartWidth = - (BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP; - const chartHeight = BAR_HEIGHT + BAR_GAP * 2; - return { chartWidth, chartHeight }; -}; - -const chartXLabels = ( - spreadsheet: Spreadsheet, - x: number, - y: number, - groupId: string, - backgroundColor: string, -): ChartElements => { - return ( - spreadsheet.labels?.map((label, index) => { - return newTextElement({ - groupIds: [groupId], - backgroundColor, - ...commonProps, - text: label.length > 8 ? `${label.slice(0, 5)}...` : label, - x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2, - y: y + BAR_GAP / 2, - width: BAR_WIDTH, - angle: 5.87 as Radians, - fontSize: FONT_SIZES.sm, - textAlign: "center", - verticalAlign: "top", - }); - }) || [] - ); -}; - -const chartYLabels = ( - spreadsheet: Spreadsheet, - x: number, - y: number, - groupId: string, - backgroundColor: string, -): ChartElements => { - const minYLabel = newTextElement({ - groupIds: [groupId], - backgroundColor, - ...commonProps, - x: x - BAR_GAP, - y: y - BAR_GAP, - text: "0", - textAlign: "right", - }); - - const maxYLabel = newTextElement({ - groupIds: [groupId], - backgroundColor, - ...commonProps, - x: x - BAR_GAP, - y: y - BAR_HEIGHT - minYLabel.height / 2, - text: Math.max(...spreadsheet.values).toLocaleString(), - textAlign: "right", - }); - - return [minYLabel, maxYLabel]; -}; - -const chartLines = ( - spreadsheet: Spreadsheet, - x: number, - y: number, - groupId: string, - backgroundColor: string, -): ChartElements => { - const { chartWidth, chartHeight } = getChartDimensions(spreadsheet); - const xLine = newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x, - y, - width: chartWidth, - points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], - }); - - const yLine = newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x, - y, - height: chartHeight, - points: [pointFrom(0, 0), pointFrom(0, -chartHeight)], - }); - - const maxLine = newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x, - y: y - BAR_HEIGHT - BAR_GAP, - strokeStyle: "dotted", - width: chartWidth, - opacity: GRID_OPACITY, - points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], - }); - - return [xLine, yLine, maxLine]; -}; - -// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g -const chartBaseElements = ( - spreadsheet: Spreadsheet, - x: number, - y: number, - groupId: string, - backgroundColor: string, - debug?: boolean, -): ChartElements => { - const { chartWidth, chartHeight } = getChartDimensions(spreadsheet); - - const title = spreadsheet.title - ? newTextElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - text: spreadsheet.title, - x: x + chartWidth / 2, - y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE, - roundness: null, - textAlign: "center", - }) - : null; - - const debugRect = debug - ? newElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "rectangle", - x, - y: y - chartHeight, - width: chartWidth, - height: chartHeight, - strokeColor: COLOR_PALETTE.black, - fillStyle: "solid", - opacity: 6, - }) - : null; - - return [ - ...(debugRect ? [debugRect] : []), - ...(title ? [title] : []), - ...chartXLabels(spreadsheet, x, y, groupId, backgroundColor), - ...chartYLabels(spreadsheet, x, y, groupId, backgroundColor), - ...chartLines(spreadsheet, x, y, groupId, backgroundColor), - ]; -}; - -const chartTypeBar = ( - spreadsheet: Spreadsheet, - x: number, - y: number, -): ChartElements => { - const max = Math.max(...spreadsheet.values); - const groupId = randomId(); - const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)]; - - const bars = spreadsheet.values.map((value, index) => { - const barHeight = (value / max) * BAR_HEIGHT; - return newElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "rectangle", - x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP, - y: y - barHeight - BAR_GAP, - width: BAR_WIDTH, - height: barHeight, - }); - }); - - return [ - ...bars, - ...chartBaseElements( - spreadsheet, - x, - y, - groupId, - backgroundColor, - isDevEnv(), - ), - ]; -}; - -const chartTypeLine = ( - spreadsheet: Spreadsheet, - x: number, - y: number, -): ChartElements => { - const max = Math.max(...spreadsheet.values); - const groupId = randomId(); - const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)]; - - let index = 0; - const points = []; - for (const value of spreadsheet.values) { - const cx = index * (BAR_WIDTH + BAR_GAP); - const cy = -(value / max) * BAR_HEIGHT; - points.push([cx, cy]); - index++; - } - - const maxX = Math.max(...points.map((element) => element[0])); - const maxY = Math.max(...points.map((element) => element[1])); - const minX = Math.min(...points.map((element) => element[0])); - const minY = Math.min(...points.map((element) => element[1])); - - const line = newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x: x + BAR_GAP + BAR_WIDTH / 2, - y: y - BAR_GAP, - height: maxY - minY, - width: maxX - minX, - strokeWidth: 2, - points: points as any, - }); - - const dots = spreadsheet.values.map((value, index) => { - const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2; - const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2; - return newElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - fillStyle: "solid", - strokeWidth: 2, - type: "ellipse", - x: x + cx + BAR_WIDTH / 2, - y: y + cy - BAR_GAP * 2, - width: BAR_GAP, - height: BAR_GAP, - }); - }); - - const lines = spreadsheet.values.map((value, index) => { - const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2; - const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP; - return newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2, - y: y - cy, - height: cy, - strokeStyle: "dotted", - opacity: GRID_OPACITY, - points: [pointFrom(0, 0), pointFrom(0, cy)], - }); - }); - - return [ - ...chartBaseElements( - spreadsheet, - x, - y, - groupId, - backgroundColor, - isDevEnv(), - ), - line, - ...lines, - ...dots, - ]; -}; - -export const renderSpreadsheet = ( - chartType: string, - spreadsheet: Spreadsheet, - x: number, - y: number, -): ChartElements => { - if (chartType === "line") { - return chartTypeLine(spreadsheet, x, y); - } - return chartTypeBar(spreadsheet, x, y); -}; diff --git a/packages/excalidraw/charts/charts.bar.ts b/packages/excalidraw/charts/charts.bar.ts new file mode 100644 index 0000000000..b1a7759606 --- /dev/null +++ b/packages/excalidraw/charts/charts.bar.ts @@ -0,0 +1,103 @@ +import { isDevEnv } from "@excalidraw/common"; + +import { newElement } from "@excalidraw/element"; + +import { commonProps } from "./charts.constants"; +import { + chartBaseElements, + chartXLabels, + createSeriesLegend, + getBackgroundColor, + getCartesianChartLayout, + getChartDimensions, + getColorOffset, + getRotatedTextElementBottom, + getSeriesColors, +} from "./charts.helpers"; + +import type { ChartElements, Spreadsheet } from "./charts.types"; + +export const renderBarChart = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + colorSeed?: number, +): ChartElements => { + const series = spreadsheet.series; + const layout = getCartesianChartLayout("bar", series.length); + const max = Math.max( + 1, + ...series.flatMap((seriesData) => + seriesData.values.map((value) => Math.max(0, value)), + ), + ); + const colorOffset = getColorOffset(colorSeed); + const backgroundColor = getBackgroundColor(colorOffset); + const seriesColors = getSeriesColors(series.length, colorOffset); + const interBarGap = + series.length > 1 + ? Math.max(1, Math.floor(layout.gap / (series.length + 1))) + : 0; + const barWidth = + series.length > 1 + ? Math.max( + 2, + (layout.slotWidth - interBarGap * (series.length - 1)) / + series.length, + ) + : layout.slotWidth; + const clusterWidth = + series.length * barWidth + interBarGap * (series.length - 1); + const clusterOffset = (layout.slotWidth - clusterWidth) / 2; + + const bars = series[0].values.flatMap((_, categoryIndex) => + series.map((seriesData, seriesIndex) => { + const value = Math.max(0, seriesData.values[categoryIndex] ?? 0); + const barHeight = (value / max) * layout.chartHeight; + const barColor = + series.length > 1 ? seriesColors[seriesIndex] : backgroundColor; + return newElement({ + backgroundColor: barColor, + ...commonProps, + type: "rectangle", + fillStyle: series.length > 1 ? "solid" : commonProps.fillStyle, + strokeColor: series.length > 1 ? barColor : commonProps.strokeColor, + x: + x + + categoryIndex * (layout.slotWidth + layout.gap) + + layout.gap + + clusterOffset + + seriesIndex * (barWidth + interBarGap), + y: y - barHeight - layout.gap, + width: barWidth, + height: barHeight, + }); + }), + ); + + const baseElements = chartBaseElements( + spreadsheet, + x, + y, + backgroundColor, + layout, + max, + isDevEnv(), + ); + const xLabels = chartXLabels(spreadsheet, x, y, backgroundColor, layout); + const xLabelsBottomY = Math.max( + y + layout.gap / 2, + ...xLabels.map((label) => getRotatedTextElementBottom(label)), + ); + const { chartWidth } = getChartDimensions(spreadsheet, layout); + const seriesLegend = createSeriesLegend( + series, + seriesColors, + x + chartWidth / 2, + xLabelsBottomY, + y + layout.gap * 5, + backgroundColor, + ); + + return [...baseElements, ...bars, ...seriesLegend]; +}; diff --git a/packages/excalidraw/charts/charts.constants.ts b/packages/excalidraw/charts/charts.constants.ts new file mode 100644 index 0000000000..4cb23da11b --- /dev/null +++ b/packages/excalidraw/charts/charts.constants.ts @@ -0,0 +1,63 @@ +import { + COLOR_PALETTE, + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + VERTICAL_ALIGN, +} from "@excalidraw/common"; + +import type { Radians } from "@excalidraw/math"; + +export const CARTESIAN_BASE_SLOT_WIDTH = 44; +export const CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES = 22; +export const CARTESIAN_BAR_SLOT_EXTRA_MAX = 66; +export const CARTESIAN_LINE_SLOT_WIDTH = 48; +export const CARTESIAN_GAP = 14; +export const CARTESIAN_BAR_HEIGHT = 304; +export const CARTESIAN_LINE_HEIGHT = 320; +export const CARTESIAN_LABEL_ROTATION = 5.87 as Radians; +export const CARTESIAN_LABEL_MIN_WIDTH = 28; +export const CARTESIAN_LABEL_SLOT_PADDING = 4; +export const CARTESIAN_LABEL_AXIS_CLEARANCE = 2; +export const CARTESIAN_LABEL_MAX_WIDTH_BUFFER = 10; +export const CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER = 10; +export const CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER = 8; + +export const BAR_GAP = 12; +export const BAR_HEIGHT = 256; +export const GRID_OPACITY = 10; + +export const RADAR_GRID_LEVELS = 4; +export const RADAR_LABEL_OFFSET = BAR_GAP * 2; +export const RADAR_PADDING = BAR_GAP * 2; +export const RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD = 100; +export const RADAR_AXIS_LABEL_MAX_WIDTH = 140; +export const RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD = 0.35; +export const RADAR_AXIS_LABEL_CLEARANCE = BAR_GAP / 2; +export const RADAR_LEGEND_SWATCH_SIZE = 20; +export const RADAR_LEGEND_ITEM_GAP = BAR_GAP * 2; +export const RADAR_LEGEND_TEXT_GAP = BAR_GAP; + +// Put all common chart element properties here so properties dialog +// shows stable values when selecting chart groups. +export const commonProps = { + fillStyle: "hachure", + fontFamily: DEFAULT_FONT_FAMILY, + fontSize: DEFAULT_FONT_SIZE, + opacity: 100, + roughness: 1, + strokeColor: COLOR_PALETTE.black, + roundness: null, + strokeStyle: "solid", + strokeWidth: 1, + verticalAlign: VERTICAL_ALIGN.MIDDLE, + locked: false, +} as const; + +export type CartesianChartType = "bar" | "line"; + +export type CartesianChartLayout = { + slotWidth: number; + gap: number; + chartHeight: number; + xLabelMaxWidth: number; +}; diff --git a/packages/excalidraw/charts/charts.helpers.ts b/packages/excalidraw/charts/charts.helpers.ts new file mode 100644 index 0000000000..18097b1df9 --- /dev/null +++ b/packages/excalidraw/charts/charts.helpers.ts @@ -0,0 +1,865 @@ +import { pointFrom } from "@excalidraw/math"; + +import { + COLOR_PALETTE, + DEFAULT_CHART_COLOR_INDEX, + FONT_FAMILY, + FONT_SIZES, + ROUNDNESS, + DEFAULT_FONT_SIZE, + getAllColorsSpecificShade, + getFontString, + getLineHeight, + ROUGHNESS, +} from "@excalidraw/common"; + +import { + getApproxMinLineWidth, + measureText, + newElement, + newLinearElement, + newTextElement, + wrapText, +} from "@excalidraw/element"; + +import type { + ChartType, + ExcalidrawTextElement, +} from "@excalidraw/element/types"; +import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; + +import { + BAR_GAP, + CARTESIAN_BAR_HEIGHT, + CARTESIAN_BASE_SLOT_WIDTH, + CARTESIAN_BAR_SLOT_EXTRA_MAX, + CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES, + CARTESIAN_GAP, + CARTESIAN_LABEL_AXIS_CLEARANCE, + CARTESIAN_LABEL_MAX_WIDTH_BUFFER, + CARTESIAN_LABEL_MIN_WIDTH, + CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER, + CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER, + CARTESIAN_LABEL_ROTATION, + CARTESIAN_LABEL_SLOT_PADDING, + CARTESIAN_LINE_HEIGHT, + CARTESIAN_LINE_SLOT_WIDTH, + GRID_OPACITY, + RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD, + RADAR_AXIS_LABEL_CLEARANCE, + RADAR_AXIS_LABEL_MAX_WIDTH, + RADAR_LABEL_OFFSET, + RADAR_LEGEND_ITEM_GAP, + RADAR_LEGEND_SWATCH_SIZE, + RADAR_LEGEND_TEXT_GAP, + RADAR_PADDING, + RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD, + BAR_HEIGHT, + commonProps, + type CartesianChartLayout, + type CartesianChartType, +} from "./charts.constants"; + +import type { + ChartElements, + Spreadsheet, + SpreadsheetSeries, +} from "./charts.types"; + +const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + +const getSpreadsheetDimensionCount = (spreadsheet: Spreadsheet) => + spreadsheet.labels?.length ?? spreadsheet.series[0]?.values.length ?? 0; + +export const isSpreadsheetValidForChartType = ( + spreadsheet: Spreadsheet | null, + chartType: ChartType, +) => { + if (!spreadsheet) { + return false; + } + + const dimensionCount = getSpreadsheetDimensionCount(spreadsheet); + if (dimensionCount < 2) { + return false; + } + + if (chartType === "radar") { + return dimensionCount >= 3; + } + + return true; +}; + +const getSeriesAwareSlotWidth = ( + baseSlotWidth: number, + seriesCount: number, +) => { + const extraSlotWidth = + seriesCount <= 1 + ? 0 + : Math.min( + CARTESIAN_BAR_SLOT_EXTRA_MAX, + (seriesCount - 1) * CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES, + ); + return baseSlotWidth + extraSlotWidth; +}; + +export const getCartesianChartLayout = ( + chartType: CartesianChartType, + seriesCount: number, +): CartesianChartLayout => { + if (chartType === "line") { + const slotWidth = getSeriesAwareSlotWidth( + CARTESIAN_LINE_SLOT_WIDTH, + seriesCount, + ); + return { + slotWidth, + gap: CARTESIAN_GAP, + chartHeight: CARTESIAN_LINE_HEIGHT, + xLabelMaxWidth: + slotWidth + CARTESIAN_GAP * 3 + CARTESIAN_LABEL_MAX_WIDTH_BUFFER, + }; + } + + const slotWidth = getSeriesAwareSlotWidth( + CARTESIAN_BASE_SLOT_WIDTH, + seriesCount, + ); + return { + slotWidth, + gap: CARTESIAN_GAP, + chartHeight: CARTESIAN_BAR_HEIGHT, + xLabelMaxWidth: + slotWidth + CARTESIAN_GAP * 3 + CARTESIAN_LABEL_MAX_WIDTH_BUFFER, + }; +}; + +export const getChartDimensions = ( + spreadsheet: Spreadsheet, + layout: CartesianChartLayout, +) => { + const chartWidth = + (layout.slotWidth + layout.gap) * spreadsheet.series[0].values.length + + layout.gap; + const chartHeight = layout.chartHeight + layout.gap * 2; + return { chartWidth, chartHeight }; +}; + +export const getRadarDimensions = () => { + const chartWidth = BAR_HEIGHT + RADAR_PADDING * 2; + const chartHeight = BAR_HEIGHT + RADAR_PADDING * 2; + return { chartWidth, chartHeight }; +}; + +const getCircularDistance = ( + firstIndex: number, + secondIndex: number, + paletteSize: number, +) => { + const absoluteDistance = Math.abs(firstIndex - secondIndex); + return Math.min(absoluteDistance, paletteSize - absoluteDistance); +}; + +export const getSeriesColors = ( + seriesCount: number, + colorOffset: number, +): readonly string[] => { + if (seriesCount <= 0 || bgColors.length === 0) { + return []; + } + + const paletteSize = bgColors.length; + const startIndex = ((colorOffset % paletteSize) + paletteSize) % paletteSize; + const selectedIndices = [startIndex]; + const maxUniqueColors = Math.min(seriesCount, paletteSize); + const availableIndices = new Set( + Array.from({ length: paletteSize }, (_, index) => index).filter( + (index) => index !== startIndex, + ), + ); + + while (selectedIndices.length < maxUniqueColors) { + let bestIndex = -1; + let bestMinDistance = -1; + let bestAverageDistance = -1; + + for (const candidateIndex of availableIndices) { + const distances = selectedIndices.map((selectedIndex) => + getCircularDistance(candidateIndex, selectedIndex, paletteSize), + ); + const minDistance = Math.min(...distances); + const averageDistance = + distances.reduce((total, distance) => total + distance, 0) / + distances.length; + + if ( + minDistance > bestMinDistance || + (minDistance === bestMinDistance && + averageDistance > bestAverageDistance) + ) { + bestIndex = candidateIndex; + bestMinDistance = minDistance; + bestAverageDistance = averageDistance; + } + } + + selectedIndices.push(bestIndex); + availableIndices.delete(bestIndex); + } + + return Array.from( + { length: seriesCount }, + (_, index) => bgColors[selectedIndices[index % selectedIndices.length]], + ); +}; + +export const getColorOffset = (colorSeed?: number) => { + if (bgColors.length === 0) { + return 0; + } + + if (typeof colorSeed !== "number" || !Number.isFinite(colorSeed)) { + return Math.floor(Math.random() * bgColors.length); + } + + const seedText = colorSeed.toString(); + let hash = 0; + for (let index = 0; index < seedText.length; index++) { + hash = (hash * 31 + seedText.charCodeAt(index)) | 0; + } + return Math.abs(hash) % bgColors.length; +}; + +export const getBackgroundColor = (colorOffset: number) => + bgColors[colorOffset]; + +export const getRadarValueScale = ( + series: SpreadsheetSeries[], + _labelsLength: number, +) => { + const allValues = series.flatMap((s) => + s.values.map((value) => Math.max(0, value)), + ); + const positiveValues = allValues.filter((value) => value > 0); + const max = Math.max(1, ...allValues); + const minPositive = + positiveValues.length > 0 ? Math.min(...positiveValues) : 1; + const useLogScale = + series.length === 1 && + minPositive > 0 && + max / minPositive >= RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD; + + return { + renderSteps: false, + normalize: (value: number, _axisIndex: number) => { + const safeValue = Math.max(0, value); + return useLogScale + ? Math.log10(safeValue + 1) / Math.log10(max + 1) + : safeValue / max; + }, + }; +}; + +const shouldWrapRadarText = (text: string) => /\s/.test(text.trim()); + +export const getRadarDisplayText = ( + text: string, + fontString: ReturnType, + maxWidth: number, +) => { + return shouldWrapRadarText(text) + ? wrapText(text, fontString, maxWidth) + : text; +}; + +export const createRadarAxisLabels = ( + labels: readonly string[], + angles: readonly number[], + centerX: number, + centerY: number, + radius: number, + backgroundColor: string, +): { + axisLabels: ChartElements; + axisLabelTopY: number; + axisLabelBottomY: number; +} => { + const fontFamily = FONT_FAMILY.Excalifont; + const fontSize = FONT_SIZES.sm; + const lineHeight = getLineHeight(fontFamily); + const fontString = getFontString({ fontFamily, fontSize }); + const baseLabelWidth = Math.min( + RADAR_AXIS_LABEL_MAX_WIDTH, + radius * (labels.length > 8 ? 0.56 : 0.72), + ); + const minLabelWidth = getApproxMinLineWidth(fontString, lineHeight); + + const axisLabels = labels.map((label, index) => { + const angle = angles[index]; + const longestWordWidth = Math.max( + 0, + ...label + .trim() + .split(/\s+/) + .filter(Boolean) + .map((word) => measureText(word, fontString, lineHeight).width), + ); + const maxLabelWidth = Math.max( + minLabelWidth, + baseLabelWidth, + longestWordWidth, + ); + const displayLabel = getRadarDisplayText(label, fontString, maxLabelWidth); + const metrics = measureText(displayLabel, fontString, lineHeight); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + const textAlign: "left" | "center" | "right" = + cos > RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD + ? "left" + : cos < -RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD + ? "right" + : "center"; + + // Keep labels outside the radar ring by projecting text extents + // onto the axis direction. + const centerAlignedXExtent = textAlign === "center" ? metrics.width / 2 : 0; + const projectedExtent = + Math.abs(cos) * centerAlignedXExtent + + Math.abs(sin) * (metrics.height / 2); + const radialOffset = + RADAR_LABEL_OFFSET + projectedExtent + RADAR_AXIS_LABEL_CLEARANCE; + const anchorX = centerX + cos * (radius + radialOffset); + const anchorY = centerY + sin * (radius + radialOffset); + + const yNudge = + sin > RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD + ? BAR_GAP / 3 + : sin < -RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD + ? -BAR_GAP / 3 + : 0; + + return newTextElement({ + backgroundColor, + ...commonProps, + text: displayLabel, + originalText: label, + x: anchorX, + y: anchorY + yNudge, + fontFamily, + fontSize, + lineHeight, + textAlign, + verticalAlign: "middle", + }); + }); + + const axisLabelTopY = Math.min(...axisLabels.map((axisLabel) => axisLabel.y)); + const axisLabelBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + return { axisLabels, axisLabelTopY, axisLabelBottomY }; +}; + +export const createSeriesLegend = ( + series: SpreadsheetSeries[], + seriesColors: readonly string[], + centerX: number, + minLegendTopY: number, + fallbackLegendY: number, + backgroundColor: string, +): ChartElements => { + if (series.length <= 1) { + return []; + } + + const fontFamily = FONT_FAMILY["Lilita One"]; + const fontSize = FONT_SIZES.lg; + const lineHeight = getLineHeight(fontFamily); + const fontString = getFontString({ fontFamily, fontSize }); + const legendItems = series.map((seriesItem, index) => { + const label = seriesItem.title?.trim() || `Series ${index + 1}`; + const displayLabel = getRadarDisplayText(label, fontString, BAR_HEIGHT); + const metrics = measureText(displayLabel, fontString, lineHeight); + const itemWidth = + RADAR_LEGEND_SWATCH_SIZE + RADAR_LEGEND_TEXT_GAP + metrics.width; + return { + label, + displayLabel, + color: seriesColors[index], + width: itemWidth, + height: metrics.height, + }; + }); + const maxLegendHalfHeight = Math.max( + RADAR_LEGEND_SWATCH_SIZE / 2, + ...legendItems.map((item) => item.height / 2), + ); + const legendY = Math.max( + fallbackLegendY, + minLegendTopY + maxLegendHalfHeight + RADAR_LABEL_OFFSET, + ); + + const pillPaddingX = RADAR_LEGEND_ITEM_GAP; + const pillPaddingY = RADAR_LEGEND_SWATCH_SIZE * 0.6; + const totalLegendWidth = + legendItems.reduce((total, item) => total + item.width, 0) + + RADAR_LEGEND_ITEM_GAP * Math.max(0, legendItems.length - 1); + const pillWidth = totalLegendWidth + pillPaddingX * 2; + const pillHeight = maxLegendHalfHeight * 2 + pillPaddingY * 2; + + const legendElements: NonDeletedExcalidrawElement[] = []; + + // rounded pill background + legendElements.push( + newElement({ + ...commonProps, + backgroundColor: "transparent", + type: "rectangle", + fillStyle: "solid", + strokeColor: COLOR_PALETTE.black, + x: centerX - pillWidth / 2, + y: legendY - pillHeight / 2, + width: pillWidth, + height: pillHeight, + roughness: ROUGHNESS.architect, + roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, + }), + ); + + let cursorX = centerX - totalLegendWidth / 2; + + legendItems.forEach((item) => { + // solid filled swatch + legendElements.push( + newElement({ + ...commonProps, + backgroundColor: item.color, + type: "rectangle", + x: cursorX, + y: legendY - RADAR_LEGEND_SWATCH_SIZE / 2, + width: RADAR_LEGEND_SWATCH_SIZE, + height: RADAR_LEGEND_SWATCH_SIZE, + fillStyle: "solid", + strokeColor: item.color, + roughness: ROUGHNESS.architect, + roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, + }), + ); + + // label in default (black) color + legendElements.push( + newTextElement({ + ...commonProps, + text: item.displayLabel, + originalText: item.label, + autoResize: false, + x: cursorX + RADAR_LEGEND_SWATCH_SIZE + RADAR_LEGEND_TEXT_GAP, + y: legendY, + fontFamily, + fontSize, + lineHeight, + textAlign: "left", + verticalAlign: "middle", + }), + ); + + cursorX += item.width + RADAR_LEGEND_ITEM_GAP; + }); + + return legendElements; +}; + +const ellipsifyTextToWidth = ( + text: string, + maxWidth: number, + fontString: ReturnType, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + if (measureText(text, fontString, lineHeight).width <= maxWidth) { + return text; + } + + let end = text.length; + while (end > 1) { + const candidate = `${text.slice(0, end)}...`; + if (measureText(candidate, fontString, lineHeight).width <= maxWidth) { + return candidate; + } + end--; + } + + return text[0] ? `${text[0]}...` : text; +}; + +const wrapOrEllipsifyTextToWidth = ( + text: string, + maxWidth: number, + fontString: ReturnType, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + if (measureText(text, fontString, lineHeight).width <= maxWidth) { + return { wrapped: false, text }; + } + + const words = text.trim().split(/\s+/).filter(Boolean); + if (words.length > 1) { + const hasLongWord = words.some((word) => { + return measureText(word, fontString, lineHeight).width > maxWidth; + }); + if ( + !hasLongWord && + maxWidth >= getApproxMinLineWidth(fontString, lineHeight) + ) { + return { wrapped: true, text: wrapText(text, fontString, maxWidth) }; + } + } + + return { + wrapped: false, + text: ellipsifyTextToWidth(text, maxWidth, fontString, lineHeight), + }; +}; + +const getRotatedBoundingBox = ( + width: number, + height: number, + angle: number, +) => { + const cos = Math.abs(Math.cos(angle)); + const sin = Math.abs(Math.sin(angle)); + return { + width: width * cos + height * sin, + height: width * sin + height * cos, + }; +}; + +type CartesianAxisLabelSpec = { + originalText: string; + text: string; + wrapped: boolean; + metrics: ReturnType; + rotatedWidth: number; + rotatedHeight: number; +}; + +const isEllipsifiedLabel = (text: string) => text.includes("..."); + +const getCartesianAxisLabelSpec = ( + label: string, + maxLabelWidth: number, + maxRotatedWidth: number, + fontString: ReturnType, + lineHeight: ExcalidrawTextElement["lineHeight"], +): CartesianAxisLabelSpec => { + const minWidth = Math.max( + CARTESIAN_LABEL_MIN_WIDTH, + Math.ceil(getApproxMinLineWidth(fontString, lineHeight)), + ); + const maxWidth = Math.max(minWidth, Math.floor(maxLabelWidth)); + const candidateWidths: number[] = []; + for (let width = maxWidth; width >= minWidth; width -= 4) { + candidateWidths.push(width); + } + if (candidateWidths[candidateWidths.length - 1] !== minWidth) { + candidateWidths.push(minWidth); + } + + const getRank = (spec: CartesianAxisLabelSpec) => { + const ellipsified = isEllipsifiedLabel(spec.text); + const visibleChars = spec.text + .replace(/\.\.\./g, "") + .replace(/\n/g, "").length; + const lineCount = spec.text.split("\n").length; + return { + ellipsified, + visibleChars, + lineCount, + }; + }; + + const shouldPrefer = ( + candidate: CartesianAxisLabelSpec, + current: CartesianAxisLabelSpec, + ) => { + const candidateRank = getRank(candidate); + const currentRank = getRank(current); + if (candidateRank.ellipsified !== currentRank.ellipsified) { + return !candidateRank.ellipsified; + } + if (candidateRank.visibleChars !== currentRank.visibleChars) { + return candidateRank.visibleChars > currentRank.visibleChars; + } + if (candidateRank.lineCount !== currentRank.lineCount) { + return candidateRank.lineCount < currentRank.lineCount; + } + return candidate.rotatedHeight < current.rotatedHeight; + }; + + let bestFit: CartesianAxisLabelSpec | null = null; + let bestOverflowAny: { + overflow: number; + spec: CartesianAxisLabelSpec; + } | null = null; + let bestOverflowNonEllipsified: { + overflow: number; + spec: CartesianAxisLabelSpec; + } | null = null; + + for (const width of candidateWidths) { + const { wrapped, text } = wrapOrEllipsifyTextToWidth( + label, + width, + fontString, + lineHeight, + ); + const metrics = measureText(text, fontString, lineHeight); + const rotated = getRotatedBoundingBox( + metrics.width, + metrics.height, + CARTESIAN_LABEL_ROTATION, + ); + const spec = { + originalText: label, + text, + metrics, + rotatedWidth: rotated.width, + rotatedHeight: rotated.height, + wrapped, + }; + const overflow = rotated.width - maxRotatedWidth; + if (overflow <= 0) { + if (!bestFit || shouldPrefer(spec, bestFit)) { + bestFit = spec; + } + continue; + } + if ( + !bestOverflowAny || + overflow < bestOverflowAny.overflow || + (overflow === bestOverflowAny.overflow && + shouldPrefer(spec, bestOverflowAny.spec)) + ) { + bestOverflowAny = { overflow, spec }; + } + if ( + !isEllipsifiedLabel(spec.text) && + (!bestOverflowNonEllipsified || + overflow < bestOverflowNonEllipsified.overflow || + (overflow === bestOverflowNonEllipsified.overflow && + shouldPrefer(spec, bestOverflowNonEllipsified.spec))) + ) { + bestOverflowNonEllipsified = { overflow, spec }; + } + } + + if (bestFit) { + return bestFit; + } + + if ( + bestOverflowNonEllipsified && + bestOverflowAny && + bestOverflowNonEllipsified.overflow <= + bestOverflowAny.overflow + CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER + ) { + return bestOverflowNonEllipsified.spec; + } + + return bestOverflowAny!.spec; +}; + +export const getRotatedTextElementBottom = ( + element: NonDeletedExcalidrawElement, +) => { + if (element.type !== "text") { + return element.y + element.height; + } + const rotated = getRotatedBoundingBox( + element.width, + element.height, + element.angle, + ); + return element.y + element.height / 2 + rotated.height / 2; +}; + +export const chartXLabels = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + backgroundColor: string, + layout: CartesianChartLayout, +): ChartElements => { + const fontFamily = commonProps.fontFamily; + const fontSize = FONT_SIZES.sm; + const lineHeight = getLineHeight(fontFamily); + const fontString = getFontString({ fontFamily, fontSize }); + const maxRotatedWidth = Math.max( + 1, + layout.slotWidth + + layout.gap - + CARTESIAN_LABEL_SLOT_PADDING * 2 + + CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER, + ); + const axisY = y; + + return ( + spreadsheet.labels?.map((label, index) => { + const labelSpec = getCartesianAxisLabelSpec( + label, + layout.xLabelMaxWidth, + maxRotatedWidth, + fontString, + lineHeight, + ); + const centerX = + x + + index * (layout.slotWidth + layout.gap) + + layout.gap + + layout.slotWidth / 2; + const labelY = + axisY + + CARTESIAN_LABEL_AXIS_CLEARANCE + + (labelSpec.rotatedHeight - labelSpec.metrics.height) / 2; + + return newTextElement({ + backgroundColor, + ...commonProps, + text: labelSpec.text, + originalText: labelSpec.wrapped ? label : labelSpec.text, + autoResize: !labelSpec.wrapped, + x: centerX, + y: labelY, + angle: CARTESIAN_LABEL_ROTATION, + fontSize, + lineHeight, + textAlign: "center", + verticalAlign: "top", + }); + }) || [] + ); +}; + +const chartYLabels = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + backgroundColor: string, + layout: CartesianChartLayout, + maxValue = Math.max(...spreadsheet.series[0].values), +): ChartElements => { + const minYLabel = newTextElement({ + backgroundColor, + ...commonProps, + x: x - layout.gap, + y: y - layout.gap, + text: "0", + textAlign: "right", + }); + + const maxYLabel = newTextElement({ + backgroundColor, + ...commonProps, + x: x - layout.gap, + y: y - layout.chartHeight - minYLabel.height / 2, + text: maxValue.toLocaleString(), + textAlign: "right", + }); + + return [minYLabel, maxYLabel]; +}; + +const chartLines = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + backgroundColor: string, + layout: CartesianChartLayout, +): ChartElements => { + const { chartWidth, chartHeight } = getChartDimensions(spreadsheet, layout); + const xLine = newLinearElement({ + backgroundColor, + ...commonProps, + type: "line", + x, + y, + width: chartWidth, + points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], + }); + + const yLine = newLinearElement({ + backgroundColor, + ...commonProps, + type: "line", + x, + y, + height: chartHeight, + points: [pointFrom(0, 0), pointFrom(0, -chartHeight)], + }); + + const maxLine = newLinearElement({ + backgroundColor, + ...commonProps, + type: "line", + x, + y: y - layout.chartHeight - layout.gap, + strokeStyle: "dotted", + width: chartWidth, + opacity: GRID_OPACITY, + points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], + }); + + return [xLine, yLine, maxLine]; +}; + +// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g +export const chartBaseElements = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + backgroundColor: string, + layout: CartesianChartLayout, + maxValue = Math.max(...spreadsheet.series[0].values), + debug?: boolean, +): ChartElements => { + const { chartWidth, chartHeight } = getChartDimensions(spreadsheet, layout); + + const title = spreadsheet.title + ? newTextElement({ + backgroundColor, + ...commonProps, + text: spreadsheet.title, + x: x + chartWidth / 2, + y: y - layout.chartHeight - layout.gap * 2 - DEFAULT_FONT_SIZE, + roundness: null, + textAlign: "center", + fontSize: FONT_SIZES.xl, + fontFamily: FONT_FAMILY["Lilita One"], + }) + : null; + + const debugRect = debug + ? newElement({ + backgroundColor, + ...commonProps, + type: "rectangle", + x, + y: y - chartHeight, + width: chartWidth, + height: chartHeight, + strokeColor: COLOR_PALETTE.black, + fillStyle: "solid", + opacity: 6, + }) + : null; + + return [ + ...(debugRect ? [debugRect] : []), + ...(title ? [title] : []), + ...chartXLabels(spreadsheet, x, y, backgroundColor, layout), + ...chartYLabels(spreadsheet, x, y, backgroundColor, layout, maxValue), + ...chartLines(spreadsheet, x, y, backgroundColor, layout), + ]; +}; diff --git a/packages/excalidraw/charts/charts.line.ts b/packages/excalidraw/charts/charts.line.ts new file mode 100644 index 0000000000..b08774d8b3 --- /dev/null +++ b/packages/excalidraw/charts/charts.line.ts @@ -0,0 +1,130 @@ +import { pointFrom } from "@excalidraw/math"; + +import { isDevEnv } from "@excalidraw/common"; + +import { newElement, newLinearElement } from "@excalidraw/element"; + +import type { LocalPoint } from "@excalidraw/math"; + +import { GRID_OPACITY, commonProps } from "./charts.constants"; +import { + chartBaseElements, + chartXLabels, + createSeriesLegend, + getBackgroundColor, + getCartesianChartLayout, + getChartDimensions, + getColorOffset, + getRotatedTextElementBottom, + getSeriesColors, +} from "./charts.helpers"; + +import type { ChartElements, Spreadsheet } from "./charts.types"; + +export const renderLineChart = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + colorSeed?: number, +): ChartElements => { + const series = spreadsheet.series; + const layout = getCartesianChartLayout("line", series.length); + const max = Math.max(1, ...series.flatMap((seriesData) => seriesData.values)); + const colorOffset = getColorOffset(colorSeed); + const backgroundColor = getBackgroundColor(colorOffset); + const seriesColors = getSeriesColors(series.length, colorOffset); + + const lines = series.map((seriesData, seriesIndex) => { + const points = seriesData.values.map((value, valueIndex) => + pointFrom( + valueIndex * (layout.slotWidth + layout.gap), + -(value / max) * layout.chartHeight, + ), + ); + + const maxX = Math.max(...points.map((point) => point[0])); + const maxY = Math.max(...points.map((point) => point[1])); + const minX = Math.min(...points.map((point) => point[0])); + const minY = Math.min(...points.map((point) => point[1])); + + return newLinearElement({ + backgroundColor: "transparent", + ...commonProps, + type: "line", + x: x + layout.gap + layout.slotWidth / 2, + y: y - layout.gap, + height: maxY - minY, + width: maxX - minX, + strokeColor: seriesColors[seriesIndex], + strokeWidth: 2, + points, + }); + }); + + const dots = series.flatMap((seriesData, seriesIndex) => + seriesData.values.map((value, valueIndex) => { + const cx = valueIndex * (layout.slotWidth + layout.gap) + layout.gap / 2; + const cy = -(value / max) * layout.chartHeight + layout.gap / 2; + return newElement({ + backgroundColor: seriesColors[seriesIndex], + ...commonProps, + fillStyle: "solid", + strokeColor: seriesColors[seriesIndex], + strokeWidth: 2, + type: "ellipse", + x: x + cx + layout.slotWidth / 2, + y: y + cy - layout.gap * 2, + width: layout.gap, + height: layout.gap, + }); + }), + ); + + const guideValues = series[0].values.map((_, valueIndex) => + Math.max( + 0, + ...series.map((seriesData) => seriesData.values[valueIndex] ?? 0), + ), + ); + const guides = guideValues.map((value, valueIndex) => { + const cx = valueIndex * (layout.slotWidth + layout.gap) + layout.gap / 2; + const cy = (value / max) * layout.chartHeight + layout.gap / 2 + layout.gap; + return newLinearElement({ + backgroundColor, + ...commonProps, + type: "line", + x: x + cx + layout.slotWidth / 2 + layout.gap / 2, + y: y - cy, + height: cy, + strokeStyle: "dotted", + opacity: GRID_OPACITY, + points: [pointFrom(0, 0), pointFrom(0, cy)], + }); + }); + + const baseElements = chartBaseElements( + spreadsheet, + x, + y, + backgroundColor, + layout, + max, + isDevEnv(), + ); + const xLabels = chartXLabels(spreadsheet, x, y, backgroundColor, layout); + const xLabelsBottomY = Math.max( + y + layout.gap / 2, + ...xLabels.map((label) => getRotatedTextElementBottom(label)), + ); + const { chartWidth } = getChartDimensions(spreadsheet, layout); + const seriesLegend = createSeriesLegend( + series, + seriesColors, + x + chartWidth / 2, + xLabelsBottomY, + y + layout.gap * 5, + backgroundColor, + ); + + return [...baseElements, ...lines, ...guides, ...dots, ...seriesLegend]; +}; diff --git a/packages/excalidraw/charts/charts.parse.ts b/packages/excalidraw/charts/charts.parse.ts new file mode 100644 index 0000000000..f6d71fdf69 --- /dev/null +++ b/packages/excalidraw/charts/charts.parse.ts @@ -0,0 +1,174 @@ +import { type ParseSpreadsheetResult } from "./charts.types"; + +/** + * @private exported for testing + */ +export const tryParseNumber = (s: string): number | null => { + const match = + /^([-+]?)[$\u20AC\u00A3\u00A5\u20A9]?([-+]?)([\d.,]+)[%]?$/.exec(s); + if (!match) { + return null; + } + return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, "")); +}; + +const isNumericColumn = (lines: string[][], columnIndex: number) => + lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null); + +/** + * @private exported for testing + */ +export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => { + const numCols = cells[0].length; + + if (numCols > 2) { + const hasHeader = cells[0].every((cell) => tryParseNumber(cell) === null); + const rows = hasHeader ? cells.slice(1) : cells; + + if (rows.length < 1) { + return { ok: false, reason: "No data rows" }; + } + + const invalidNumericColumn = rows.some((row) => + row.slice(1).some((value) => tryParseNumber(value) === null), + ); + if (invalidNumericColumn) { + return { ok: false, reason: "Value is not numeric" }; + } + + // When there are more value columns than data rows, the data is in + // "wide" format — transpose so columns become labels (dimensions) + // and rows become series. This enables e.g. radar charts for wide data. + const numValueCols = numCols - 1; + if (numValueCols > rows.length) { + const labels = hasHeader ? cells[0].slice(1).map((h) => h.trim()) : null; + const series = rows.map((row) => ({ + title: row[0]?.trim() || null, + values: row.slice(1).map((v) => tryParseNumber(v)!), + })); + const title = + series.length === 1 + ? series[0].title + : hasHeader + ? cells[0][0].trim() || null + : null; + return { + ok: true, + data: { title, labels, series }, + }; + } + + const series = cells[0].slice(1).map((seriesTitle, index) => { + const valueColumnIndex = index + 1; + const fallbackTitle = `Series ${valueColumnIndex}`; + return { + title: hasHeader ? seriesTitle.trim() || fallbackTitle : fallbackTitle, + values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!), + }; + }); + + return { + ok: true, + data: { + title: hasHeader ? cells[0][0].trim() || null : null, + labels: rows.map((row) => row[0]), + series, + }, + }; + } + + if (numCols === 1) { + if (!isNumericColumn(cells, 0)) { + return { ok: false, reason: "Value is not numeric" }; + } + + const hasHeader = tryParseNumber(cells[0][0]) === null; + const title = hasHeader ? cells[0][0] : null; + const values = (hasHeader ? cells.slice(1) : cells).map((line) => + tryParseNumber(line[0]), + ); + + if (values.length < 2) { + return { ok: false, reason: "Less than two rows" }; + } + + return { + ok: true, + data: { + title, + labels: null, + series: [{ title, values: values as number[] }], + }, + }; + } + + const hasHeader = tryParseNumber(cells[0][1]) === null; + const rows = hasHeader ? cells.slice(1) : cells; + + if (rows.length < 2) { + return { ok: false, reason: "Less than 2 rows" }; + } + + const invalidNumericColumn = rows.some( + (row) => tryParseNumber(row[1]) === null, + ); + if (invalidNumericColumn) { + return { ok: false, reason: "Value is not numeric" }; + } + + const title = hasHeader ? cells[0][1] : null; + + return { + ok: true, + data: { + title, + labels: rows.map((row) => row[0]), + series: [{ title, values: rows.map((row) => tryParseNumber(row[1])!) }], + }, + }; +}; + +export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => { + // Copy/paste from excel, spreadsheets, TSV, CSV, semicolon-separated. + const parseDelimitedLines = (delimiter: "\t" | "," | ";") => + text + .replace(/\r\n?/g, "\n") + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => line.split(delimiter).map((cell) => cell.trim())); + + // Score each delimiter: prefer consistent column counts with the most columns. + // A delimiter that produces all single-column rows likely isn't the right one. + const candidates = (["\t", ",", ";"] as const).map((delimiter) => { + const parsed = parseDelimitedLines(delimiter); + const numCols = parsed[0]?.length ?? 0; + const isConsistent = + parsed.length > 0 && parsed.every((line) => line.length === numCols); + return { delimiter, parsed, numCols, isConsistent }; + }); + + // Prefer: consistent + most columns. Among ties, tab > comma > semicolon + // (the array order already encodes this priority). + const best = + candidates.find((c) => c.isConsistent && c.numCols > 1) ?? + candidates.find((c) => c.isConsistent) ?? + candidates[0]; + + const lines = best.parsed; + + if (lines.length === 0) { + return { ok: false, reason: "No values" }; + } + + const numColsFirstLine = lines[0].length; + const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine); + + if (!isSpreadsheet) { + return { + ok: false, + reason: "All rows don't have same number of columns", + }; + } + + return tryParseCells(lines); +}; diff --git a/packages/excalidraw/charts/charts.radar.ts b/packages/excalidraw/charts/charts.radar.ts new file mode 100644 index 0000000000..6606a4af60 --- /dev/null +++ b/packages/excalidraw/charts/charts.radar.ts @@ -0,0 +1,199 @@ +import { pointFrom } from "@excalidraw/math"; + +import { + FONT_FAMILY, + FONT_SIZES, + getFontString, + getLineHeight, + ROUGHNESS, +} from "@excalidraw/common"; + +import { + measureText, + newLinearElement, + newTextElement, +} from "@excalidraw/element"; + +import type { LocalPoint } from "@excalidraw/math"; + +import { + BAR_GAP, + BAR_HEIGHT, + GRID_OPACITY, + RADAR_GRID_LEVELS, + RADAR_LABEL_OFFSET, + commonProps, +} from "./charts.constants"; +import { + createRadarAxisLabels, + createSeriesLegend, + getBackgroundColor, + getColorOffset, + getRadarDimensions, + getRadarDisplayText, + getRadarValueScale, + getSeriesColors, + isSpreadsheetValidForChartType, +} from "./charts.helpers"; + +import type { ChartElements, Spreadsheet } from "./charts.types"; + +export const renderRadarChart = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + colorSeed?: number, +): ChartElements | null => { + if (!isSpreadsheetValidForChartType(spreadsheet, "radar")) { + return null; + } + + const labels = + spreadsheet.labels ?? + spreadsheet.series[0].values.map((_, index) => `Value ${index + 1}`); + + const series = spreadsheet.series; + const { normalize, renderSteps } = getRadarValueScale(series, labels.length); + const colorOffset = getColorOffset(colorSeed); + const backgroundColor = getBackgroundColor(colorOffset); + const seriesColors = getSeriesColors(series.length, colorOffset); + const { chartWidth, chartHeight } = getRadarDimensions(); + const centerX = x + chartWidth / 2; + const centerY = y - chartHeight / 2; + const radius = BAR_HEIGHT / 2; + const angles = labels.map( + (_, index) => -Math.PI / 2 + (Math.PI * 2 * index) / labels.length, + ); + + const { axisLabels, axisLabelTopY, axisLabelBottomY } = createRadarAxisLabels( + labels, + angles, + centerX, + centerY, + radius, + backgroundColor, + ); + + const titleFontFamily = FONT_FAMILY["Lilita One"]; + const titleFontSize = FONT_SIZES.xl; + const titleLineHeight = getLineHeight(titleFontFamily); + const titleFontString = getFontString({ + fontFamily: titleFontFamily, + fontSize: titleFontSize, + }); + const titleText = spreadsheet.title + ? getRadarDisplayText( + spreadsheet.title, + titleFontString, + chartWidth + RADAR_LABEL_OFFSET * 2, + ) + : null; + const titleTextMetrics = titleText + ? measureText(titleText, titleFontString, titleLineHeight) + : null; + const title = titleText + ? newTextElement({ + backgroundColor, + ...commonProps, + text: titleText, + originalText: spreadsheet.title ?? titleText, + x: x + chartWidth / 2, + y: axisLabelTopY - RADAR_LABEL_OFFSET - titleTextMetrics!.height / 2, + fontFamily: titleFontFamily, + fontSize: titleFontSize, + lineHeight: titleLineHeight, + textAlign: "center", + }) + : null; + + const radarGridLines = renderSteps + ? Array.from({ length: RADAR_GRID_LEVELS }, (_, levelIndex) => { + const levelRatio = (levelIndex + 1) / RADAR_GRID_LEVELS; + const levelRadius = radius * levelRatio; + const points = angles.map((angle) => + pointFrom( + Math.cos(angle) * levelRadius, + Math.sin(angle) * levelRadius, + ), + ); + points.push(pointFrom(points[0][0], points[0][1])); + + return newLinearElement({ + backgroundColor: "transparent", + ...commonProps, + type: "line", + x: centerX, + y: centerY, + width: levelRadius * 2, + height: levelRadius * 2, + strokeStyle: "solid", + roughness: ROUGHNESS.architect, + opacity: GRID_OPACITY, + polygon: true, + points, + }); + }) + : []; + + const spokes = angles.map((angle) => { + const px = Math.cos(angle) * radius; + const py = Math.sin(angle) * radius; + return newLinearElement({ + backgroundColor: "transparent", + ...commonProps, + type: "line", + x: centerX, + y: centerY, + width: Math.abs(px), + height: Math.abs(py), + strokeStyle: "solid", + roughness: ROUGHNESS.architect, + opacity: GRID_OPACITY, + points: [pointFrom(0, 0), pointFrom(px, py)], + }); + }); + + const seriesPolygons = series.map((seriesData, index) => { + const points = angles.map((angle, axisIndex) => { + const value = seriesData.values[axisIndex] ?? 0; + const pointRadius = normalize(value, axisIndex) * radius; + return pointFrom( + Math.cos(angle) * pointRadius, + Math.sin(angle) * pointRadius, + ); + }); + points.push(pointFrom(points[0][0], points[0][1])); + + return newLinearElement({ + backgroundColor: "transparent", + ...commonProps, + type: "line", + x: centerX, + y: centerY, + width: radius * 2, + height: radius * 2, + strokeColor: seriesColors[index], + strokeWidth: 2, + polygon: true, + points, + }); + }); + + const seriesLegend = createSeriesLegend( + series, + seriesColors, + centerX, + axisLabelBottomY, + y + BAR_GAP * 5, + backgroundColor, + ); + + return [ + ...(title ? [title] : []), + ...axisLabels, + ...radarGridLines, + ...spokes, + ...seriesPolygons, + ...seriesLegend, + ]; +}; diff --git a/packages/excalidraw/charts/charts.types.ts b/packages/excalidraw/charts/charts.types.ts new file mode 100644 index 0000000000..29f3971a58 --- /dev/null +++ b/packages/excalidraw/charts/charts.types.ts @@ -0,0 +1,18 @@ +import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; + +export type ChartElements = readonly NonDeletedExcalidrawElement[]; + +export interface Spreadsheet { + title: string | null; + labels: string[] | null; + series: SpreadsheetSeries[]; +} + +export interface SpreadsheetSeries { + title: string | null; + values: number[]; +} + +export type ParseSpreadsheetResult = + | { ok: false; reason: string } + | { ok: true; data: Spreadsheet }; diff --git a/packages/excalidraw/charts/index.ts b/packages/excalidraw/charts/index.ts new file mode 100644 index 0000000000..d806546a49 --- /dev/null +++ b/packages/excalidraw/charts/index.ts @@ -0,0 +1,38 @@ +import type { ChartType } from "@excalidraw/element/types"; + +import { renderBarChart } from "./charts.bar"; +import { renderLineChart } from "./charts.line"; +import { + tryParseCells, + tryParseNumber, + tryParseSpreadsheet, +} from "./charts.parse"; +import { renderRadarChart } from "./charts.radar"; + +import type { ChartElements, Spreadsheet } from "./charts.types"; + +export { + type ParseSpreadsheetResult, + type Spreadsheet, + type SpreadsheetSeries, + type ChartElements, +} from "./charts.types"; + +export { isSpreadsheetValidForChartType } from "./charts.helpers"; +export { tryParseCells, tryParseNumber, tryParseSpreadsheet }; + +export const renderSpreadsheet = ( + chartType: ChartType, + spreadsheet: Spreadsheet, + x: number, + y: number, + colorSeed?: number, +): ChartElements | null => { + if (chartType === "line") { + return renderLineChart(spreadsheet, x, y, colorSeed); + } + if (chartType === "radar") { + return renderRadarChart(spreadsheet, x, y, colorSeed); + } + return renderBarChart(spreadsheet, x, y, colorSeed); +}; diff --git a/packages/excalidraw/clipboard.test.ts b/packages/excalidraw/clipboard.test.ts index 2115c3eff2..6f2b6fc374 100644 --- a/packages/excalidraw/clipboard.test.ts +++ b/packages/excalidraw/clipboard.test.ts @@ -155,67 +155,4 @@ describe("parseClipboard()", () => { }, ]); }); - - it("should parse spreadsheet from either text/plain and text/html", async () => { - let clipboardData; - // ------------------------------------------------------------------------- - clipboardData = await parseClipboard( - await parseDataTransferEvent( - createPasteEvent({ - types: { - "text/plain": `a b - 1 2 - 4 5 - 7 10`, - }, - }), - ), - ); - expect(clipboardData.spreadsheet).toEqual({ - title: "b", - labels: ["1", "4", "7"], - values: [2, 5, 10], - }); - // ------------------------------------------------------------------------- - clipboardData = await parseClipboard( - await parseDataTransferEvent( - createPasteEvent({ - types: { - "text/html": `a b - 1 2 - 4 5 - 7 10`, - }, - }), - ), - ); - expect(clipboardData.spreadsheet).toEqual({ - title: "b", - labels: ["1", "4", "7"], - values: [2, 5, 10], - }); - // ------------------------------------------------------------------------- - clipboardData = await parseClipboard( - await parseDataTransferEvent( - createPasteEvent({ - types: { - "text/html": ` - -
ab
12
45
710
- - `, - "text/plain": `a b - 1 2 - 4 5 - 7 10`, - }, - }), - ), - ); - expect(clipboardData.spreadsheet).toEqual({ - title: "b", - labels: ["1", "4", "7"], - values: [2, 5, 10], - }); - }); }); diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 6033b857af..165534741f 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -33,12 +33,8 @@ import { normalizeFile, } from "./data/blob"; -import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; - import type { FileSystemHandle } from "./data/filesystem"; -import type { Spreadsheet } from "./charts"; - import type { BinaryFiles } from "./types"; type ElementsClipboard = { @@ -50,7 +46,6 @@ type ElementsClipboard = { export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[]; export interface ClipboardData { - spreadsheet?: Spreadsheet; elements?: readonly ExcalidrawElement[]; files?: BinaryFiles; text?: string; @@ -215,16 +210,6 @@ export const copyToClipboard = async ( ); }; -const parsePotentialSpreadsheet = ( - text: string, -): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => { - const result = tryParseSpreadsheet(text); - if (result.type === VALID_SPREADSHEET) { - return { spreadsheet: result.spreadsheet }; - } - return null; -}; - /** internal, specific to parsing paste events. Do not reuse. */ function parseHTMLTree(el: ChildNode) { let result: PastedMixedContent = []; @@ -551,19 +536,6 @@ export const parseClipboard = async ( }; } - try { - // if system clipboard contains spreadsheet, use it even though it's - // technically possible it's staler than in-app clipboard - const spreadsheetResult = - !isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value); - - if (spreadsheetResult) { - return spreadsheetResult; - } - } catch (error: any) { - console.error(error); - } - try { const systemClipboardData = JSON.parse(parsedEventData.value); const programmaticAPI = diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 285421e357..adbcce44e5 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -425,6 +425,8 @@ import { EraserTrail } from "../eraser"; import { getShortcutKey } from "../shortcut"; +import { tryParseSpreadsheet } from "../charts"; + import ConvertElementTypePopup, { getConversionTypeFromElements, convertElementTypePopupAtom, @@ -3542,14 +3544,19 @@ class App extends React.Component { } // ------------------- Spreadsheet ------------------- - if (data.spreadsheet && !isPlainPaste) { - this.setState({ - pasteDialog: { - data: data.spreadsheet, - shown: true, - }, - }); - return; + + if (!isPlainPaste && data.text) { + const result = tryParseSpreadsheet(data.text); + if (result.ok) { + this.setState({ + openDialog: { + name: "charts", + data: result.data, + rawText: data.text, + }, + }); + return; + } } // ------------------- Images or SVG code ------------------- diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 11447bbd31..85d2701b11 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -556,13 +556,13 @@ const LayerUI = ({ {renderImageExportDialog()} {renderJSONExportDialog()} - {appState.pasteDialog.shown && ( + {appState.openDialog?.name === "charts" && ( setAppState({ - pasteDialog: { shown: false, data: null }, + openDialog: null, }) } /> diff --git a/packages/excalidraw/components/PasteChartDialog.scss b/packages/excalidraw/components/PasteChartDialog.scss index 3bd21b02be..7e73768e39 100644 --- a/packages/excalidraw/components/PasteChartDialog.scss +++ b/packages/excalidraw/components/PasteChartDialog.scss @@ -2,6 +2,40 @@ .excalidraw { .PasteChartDialog { + .PasteChartDialog__title { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .PasteChartDialog__titleText { + min-width: 0; + } + + .PasteChartDialog__reshuffleBtn { + margin-left: auto; + flex: 0 0 auto; + width: 1rem; + height: 1rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + color: var(--text-primary-color); + transition: transform 120ms ease, background-color 120ms ease, + color 120ms ease; + user-select: none; + + &:hover { + color: $color-blue-6; + } + + &:active { + transform: scale(0.94); + } + } + @include isMobile { .Island { display: flex; @@ -11,35 +45,61 @@ .container { display: flex; align-items: center; - justify-content: space-around; + justify-content: center; flex-wrap: wrap; + gap: 1rem; @include isMobile { flex-direction: column; justify-content: center; + align-items: stretch; } } .ChartPreview { - margin: 8px; - text-align: center; - width: 192px; - height: 128px; - border-radius: 2px; - padding: 1px; + width: 260px; + min-height: 190px; + border-radius: 8px; + padding: 10px; border: 1px solid $color-gray-4; display: flex; - align-items: center; - justify-content: center; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + gap: 10px; background: transparent; - div { - display: inline-block; + .ChartPreview__canvas { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + overflow: hidden; + } + .ChartPreview__label { + font-size: 0.875rem; + font-weight: 600; + line-height: 1; + text-align: center; + color: var(--text-primary-color); } svg { - max-height: 120px; - max-width: 186px; + max-height: 144px; + max-width: 100%; } &:hover { - padding: 0; - border: 2px solid $color-blue-5; + border-color: $color-blue-5; + } + &:active { + border-color: $color-blue-5; + box-shadow: 0 0 0 1px $color-blue-5; + transform: scale(0.98); + } + &:focus-visible { + border-color: $color-blue-5; + box-shadow: 0 0 0 1px $color-blue-5; + } + + @include isMobile { + width: 100%; + min-height: 200px; } } } diff --git a/packages/excalidraw/components/PasteChartDialog.tsx b/packages/excalidraw/components/PasteChartDialog.tsx index 2db632a63b..19c3271766 100644 --- a/packages/excalidraw/components/PasteChartDialog.tsx +++ b/packages/excalidraw/components/PasteChartDialog.tsx @@ -1,35 +1,57 @@ import React, { useLayoutEffect, useRef, useState } from "react"; +import { newTextElement } from "@excalidraw/element"; + import type { ChartType } from "@excalidraw/element/types"; import { trackEvent } from "../analytics"; -import { renderSpreadsheet } from "../charts"; +import { isSpreadsheetValidForChartType, renderSpreadsheet } from "../charts"; import { t } from "../i18n"; import { exportToSvg } from "../scene/export"; +import { useUIAppState } from "../context/ui-appState"; + import { useApp } from "./App"; import { Dialog } from "./Dialog"; import "./PasteChartDialog.scss"; +import { bucketFillIcon } from "./icons"; + import type { ChartElements, Spreadsheet } from "../charts"; -import type { UIAppState } from "../types"; + +type OnPlainTextPaste = (rawText: string) => void; type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void; +const getChartTypeLabel = (chartType: ChartType) => { + switch (chartType) { + case "bar": + return t("labels.chartType_bar"); + case "line": + return t("labels.chartType_line"); + case "radar": + return t("labels.chartType_radar"); + default: + return chartType; + } +}; + const ChartPreviewBtn = (props: { spreadsheet: Spreadsheet | null; chartType: ChartType; - selected: boolean; + colorSeed: number; onClick: OnInsertChart; }) => { const previewRef = useRef(null); const [chartElements, setChartElements] = useState( null, ); + const { theme } = useUIAppState(); useLayoutEffect(() => { if (!props.spreadsheet) { + setChartElements(null); return; } @@ -38,7 +60,13 @@ const ChartPreviewBtn = (props: { props.spreadsheet, 0, 0, + props.colorSeed, ); + if (!elements) { + setChartElements(null); + previewRef.current?.replaceChildren(); + return; + } setChartElements(elements); let svg: SVGSVGElement; const previewNode = previewRef.current!; @@ -49,6 +77,7 @@ const ChartPreviewBtn = (props: { { exportBackground: false, viewBackgroundColor: "#fff", + exportWithDarkMode: theme === "dark", }, null, // files { @@ -58,42 +87,108 @@ const ChartPreviewBtn = (props: { svg.querySelector(".style-fonts")?.remove(); previewNode.replaceChildren(); previewNode.appendChild(svg); - - if (props.selected) { - (previewNode.parentNode as HTMLDivElement).focus(); - } })(); return () => { previewNode.replaceChildren(); }; - }, [props.spreadsheet, props.chartType, props.selected]); + }, [props.spreadsheet, props.chartType, props.colorSeed, theme]); + + const chartTypeLabel = getChartTypeLabel(props.chartType); return ( + ); +}; + +const PlainTextPreviewBtn = (props: { + rawText: string; + onClick: OnPlainTextPaste; +}) => { + const previewRef = useRef(null); + const { theme } = useUIAppState(); + + useLayoutEffect(() => { + if (!props.rawText) { + return; + } + + const textElement = newTextElement({ + text: props.rawText, + x: 0, + y: 0, + }); + + const previewNode = previewRef.current!; + + (async () => { + const svg = await exportToSvg( + [textElement], + { + exportBackground: false, + viewBackgroundColor: "#fff", + exportWithDarkMode: theme === "dark", + }, + null, + { + skipInliningFonts: true, + }, + ); + svg.querySelector(".style-fonts")?.remove(); + previewNode.replaceChildren(); + previewNode.appendChild(svg); + })(); + + return () => { + previewNode.replaceChildren(); + }; + }, [props.rawText, theme]); + + return ( + ); }; export const PasteChartDialog = ({ - setAppState, - appState, + data, + rawText, onClose, }: { - appState: UIAppState; + data: Spreadsheet; + rawText: string; onClose: () => void; - setAppState: React.Component["setState"]; }) => { - const { onInsertElements } = useApp(); + const { onInsertElements, focusContainer } = useApp(); + const [colorSeed, setColorSeed] = useState(Math.random()); + + const handleReshuffleColors = React.useCallback(() => { + setColorSeed(Math.random()); + }, []); + const handleClose = React.useCallback(() => { if (onClose) { onClose(); @@ -103,36 +198,72 @@ export const PasteChartDialog = ({ const handleChartClick = (chartType: ChartType, elements: ChartElements) => { onInsertElements(elements); trackEvent("paste", "chart", chartType); - setAppState({ - currentChartType: chartType, - pasteDialog: { - shown: false, - data: null, - }, + onClose(); + focusContainer(); + }; + + const handlePlainTextClick = (rawText: string) => { + const textElement = newTextElement({ + text: rawText, + x: 0, + y: 0, }); + onInsertElements([textElement]); + trackEvent("paste", "chart", "plaintext"); + onClose(); + focusContainer(); }; return ( +
+ {t("labels.pasteCharts")} +
+
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleReshuffleColors(); + } + }} + > + {bucketFillIcon} +
+
+ } className={"PasteChartDialog"} autofocus={false} >
- - + {(["bar", "line", "radar"] as const).map((chartType) => { + if (!isSpreadsheetValidForChartType(data, chartType)) { + return null; + } + + return ( + + ); + })} + {rawText && ( + + )}
); diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index f8004b4c2f..ab0054ee35 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -319,3 +319,9 @@ export { isElementLink } from "@excalidraw/element"; export { setCustomTextMetricsProvider } from "@excalidraw/element"; export { CommandPalette } from "./components/CommandPalette/CommandPalette"; + +export { + renderSpreadsheet, + tryParseSpreadsheet, + isSpreadsheetValidForChartType, +} from "./charts"; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 182e59bb61..18e8c88908 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -3,6 +3,10 @@ "paste": "Paste", "pasteAsPlaintext": "Paste as plaintext", "pasteCharts": "Paste charts", + "chartType_bar": "Bar chart", + "chartType_line": "Line chart", + "chartType_radar": "Radar chart", + "chartType_plaintext": "Plain text", "selectAll": "Select all", "multiSelect": "Add element to selection", "moveCanvas": "Move canvas", diff --git a/packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap index 868e27e842..5fa9fb7e35 100644 --- a/packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap @@ -2,19 +2,24 @@ exports[`tryParseSpreadsheet > works for numbers with comma in them 1`] = ` { - "spreadsheet": { + "data": { "labels": [ "Week 1", "Week 2", "Week 3", ], - "title": "Users", - "values": [ - 814, - 10301, - 4264, + "series": [ + { + "title": "Users", + "values": [ + 814, + 10301, + 4264, + ], + }, ], + "title": "Users", }, - "type": "VALID_SPREADSHEET", + "ok": true, } `; diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index b8467c20ab..38f748b817 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -889,7 +889,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "top": 40, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -951,10 +950,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1091,7 +1086,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1150,10 +1144,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1308,7 +1298,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1367,10 +1356,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1642,7 +1627,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1701,10 +1685,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1976,7 +1956,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2035,10 +2014,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2193,7 +2168,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2252,10 +2226,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2437,7 +2407,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2496,10 +2465,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2738,7 +2703,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2797,10 +2761,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3113,7 +3073,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#a5d8ff", @@ -3172,10 +3131,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3609,7 +3564,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3668,10 +3622,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3935,7 +3885,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3994,10 +3943,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4261,7 +4206,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4320,10 +4264,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5549,7 +5489,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "top": -7, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5608,10 +5547,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -6769,7 +6704,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "top": -7, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -6828,10 +6762,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7707,7 +7637,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "top": -9, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7769,10 +7698,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8710,7 +8635,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "top": -7, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8769,10 +8693,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9704,7 +9624,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "top": 90, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9766,10 +9685,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index c8c427e6ae..97204839fc 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -15,7 +15,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -73,10 +72,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -649,7 +644,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -707,10 +701,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1213,7 +1203,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1274,10 +1263,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1576,7 +1561,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1637,10 +1621,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1941,7 +1921,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2002,10 +1981,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2207,7 +2182,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2265,10 +2239,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2663,7 +2633,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2724,10 +2693,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2969,7 +2934,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3030,10 +2994,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3291,7 +3251,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3352,10 +3311,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3588,7 +3543,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3649,10 +3603,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3877,7 +3827,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3938,10 +3887,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4115,7 +4060,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4176,10 +4120,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4375,7 +4315,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4436,10 +4375,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4649,7 +4584,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4710,10 +4644,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4881,7 +4811,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4942,10 +4871,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5113,7 +5038,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5174,10 +5098,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5363,7 +5283,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5424,10 +5343,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5622,7 +5537,6 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5683,10 +5597,6 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5883,7 +5793,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5941,10 +5850,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -6215,7 +6120,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", @@ -6273,10 +6177,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -6645,7 +6545,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -6703,10 +6602,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7022,7 +6917,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7083,10 +6977,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7337,7 +7227,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7395,10 +7284,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7632,7 +7517,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", @@ -7690,10 +7574,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "openPopup": "elementBackground", "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7865,7 +7745,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7923,10 +7802,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8220,7 +8095,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8278,10 +8152,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8575,7 +8445,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8633,10 +8502,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8984,7 +8849,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9042,10 +8906,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9266,7 +9126,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9324,10 +9183,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9533,7 +9388,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", @@ -9591,10 +9445,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "openPopup": "elementBackground", "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9801,7 +9651,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", @@ -9859,10 +9708,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "openPopup": "elementBackground", "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -10036,7 +9881,6 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10097,10 +9941,6 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -10336,7 +10176,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10394,10 +10233,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -10656,7 +10491,6 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10714,10 +10548,6 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -10895,7 +10725,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10956,10 +10785,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -11340,7 +11165,6 @@ exports[`history > multiplayer undo/redo > should update history entries after r "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#a5d8ff", @@ -11398,10 +11222,6 @@ exports[`history > multiplayer undo/redo > should update history entries after r "openPopup": "elementBackground", "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -11603,7 +11423,6 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -11661,10 +11480,6 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -11841,7 +11656,6 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -11899,10 +11713,6 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -12081,7 +11891,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -12139,10 +11948,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -12475,7 +12280,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -12536,10 +12340,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -12688,7 +12488,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -12746,10 +12545,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -12898,7 +12693,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -12959,10 +12753,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -13202,7 +12992,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -13260,10 +13049,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -13503,7 +13288,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on s "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -13564,10 +13348,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on s "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -13751,7 +13531,6 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -13809,10 +13588,6 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -13991,7 +13766,6 @@ exports[`history > singleplayer undo/redo > should end up with no history entry "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -14049,10 +13823,6 @@ exports[`history > singleplayer undo/redo > should end up with no history entry "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14231,7 +14001,6 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -14289,10 +14058,6 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14481,7 +14246,6 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -14539,10 +14303,6 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14815,7 +14575,6 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -14876,10 +14635,6 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14988,7 +14743,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -15046,10 +14800,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -15275,7 +15025,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -15336,10 +15085,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -15541,7 +15286,6 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -15602,10 +15346,6 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -15697,7 +15437,6 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#a5d8ff", @@ -15755,10 +15494,6 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -15982,7 +15717,6 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -16043,10 +15777,6 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -16147,7 +15877,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -16205,10 +15934,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -16898,7 +16623,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -16956,10 +16680,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -17547,7 +17267,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -17605,10 +17324,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -18196,7 +17911,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -18254,10 +17968,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -18948,7 +18658,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -19006,10 +18715,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -19719,7 +19424,6 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -19777,10 +19481,6 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -20202,7 +19902,6 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -20260,10 +19959,6 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -20716,7 +20411,6 @@ exports[`history > singleplayer undo/redo > should support element creation, del "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -20774,10 +20468,6 @@ exports[`history > singleplayer undo/redo > should support element creation, del "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -21178,7 +20868,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -21236,10 +20925,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index d9f469988c..c7dd74b91f 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -15,7 +15,6 @@ exports[`given element A and group of elements B and given both are selected whe "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -74,10 +73,6 @@ exports[`given element A and group of elements B and given both are selected whe "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -444,7 +439,6 @@ exports[`given element A and group of elements B and given both are selected whe "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -503,10 +497,6 @@ exports[`given element A and group of elements B and given both are selected whe "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -863,7 +853,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -922,10 +911,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1432,7 +1417,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1491,10 +1475,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -1642,7 +1622,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1701,10 +1680,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2029,7 +2004,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2088,10 +2062,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2277,7 +2247,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2336,10 +2305,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2460,7 +2425,6 @@ exports[`regression tests > can drag element that covers another element, while "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2519,10 +2483,6 @@ exports[`regression tests > can drag element that covers another element, while "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -2788,7 +2748,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", @@ -2847,10 +2806,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app "openPopup": "elementStroke", "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3046,7 +3001,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3105,10 +3059,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3290,7 +3240,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3349,10 +3298,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3529,7 +3474,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3588,10 +3532,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -3790,7 +3730,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3849,10 +3788,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4107,7 +4042,6 @@ exports[`regression tests > deleting last but one element in editing group shoul "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4166,10 +4100,6 @@ exports[`regression tests > deleting last but one element in editing group shoul "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4546,7 +4476,6 @@ exports[`regression tests > deselects group of selected elements on pointer down "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4605,10 +4534,6 @@ exports[`regression tests > deselects group of selected elements on pointer down "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -4832,7 +4757,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4891,10 +4815,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5111,7 +5031,6 @@ exports[`regression tests > deselects selected element on pointer down when poin "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5170,10 +5089,6 @@ exports[`regression tests > deselects selected element on pointer down when poin "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5322,7 +5237,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5381,10 +5295,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5525,7 +5435,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5584,10 +5493,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -5921,7 +5826,6 @@ exports[`regression tests > drags selected elements from point inside common bou "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5980,10 +5884,6 @@ exports[`regression tests > drags selected elements from point inside common bou "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -6221,7 +6121,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -6280,10 +6179,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7012,7 +6907,6 @@ exports[`regression tests > given a group of selected elements with an element t "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7071,10 +6965,6 @@ exports[`regression tests > given a group of selected elements with an element t "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7349,7 +7239,6 @@ exports[`regression tests > given a selected element A and a not selected elemen "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#ffc9c9", @@ -7408,10 +7297,6 @@ exports[`regression tests > given a selected element A and a not selected elemen "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7631,7 +7516,6 @@ exports[`regression tests > given selected element A with lower z-index than uns "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7690,10 +7574,6 @@ exports[`regression tests > given selected element A with lower z-index than uns "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -7869,7 +7749,6 @@ exports[`regression tests > given selected element A with lower z-index than uns "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7928,10 +7807,6 @@ exports[`regression tests > given selected element A with lower z-index than uns "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8112,7 +7987,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8171,10 +8045,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8295,7 +8165,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8354,10 +8223,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8478,7 +8343,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8537,10 +8401,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8661,7 +8521,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8720,10 +8579,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -8896,7 +8751,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8955,10 +8809,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9129,7 +8979,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9188,10 +9037,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9324,7 +9169,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9383,10 +9227,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9559,7 +9399,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9618,10 +9457,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9742,7 +9577,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9801,10 +9635,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -9975,7 +9805,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10034,10 +9863,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -10158,7 +9983,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10217,10 +10041,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -10353,7 +10173,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10412,10 +10231,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -10536,7 +10351,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -10595,10 +10409,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -11070,7 +10880,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -11129,10 +10938,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -11353,7 +11158,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -11412,10 +11216,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -11479,7 +11279,6 @@ exports[`regression tests > shift click on selected element should deselect it o "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -11538,10 +11337,6 @@ exports[`regression tests > shift click on selected element should deselect it o "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -11682,7 +11477,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -11741,10 +11535,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -12004,7 +11794,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -12063,10 +11852,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -12436,7 +12221,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -12495,10 +12279,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -13079,7 +12859,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -13141,10 +12920,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -13208,7 +12983,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -13267,10 +13041,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -13842,7 +13612,6 @@ exports[`regression tests > switches from group of selected elements to another "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -13901,10 +13670,6 @@ exports[`regression tests > switches from group of selected elements to another "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14184,7 +13949,6 @@ exports[`regression tests > switches selected element on pointer down > [end of "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -14243,10 +14007,6 @@ exports[`regression tests > switches selected element on pointer down > [end of "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14451,7 +14211,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -14510,10 +14269,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14577,7 +14332,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -14636,10 +14390,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -14944,7 +14694,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -15003,10 +14752,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { @@ -15070,7 +14815,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -15132,10 +14876,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { diff --git a/packages/excalidraw/tests/charts.test.tsx b/packages/excalidraw/tests/charts.test.tsx index a4bce153bd..8f2274c824 100644 --- a/packages/excalidraw/tests/charts.test.tsx +++ b/packages/excalidraw/tests/charts.test.tsx @@ -10,4 +10,155 @@ Week 3${"\t"}4,264`, ); expect(result).toMatchSnapshot(); }); + + it("parses multi-series CSV for radar charts", () => { + const result = tryParseSpreadsheet( + `Metric,Player A,Player B,Player C +Speed,80,60,75 +Strength,65,85,70 +Agility,90,70,88 +Intelligence,70,88,92 +Stamina,85,75,80`, + ); + + expect(result).toEqual({ + ok: true, + data: { + title: "Metric", + labels: ["Speed", "Strength", "Agility", "Intelligence", "Stamina"], + series: [ + { title: "Player A", values: [80, 65, 90, 70, 85] }, + { title: "Player B", values: [60, 85, 70, 88, 75] }, + { title: "Player C", values: [75, 70, 88, 92, 80] }, + ], + }, + }); + }); + + it("parses TSV with empty chart-name header cell", () => { + const result = tryParseSpreadsheet( + `\tDunk\tEgg +Physical Strength\t10\t2 +Swordsmanship\t8\t1 +Political Instinct\t3\t9`, + ); + + expect(result).toEqual({ + ok: true, + data: { + title: null, + labels: ["Physical Strength", "Swordsmanship", "Political Instinct"], + series: [ + { title: "Dunk", values: [10, 8, 3] }, + { title: "Egg", values: [2, 1, 9] }, + ], + }, + }); + }); + + it("parses 2-row multi-series TSV without transposing", () => { + const result = tryParseSpreadsheet( + `Physical Strength\t10\t2 +Swordsmanship skill\t8\t1`, + ); + + expect(result).toEqual({ + ok: true, + data: { + title: null, + labels: ["Physical Strength", "Swordsmanship skill"], + series: [ + { title: "Series 1", values: [10, 8] }, + { title: "Series 2", values: [2, 1] }, + ], + }, + }); + }); + + it("parses semicolon-separated values", () => { + const result = tryParseSpreadsheet( + `Metric;Player A;Player B +Speed;80;60 +Strength;65;85 +Agility;90;70`, + ); + + expect(result).toEqual({ + ok: true, + data: { + title: "Metric", + labels: ["Speed", "Strength", "Agility"], + series: [ + { title: "Player A", values: [80, 65, 90] }, + { title: "Player B", values: [60, 85, 70] }, + ], + }, + }); + }); + + it("transposes wide data (more value cols than rows) into series-per-row", () => { + const result = tryParseSpreadsheet( + `trait,Dunk,Egg,Daeron +Physical,10,2,7 +Mental,10,2,7`, + ); + + expect(result).toEqual({ + ok: true, + data: { + title: "trait", + labels: ["Dunk", "Egg", "Daeron"], + series: [ + { title: "Physical", values: [10, 2, 7] }, + { title: "Mental", values: [10, 2, 7] }, + ], + }, + }); + }); + + it("transposes single data row with header into single series", () => { + const result = tryParseSpreadsheet( + `trait,Dunk,Egg,Daeron +Physical,10,2,7`, + ); + + expect(result).toEqual({ + ok: true, + data: { + title: "Physical", + labels: ["Dunk", "Egg", "Daeron"], + series: [{ title: "Physical", values: [10, 2, 7] }], + }, + }); + }); + + it("transposes single data row without header into single series", () => { + const result = tryParseSpreadsheet(`Physical,10,2,7`); + + expect(result).toEqual({ + ok: true, + data: { + title: "Physical", + labels: null, + series: [{ title: "Physical", values: [10, 2, 7] }], + }, + }); + }); + + it("prefers tab over comma/semicolon when tabs produce multiple columns", () => { + const result = tryParseSpreadsheet( + `Label\tValue +A\t10 +B\t20`, + ); + + expect(result).toEqual({ + ok: true, + data: { + title: "Value", + labels: ["A", "B"], + series: [{ title: "Value", values: [10, 20] }], + }, + }); + }); }); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 02f70f62b4..07701e17bb 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -20,7 +20,6 @@ import type { GroupId, ExcalidrawBindableElement, Arrowhead, - ChartType, FontFamilyValues, FileId, Theme, @@ -383,7 +382,8 @@ export interface AppState { | { name: "ttd"; tab: "text-to-diagram" | "mermaid" } | { name: "commandPalette" } | { name: "settings" } - | { name: "elementLinkSelector"; sourceElementId: ExcalidrawElement["id"] }; + | { name: "elementLinkSelector"; sourceElementId: ExcalidrawElement["id"] } + | { name: "charts"; data: Spreadsheet; rawText: string }; /** * Reflects user preference for whether the default sidebar should be docked. * @@ -425,16 +425,6 @@ export interface AppState { /** bitmap. Use `STATS_PANELS` bit values */ panels: number; }; - currentChartType: ChartType; - pasteDialog: - | { - shown: false; - data: null; - } - | { - shown: true; - data: Spreadsheet; - }; showHyperlinkPopup: false | "info" | "editor"; selectedLinearElement: LinearElementEditor | null; snapLines: readonly SnapLine[]; diff --git a/packages/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap index f914a2bf2b..8b239c7d04 100644 --- a/packages/utils/tests/__snapshots__/export.test.ts.snap +++ b/packages/utils/tests/__snapshots__/export.test.ts.snap @@ -15,7 +15,6 @@ exports[`exportToSvg > with default arguments 1`] = ` "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -75,10 +74,6 @@ exports[`exportToSvg > with default arguments 1`] = ` "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, "preferredSelectionTool": { From 437595fa652b77df700c7f4e6d4fcd8d9ff42000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Tue, 3 Mar 2026 22:55:40 +0100 Subject: [PATCH 14/62] feat: Arrow binding is a preference (#10839) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/element/src/Scene.ts | 2 + packages/element/src/binding.ts | 71 ++- packages/element/src/elbowArrow.ts | 16 +- packages/element/src/linearElementEditor.ts | 68 ++- packages/element/src/mutateElement.ts | 2 + packages/element/src/utils.ts | 19 +- .../excalidraw/actions/actionProperties.tsx | 2 + .../actions/actionToggleArrowBinding.tsx | 26 + .../actions/actionToggleMidpointSnapping.tsx | 23 + packages/excalidraw/actions/index.ts | 2 + packages/excalidraw/actions/types.ts | 2 + packages/excalidraw/appState.ts | 6 +- packages/excalidraw/components/App.tsx | 55 +- .../components/canvases/InteractiveCanvas.tsx | 1 + .../components/main-menu/DefaultItems.tsx | 40 ++ packages/excalidraw/locales/en.json | 4 +- .../excalidraw/renderer/interactiveScene.ts | 141 ++--- .../__snapshots__/contextmenu.test.tsx.snap | 56 ++ .../tests/__snapshots__/history.test.tsx.snap | 126 +++++ .../regressionTests.test.tsx.snap | 106 +++- .../excalidraw/tests/arrowBinding.test.tsx | 526 ++++++++++++++++++ .../excalidraw/tests/contextmenu.test.tsx | 5 +- packages/excalidraw/types.ts | 9 + .../tests/__snapshots__/export.test.ts.snap | 2 + 24 files changed, 1164 insertions(+), 146 deletions(-) create mode 100644 packages/excalidraw/actions/actionToggleArrowBinding.tsx create mode 100644 packages/excalidraw/actions/actionToggleMidpointSnapping.tsx create mode 100644 packages/excalidraw/tests/arrowBinding.test.tsx diff --git a/packages/element/src/Scene.ts b/packages/element/src/Scene.ts index eaef257960..4ba663ceba 100644 --- a/packages/element/src/Scene.ts +++ b/packages/element/src/Scene.ts @@ -438,6 +438,8 @@ export class Scene { options: { informMutation: boolean; isDragging: boolean; + isBindingEnabled?: boolean; + isMidpointSnappingEnabled?: boolean; } = { informMutation: true, isDragging: false, diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index bf1eeb63c7..566ef3c4e4 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1,5 +1,4 @@ import { - KEYS, arrayToMap, getFeatureFlag, invariant, @@ -137,12 +136,6 @@ export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => { ); }; -export const shouldEnableBindingForPointerEvent = ( - event: React.PointerEvent, -) => { - return !event[KEYS.CTRL_OR_CMD]; -}; - export const isBindingEnabled = (appState: { isBindingEnabled: AppState["isBindingEnabled"]; }): boolean => { @@ -177,8 +170,20 @@ export const bindOrUnbindBindingElement = ( }, ); - bindOrUnbindBindingElementEdge(arrow, start, "start", scene); - bindOrUnbindBindingElementEdge(arrow, end, "end", scene); + bindOrUnbindBindingElementEdge( + arrow, + start, + "start", + scene, + appState.isBindingEnabled, + ); + bindOrUnbindBindingElementEdge( + arrow, + end, + "end", + scene, + appState.isBindingEnabled, + ); if (start.focusPoint || end.focusPoint) { // If the strategy dictates a focus point override, then // update the arrow points to point to the focus point. @@ -221,12 +226,21 @@ const bindOrUnbindBindingElementEdge = ( { mode, element, focusPoint }: BindingStrategy, startOrEnd: "start" | "end", scene: Scene, + shouldSnapToOutline = true, ): void => { if (mode === null) { // null means break the binding unbindBindingElement(arrow, startOrEnd, scene); } else if (mode !== undefined) { - bindBindingElement(arrow, element, mode, startOrEnd, scene, focusPoint); + bindBindingElement( + arrow, + element, + mode, + startOrEnd, + scene, + focusPoint, + shouldSnapToOutline, + ); } }; @@ -798,6 +812,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( startDragged ? "start" : "end", elementsMap, appState.zoom, + appState.isMidpointSnappingEnabled, ) || globalPoint, } : { mode: null }; @@ -842,6 +857,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( startDragged ? "end" : "start", elementsMap, appState.zoom, + appState.isMidpointSnappingEnabled, ) || otherEndpoint, } : { mode: undefined } @@ -1005,6 +1021,7 @@ export const bindBindingElement = ( startOrEnd: "start" | "end", scene: Scene, focusPoint?: GlobalPoint, + shouldSnapToOutline = true, ): void => { const elementsMap = scene.getNonDeletedElementsMap(); @@ -1019,6 +1036,7 @@ export const bindBindingElement = ( hoveredElement, startOrEnd, elementsMap, + shouldSnapToOutline, ), }; } else { @@ -1352,6 +1370,7 @@ export const bindPointToSnapToElementOutline = ( startOrEnd: "start" | "end", elementsMap: ElementsMap, customIntersector?: LineSegment, + isMidpointSnappingEnabled = true, ): GlobalPoint => { const elbowed = isElbowArrow(arrowElement); const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( @@ -1391,13 +1410,9 @@ export const bindPointToSnapToElementOutline = ( const isHorizontal = headingIsHorizontal( headingForPointFromElement(bindableElement, aabb, point), ); - const snapPoint = snapToMid( - bindableElement, - elementsMap, - edgePoint, - 0.05, - arrowElement, - ); + const snapPoint = isMidpointSnappingEnabled + ? snapToMid(bindableElement, elementsMap, edgePoint, 0.05, arrowElement) + : undefined; const resolved = snapPoint || point; const otherPoint = pointFrom( isHorizontal ? bindableCenter[0] : resolved[0], @@ -1892,6 +1907,8 @@ export const calculateFixedPointForElbowArrowBinding = ( hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", elementsMap: ElementsMap, + shouldSnapToOutline = true, + isMidpointSnappingEnabled = true, ): { fixedPoint: FixedPoint } => { const bounds = [ hoveredElement.x, @@ -1899,12 +1916,20 @@ export const calculateFixedPointForElbowArrowBinding = ( hoveredElement.x + hoveredElement.width, hoveredElement.y + hoveredElement.height, ] as Bounds; - const snappedPoint = bindPointToSnapToElementOutline( - linearElement, - hoveredElement, - startOrEnd, - elementsMap, - ); + const snappedPoint = shouldSnapToOutline + ? bindPointToSnapToElementOutline( + linearElement, + hoveredElement, + startOrEnd, + elementsMap, + undefined, + isMidpointSnappingEnabled, + ) + : LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + startOrEnd === "start" ? 0 : -1, + elementsMap, + ); const globalMidPoint = pointFrom( bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[1] + (bounds[3] - bounds[1]) / 2, diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index 63b3b7926d..9543b4182f 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -915,6 +915,8 @@ export const updateElbowArrowPoints = ( }, options?: { isDragging?: boolean; + isBindingEnabled?: boolean; + isMidpointSnappingEnabled?: boolean; }, ): ElementUpdate => { if (arrow.points.length < 2) { @@ -1202,6 +1204,8 @@ const getElbowArrowData = ( options?: { isDragging?: boolean; zoom?: AppState["zoom"]; + isBindingEnabled?: boolean; + isMidpointSnappingEnabled?: boolean; }, ) => { const origStartGlobalPoint: GlobalPoint = pointTranslate< @@ -1215,7 +1219,7 @@ const getElbowArrowData = ( let hoveredStartElement = null; let hoveredEndElement = null; - if (options?.isDragging) { + if (options?.isDragging && options?.isBindingEnabled !== false) { const elements = Array.from(elementsMap.values()); hoveredStartElement = getHoveredElement( @@ -1255,6 +1259,8 @@ const getElbowArrowData = ( hoveredStartElement, elementsMap, options?.isDragging, + options?.isBindingEnabled, + options?.isMidpointSnappingEnabled, ); const endGlobalPoint = getGlobalPoint( { @@ -1270,6 +1276,8 @@ const getElbowArrowData = ( hoveredEndElement, elementsMap, options?.isDragging, + options?.isBindingEnabled, + options?.isMidpointSnappingEnabled, ); const startHeading = getBindPointHeading( startGlobalPoint, @@ -2213,14 +2221,18 @@ const getGlobalPoint = ( element?: ExcalidrawBindableElement | null, elementsMap?: ElementsMap, isDragging?: boolean, + isBindingEnabled = true, + isMidpointSnappingEnabled = true, ): GlobalPoint => { if (isDragging) { - if (element && elementsMap) { + if (isBindingEnabled && element && elementsMap) { return bindPointToSnapToElementOutline( arrow, element, startOrEnd, elementsMap, + undefined, + isMidpointSnappingEnabled, ); } diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index e3d3f2e510..e57211abbc 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -359,11 +359,20 @@ export class LinearElementEditor { linearElementEditor, ); - LinearElementEditor.movePoints(element, app.scene, positions, { - startBinding: updates?.startBinding, - endBinding: updates?.endBinding, - moveMidPointsWithElement: updates?.moveMidPointsWithElement, - }); + LinearElementEditor.movePoints( + element, + app.scene, + positions, + { + startBinding: updates?.startBinding, + endBinding: updates?.endBinding, + moveMidPointsWithElement: updates?.moveMidPointsWithElement, + }, + { + isBindingEnabled: app.state.isBindingEnabled, + isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled, + }, + ); // Set the suggested binding from the updates if available if (isBindingElement(element, false)) { if (isBindingEnabled(app.state)) { @@ -418,6 +427,7 @@ export class LinearElementEditor { "start", elementsMap, app.state.zoom, + app.state.isMidpointSnappingEnabled, ) : linearElementEditor.initialState.altFocusPoint, }, @@ -538,11 +548,20 @@ export class LinearElementEditor { linearElementEditor, ); - LinearElementEditor.movePoints(element, app.scene, positions, { - startBinding: updates?.startBinding, - endBinding: updates?.endBinding, - moveMidPointsWithElement: updates?.moveMidPointsWithElement, - }); + LinearElementEditor.movePoints( + element, + app.scene, + positions, + { + startBinding: updates?.startBinding, + endBinding: updates?.endBinding, + moveMidPointsWithElement: updates?.moveMidPointsWithElement, + }, + { + isBindingEnabled: app.state.isBindingEnabled, + isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled, + }, + ); // Set the suggested binding from the updates if available if (isBindingElement(element, false)) { @@ -636,6 +655,7 @@ export class LinearElementEditor { "start", elementsMap, app.state.zoom, + app.state.isMidpointSnappingEnabled, ) : linearElementEditor.initialState.altFocusPoint, }, @@ -1524,6 +1544,10 @@ export class LinearElementEditor { endBinding?: FixedPointBinding | null; moveMidPointsWithElement?: boolean | null; }, + options?: { + isBindingEnabled?: boolean; + isMidpointSnappingEnabled?: boolean; + }, ) { const { points } = element; @@ -1592,6 +1616,8 @@ export class LinearElementEditor { otherUpdates, { isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging), + isBindingEnabled: options?.isBindingEnabled, + isMidpointSnappingEnabled: options?.isMidpointSnappingEnabled, }, ); } @@ -1706,6 +1732,8 @@ export class LinearElementEditor { isDragging?: boolean; zoom?: AppState["zoom"]; sceneElementsMap?: NonDeletedSceneElementsMap; + isBindingEnabled?: boolean; + isMidpointSnappingEnabled?: boolean; }, ) { if (isElbowArrow(element)) { @@ -1726,6 +1754,8 @@ export class LinearElementEditor { scene.mutateElement(element, updates, { informMutation: true, isDragging: options?.isDragging ?? false, + isBindingEnabled: options?.isBindingEnabled, + isMidpointSnappingEnabled: options?.isMidpointSnappingEnabled, }); } else { // TODO do we need to get precise coords here just to calc centers? @@ -2145,14 +2175,16 @@ const pointDraggingUpdates = ( suggestedBinding: suggestedBindingElement ? { element: suggestedBindingElement, - midPoint: snapToMid( - suggestedBindingElement, - elementsMap, - pointFrom( - scenePointerX - linearElementEditor.pointerOffset.x, - scenePointerY - linearElementEditor.pointerOffset.y, - ), - ), + midPoint: app.state.isMidpointSnappingEnabled + ? snapToMid( + suggestedBindingElement, + elementsMap, + pointFrom( + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + ), + ) + : undefined, } : null, }, diff --git a/packages/element/src/mutateElement.ts b/packages/element/src/mutateElement.ts index c45c6df08c..eb6350c2bf 100644 --- a/packages/element/src/mutateElement.ts +++ b/packages/element/src/mutateElement.ts @@ -40,6 +40,8 @@ export const mutateElement = >( updates: ElementUpdate, options?: { isDragging?: boolean; + isBindingEnabled?: boolean; + isMidpointSnappingEnabled?: boolean; }, ) => { let didChange = false; diff --git a/packages/element/src/utils.ts b/packages/element/src/utils.ts index 7013acf2f1..96e09bcbf1 100644 --- a/packages/element/src/utils.ts +++ b/packages/element/src/utils.ts @@ -659,20 +659,23 @@ export const projectFixedPointOntoDiagonal = ( startOrEnd: "start" | "end", elementsMap: ElementsMap, zoom: AppState["zoom"], + isMidpointSnappingEnabled: boolean = true, ): GlobalPoint | null => { invariant(arrow.points.length >= 2, "Arrow must have at least two points"); if (arrow.width < 3 && arrow.height < 3) { return null; } - const sideMidPoint = getSnapOutlineMidPoint( - point, - element, - elementsMap, - zoom, - ); - if (sideMidPoint) { - return sideMidPoint; + if (isMidpointSnappingEnabled) { + const sideMidPoint = getSnapOutlineMidPoint( + point, + element, + elementsMap, + zoom, + ); + if (sideMidPoint) { + return sideMidPoint; + } } // Do the projection onto the diagonals (or center lines diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 7fb9ed0e86..93fe0ada1a 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1830,6 +1830,7 @@ export const actionChangeArrowType = register({ startElement, "start", elementsMap, + appState.isBindingEnabled, ), } : null; @@ -1843,6 +1844,7 @@ export const actionChangeArrowType = register({ endElement, "end", elementsMap, + appState.isBindingEnabled, ), } : null; diff --git a/packages/excalidraw/actions/actionToggleArrowBinding.tsx b/packages/excalidraw/actions/actionToggleArrowBinding.tsx new file mode 100644 index 0000000000..a4e6e50ed2 --- /dev/null +++ b/packages/excalidraw/actions/actionToggleArrowBinding.tsx @@ -0,0 +1,26 @@ +import { CaptureUpdateAction } from "@excalidraw/element"; + +import { register } from "./register"; + +export const actionToggleArrowBinding = register({ + name: "arrowBinding", + label: "labels.arrowBinding", + viewMode: false, + trackEvent: { + category: "canvas", + predicate: (appState) => appState.bindingPreference === "disabled", + }, + perform(elements, appState) { + const newPreference = + appState.bindingPreference === "enabled" ? "disabled" : "enabled"; + return { + appState: { + ...appState, + bindingPreference: newPreference, + isBindingEnabled: newPreference === "enabled", + }, + captureUpdate: CaptureUpdateAction.NEVER, + }; + }, + checked: (appState) => appState.bindingPreference === "enabled", +}); diff --git a/packages/excalidraw/actions/actionToggleMidpointSnapping.tsx b/packages/excalidraw/actions/actionToggleMidpointSnapping.tsx new file mode 100644 index 0000000000..eca9df7e27 --- /dev/null +++ b/packages/excalidraw/actions/actionToggleMidpointSnapping.tsx @@ -0,0 +1,23 @@ +import { CaptureUpdateAction } from "@excalidraw/element"; + +import { register } from "./register"; + +export const actionToggleMidpointSnapping = register({ + name: "midpointSnapping", + label: "labels.midpointSnapping", + viewMode: false, + trackEvent: { + category: "canvas", + predicate: (appState) => !appState.isMidpointSnappingEnabled, + }, + perform(elements, appState) { + return { + appState: { + ...appState, + isMidpointSnappingEnabled: !this.checked!(appState), + }, + captureUpdate: CaptureUpdateAction.NEVER, + }; + }, + checked: (appState) => appState.isMidpointSnappingEnabled, +}); diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index 6b888e92d3..cc9ca1789c 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -79,6 +79,8 @@ export { export { actionToggleGridMode } from "./actionToggleGridMode"; export { actionToggleZenMode } from "./actionToggleZenMode"; export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode"; +export { actionToggleArrowBinding } from "./actionToggleArrowBinding"; +export { actionToggleMidpointSnapping } from "./actionToggleMidpointSnapping"; export { actionToggleStats } from "./actionToggleStats"; export { actionUnbindText, actionBindText } from "./actionBoundText"; diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index c85b0639ef..ae80e4107c 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -59,6 +59,8 @@ export type ActionName = | "gridMode" | "zenMode" | "objectsSnapMode" + | "arrowBinding" + | "midpointSnapping" | "stats" | "changeStrokeColor" | "changeBackgroundColor" diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 18e303db29..e51865b2ea 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -70,6 +70,8 @@ export const getDefaultAppState = (): Omit< gridStep: DEFAULT_GRID_STEP, gridModeEnabled: false, isBindingEnabled: true, + bindingPreference: "enabled", + isMidpointSnappingEnabled: true, defaultSidebarDockedPreference: false, isLoading: false, isResizing: false, @@ -190,7 +192,9 @@ const APP_STATE_STORAGE_CONF = (< gridStep: { browser: true, export: true, server: true }, gridModeEnabled: { browser: true, export: true, server: true }, height: { browser: false, export: false, server: false }, - isBindingEnabled: { browser: false, export: false, server: false }, + isBindingEnabled: { browser: true, export: false, server: false }, + bindingPreference: { browser: true, export: false, server: false }, + isMidpointSnappingEnabled: { browser: true, export: false, server: false }, defaultSidebarDockedPreference: { browser: true, export: false, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index adbcce44e5..0b361e0e70 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -119,7 +119,6 @@ import { fixBindingsAfterDeletion, getHoveredElementForBinding, isBindingEnabled, - shouldEnableBindingForPointerEvent, updateBoundElements, LinearElementEditor, newElementWith, @@ -319,6 +318,8 @@ import { actionToggleElementLock, actionToggleLinearEditor, actionToggleObjectsSnapMode, + actionToggleArrowBinding, + actionToggleMidpointSnapping, actionToggleCropEditor, } from "../actions"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; @@ -2734,7 +2735,9 @@ class App extends React.Component { private onBlur = withBatchedUpdates(() => { isHoldingSpace = false; - this.setState({ isBindingEnabled: true }); + this.setState({ + isBindingEnabled: this.state.bindingPreference === "enabled", + }); }); private onUnload = () => { @@ -4937,13 +4940,15 @@ class App extends React.Component { return; } - if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) { + if (event[KEYS.CTRL_OR_CMD] && !event.repeat) { if (getFeatureFlag("COMPLEX_BINDINGS")) { this.resetDelayedBindMode(); } flushSync(() => { - this.setState({ isBindingEnabled: false }); + this.setState({ + isBindingEnabled: this.state.bindingPreference !== "enabled", + }); }); maybeHandleArrowPointlikeDrag({ app: this, event }); @@ -5217,10 +5222,13 @@ class App extends React.Component { } } } - if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) { - flushSync(() => { - this.setState({ isBindingEnabled: true }); - }); + if (!event[KEYS.CTRL_OR_CMD]) { + const preferenceEnabled = this.state.bindingPreference === "enabled"; + if (this.state.isBindingEnabled !== preferenceEnabled) { + flushSync(() => { + this.setState({ isBindingEnabled: preferenceEnabled }); + }); + } maybeHandleArrowPointlikeDrag({ app: this, event }); } @@ -7138,6 +7146,14 @@ class App extends React.Component { private handleCanvasPointerDown = ( event: React.PointerEvent, ) => { + // If Ctrl is not held, ensure isBindingEnabled reflects the user preference. + if (!event.ctrlKey) { + const preferenceEnabled = this.state.bindingPreference === "enabled"; + if (this.state.isBindingEnabled !== preferenceEnabled) { + this.setState({ isBindingEnabled: preferenceEnabled }); + } + } + const scenePointer = viewportCoordsToSceneCoords(event, this.state); const { x: scenePointerX, y: scenePointerY } = scenePointer; this.lastPointerMoveCoords = { @@ -7358,7 +7374,6 @@ class App extends React.Component { } this.clearSelectionIfNotUsingSelection(); - this.updateBindingEnabledOnPointerMove(event); if (this.handleSelectionOnPointerDown(event, pointerDownState)) { return; @@ -7581,6 +7596,13 @@ class App extends React.Component { this.removePointer(event); this.lastPointerUpEvent = event; + if (!event.ctrlKey) { + const preferenceEnabled = this.state.bindingPreference === "enabled"; + if (this.state.isBindingEnabled !== preferenceEnabled) { + this.setState({ isBindingEnabled: preferenceEnabled }); + } + } + const scenePointer = viewportCoordsToSceneCoords( { clientX: event.clientX, clientY: event.clientY }, this.state, @@ -8636,7 +8658,9 @@ class App extends React.Component { ): void => { if (event.ctrlKey) { flushSync(() => { - this.setState({ isBindingEnabled: false }); + this.setState({ + isBindingEnabled: this.state.bindingPreference !== "enabled", + }); }); } @@ -11330,15 +11354,6 @@ class App extends React.Component { this.addNewImagesToImageCache(); }, IMAGE_RENDER_TIMEOUT); - private updateBindingEnabledOnPointerMove = ( - event: React.PointerEvent, - ) => { - const shouldEnableBinding = shouldEnableBindingForPointerEvent(event); - if (this.state.isBindingEnabled !== shouldEnableBinding) { - this.setState({ isBindingEnabled: shouldEnableBinding }); - } - }; - private clearSelection(hitElement: ExcalidrawElement | null): void { this.setState((prevState) => ({ selectedElementIds: makeNextSelectedElementIds({}, prevState), @@ -12100,6 +12115,8 @@ class App extends React.Component { CONTEXT_MENU_SEPARATOR, actionToggleGridMode, actionToggleObjectsSnapMode, + actionToggleArrowBinding, + actionToggleMidpointSnapping, actionToggleZenMode, actionToggleViewMode, actionToggleStats, diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 15ecb61eaf..c6245953b9 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -249,6 +249,7 @@ const getRelevantAppStateProps = ( multiElement: appState.multiElement, newElement: appState.newElement, isBindingEnabled: appState.isBindingEnabled, + isMidpointSnappingEnabled: appState.isMidpointSnappingEnabled, suggestedBinding: appState.suggestedBinding, isRotating: appState.isRotating, elementsToHighlight: appState.elementsToHighlight, diff --git a/packages/excalidraw/components/main-menu/DefaultItems.tsx b/packages/excalidraw/components/main-menu/DefaultItems.tsx index 04cea3da48..8865dfa7ae 100644 --- a/packages/excalidraw/components/main-menu/DefaultItems.tsx +++ b/packages/excalidraw/components/main-menu/DefaultItems.tsx @@ -9,7 +9,9 @@ import { actionLoadScene, actionSaveToActiveFile, actionShortcuts, + actionToggleArrowBinding, actionToggleGridMode, + actionToggleMidpointSnapping, actionToggleObjectsSnapMode, actionToggleSearchMenu, actionToggleStats, @@ -443,6 +445,40 @@ const PreferencesToggleSnapModeItem = () => { ); }; +const PreferencesToggleArrowBindingItem = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + const appState = useUIAppState(); + return ( + { + actionManager.executeAction(actionToggleArrowBinding); + event.preventDefault(); + }} + > + {t("labels.arrowBinding")} + + ); +}; + +const PreferencesToggleMidpointSnappingItem = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + const appState = useUIAppState(); + return ( + { + actionManager.executeAction(actionToggleMidpointSnapping); + event.preventDefault(); + }} + > + {t("labels.midpointSnapping")} + + ); +}; + export const PreferencesToggleGridModeItem = () => { const { t } = useI18n(); const actionManager = useExcalidrawActionManager(); @@ -538,6 +574,8 @@ export const Preferences = ({ + + )} {additionalItems} @@ -548,6 +586,8 @@ export const Preferences = ({ Preferences.ToggleToolLock = PreferencesToggleToolLockItem; Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem; +Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem; +Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem; Preferences.ToggleGridMode = PreferencesToggleGridModeItem; Preferences.ToggleZenMode = PreferencesToggleZenModeItem; Preferences.ToggleViewMode = PreferencesToggleViewModeItem; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 18e8c88908..7a2a7294f8 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -177,7 +177,9 @@ "tab": "Tab", "shapeSwitch": "Switch shape", "preferences": "Preferences", - "preferences_toolLock": "Tool lock" + "preferences_toolLock": "Tool lock", + "arrowBinding": "Arrow binding", + "midpointSnapping": "Snap to midpoints" }, "elementLink": { "title": "Link to object", diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index fc9331e6ea..56c308713e 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -407,8 +407,9 @@ const renderBindingHighlightForBindableElement_simple = ( } if ( - isFrameLikeElement(suggestedBinding.element) || - isBindableElement(suggestedBinding.element) + appState.isMidpointSnappingEnabled && + (isFrameLikeElement(suggestedBinding.element) || + isBindableElement(suggestedBinding.element)) ) { // Draw midpoint indicators const linearElement = appState.selectedLinearElement; @@ -799,77 +800,79 @@ const renderBindingHighlightForBindableElement_complex = ( context.restore(); - // Draw midpoint indicators - context.save(); - context.translate( - element.x + appState.scrollX, - element.y + appState.scrollY, - ); - - const midpointRadius = 5 / appState.zoom.value; - const cutoutPadding = 5 / appState.zoom.value; - const cutoutRadius = midpointRadius + cutoutPadding; - - let midpoints; - if (element.type === "diamond") { - const [, curves] = deconstructDiamondElement(element); - const center = elementCenterPoint(element, allElementsMap); - - midpoints = curves.map((curve) => { - const point = bezierEquation(curve, 0.5); - const rotatedPoint = pointRotateRads(point, center, element.angle); - return { - x: rotatedPoint[0] - element.x, - y: rotatedPoint[1] - element.y, - }; - }); - } else { - const center = elementCenterPoint(element, allElementsMap); - const basePoints = [ - { x: element.width / 2, y: 0 }, // TOP - { x: element.width, y: element.height / 2 }, // RIGHT - { x: element.width / 2, y: element.height }, // BOTTOM - { x: 0, y: element.height / 2 }, // LEFT - ]; - midpoints = basePoints.map((point) => { - const globalPoint = pointFrom( - point.x + element.x, - point.y + element.y, - ); - const rotatedPoint = pointRotateRads( - globalPoint, - center, - element.angle, - ); - return { - x: rotatedPoint[0] - element.x, - y: rotatedPoint[1] - element.y, - }; - }); - } - - // Clear cutouts around midpoints - midpoints.forEach((midpoint) => { - context.clearRect( - midpoint.x - cutoutRadius, - midpoint.y - cutoutRadius, - cutoutRadius * 2, - cutoutRadius * 2, + if (appState.isMidpointSnappingEnabled) { + // Draw midpoint indicators + context.save(); + context.translate( + element.x + appState.scrollX, + element.y + appState.scrollY, ); - }); - context.fillStyle = - appState.theme === THEME.DARK - ? `rgba(3, 93, 161, ${opacity})` - : `rgba(106, 189, 252, ${opacity})`; + const midpointRadius = 5 / appState.zoom.value; + const cutoutPadding = 5 / appState.zoom.value; + const cutoutRadius = midpointRadius + cutoutPadding; - midpoints.forEach((midpoint) => { - context.beginPath(); - context.arc(midpoint.x, midpoint.y, midpointRadius, 0, 2 * Math.PI); - context.fill(); - }); + let midpoints; + if (element.type === "diamond") { + const [, curves] = deconstructDiamondElement(element); + const center = elementCenterPoint(element, allElementsMap); - context.restore(); + midpoints = curves.map((curve) => { + const point = bezierEquation(curve, 0.5); + const rotatedPoint = pointRotateRads(point, center, element.angle); + return { + x: rotatedPoint[0] - element.x, + y: rotatedPoint[1] - element.y, + }; + }); + } else { + const center = elementCenterPoint(element, allElementsMap); + const basePoints = [ + { x: element.width / 2, y: 0 }, // TOP + { x: element.width, y: element.height / 2 }, // RIGHT + { x: element.width / 2, y: element.height }, // BOTTOM + { x: 0, y: element.height / 2 }, // LEFT + ]; + midpoints = basePoints.map((point) => { + const globalPoint = pointFrom( + point.x + element.x, + point.y + element.y, + ); + const rotatedPoint = pointRotateRads( + globalPoint, + center, + element.angle, + ); + return { + x: rotatedPoint[0] - element.x, + y: rotatedPoint[1] - element.y, + }; + }); + } + + // Clear cutouts around midpoints + midpoints.forEach((midpoint) => { + context.clearRect( + midpoint.x - cutoutRadius, + midpoint.y - cutoutRadius, + cutoutRadius * 2, + cutoutRadius * 2, + ); + }); + + context.fillStyle = + appState.theme === THEME.DARK + ? `rgba(3, 93, 161, ${opacity})` + : `rgba(106, 189, 252, ${opacity})`; + + midpoints.forEach((midpoint) => { + context.beginPath(); + context.arc(midpoint.x, midpoint.y, midpointRadius, 0, 2 * Math.PI); + context.fill(); + }); + + context.restore(); + } } return { diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 38f748b817..15458fa366 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 "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": { "items": [ @@ -932,6 +933,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -1083,6 +1085,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1129,6 +1132,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -1295,6 +1299,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1341,6 +1346,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -1624,6 +1630,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1670,6 +1677,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -1953,6 +1961,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1999,6 +2008,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2165,6 +2175,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2211,6 +2222,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2404,6 +2416,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2450,6 +2463,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2700,6 +2714,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2746,6 +2761,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -3070,6 +3086,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3116,6 +3133,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -3561,6 +3579,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3607,6 +3626,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -3882,6 +3902,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3928,6 +3949,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -4203,6 +4225,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4249,6 +4272,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -4612,6 +4636,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": { "items": [ @@ -5532,6 +5557,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -5827,6 +5853,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": { "items": [ @@ -6747,6 +6774,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -7093,6 +7121,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": { "items": [ @@ -7468,6 +7497,28 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app }, "viewMode": false, }, + { + "checked": [Function], + "label": "labels.arrowBinding", + "name": "arrowBinding", + "perform": [Function], + "trackEvent": { + "category": "canvas", + "predicate": [Function], + }, + "viewMode": false, + }, + { + "checked": [Function], + "label": "labels.midpointSnapping", + "name": "midpointSnapping", + "perform": [Function], + "trackEvent": { + "category": "canvas", + "predicate": [Function], + }, + "viewMode": false, + }, { "checked": [Function], "icon": shows context menu for canvas > [end of test] app "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -7758,6 +7810,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": { "items": [ @@ -8678,6 +8731,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -8747,6 +8801,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": { "items": [ @@ -9667,6 +9722,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 97204839fc..c81459c1c4 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -12,6 +12,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -58,6 +59,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -641,6 +643,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -687,6 +690,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -1200,6 +1204,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1246,6 +1251,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -1558,6 +1564,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1604,6 +1611,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -1918,6 +1926,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1964,6 +1973,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2179,6 +2189,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2225,6 +2236,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2630,6 +2642,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2676,6 +2689,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2931,6 +2945,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2977,6 +2992,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -3248,6 +3264,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3294,6 +3311,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -3540,6 +3558,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3586,6 +3605,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -3824,6 +3844,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3870,6 +3891,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -4057,6 +4079,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4103,6 +4126,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -4312,6 +4336,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4358,6 +4383,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -4581,6 +4607,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4627,6 +4654,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -4808,6 +4836,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4854,6 +4883,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -5035,6 +5065,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5081,6 +5112,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -5280,6 +5312,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5326,6 +5359,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -5534,6 +5568,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5580,6 +5615,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -5790,6 +5826,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5836,6 +5873,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -6117,6 +6155,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6163,6 +6202,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -6542,6 +6582,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6588,6 +6629,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -6914,6 +6956,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6960,6 +7003,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -7224,6 +7268,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7270,6 +7315,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -7514,6 +7560,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7560,6 +7607,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -7742,6 +7790,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7788,6 +7837,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -8092,6 +8142,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8138,6 +8189,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -8442,6 +8494,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8488,6 +8541,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -8846,6 +8900,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "type": "freedraw", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8892,6 +8947,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -9123,6 +9179,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9169,6 +9226,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -9385,6 +9443,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9431,6 +9490,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -9648,6 +9708,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9694,6 +9755,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -9878,6 +9940,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9924,6 +9987,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -10173,6 +10237,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10219,6 +10284,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -10488,6 +10554,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10534,6 +10601,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -10722,6 +10790,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10768,6 +10837,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -11162,6 +11232,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11208,6 +11279,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -11420,6 +11492,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11466,6 +11539,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -11653,6 +11727,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11699,6 +11774,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -11888,6 +11964,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "type": "freedraw", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11934,6 +12011,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -12277,6 +12355,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12323,6 +12402,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -12485,6 +12565,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12531,6 +12612,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -12690,6 +12772,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12736,6 +12819,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -12989,6 +13073,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13035,6 +13120,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -13285,6 +13371,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13331,6 +13418,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -13528,6 +13616,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13574,6 +13663,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -13763,6 +13853,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13809,6 +13900,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -13998,6 +14090,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14044,6 +14137,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -14243,6 +14337,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14289,6 +14384,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -14572,6 +14668,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14618,6 +14715,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -14740,6 +14838,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14786,6 +14885,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -15022,6 +15122,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15068,6 +15169,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -15283,6 +15385,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15329,6 +15432,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -15434,6 +15538,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15480,6 +15585,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -15714,6 +15820,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15760,6 +15867,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -15874,6 +15982,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -15920,6 +16029,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -16620,6 +16730,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -16666,6 +16777,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -17264,6 +17376,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -17310,6 +17423,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -17908,6 +18022,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -17954,6 +18069,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -18655,6 +18771,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -18701,6 +18818,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -19421,6 +19539,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -19467,6 +19586,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -19899,6 +20019,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -19945,6 +20066,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -20408,6 +20530,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20454,6 +20577,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -20865,6 +20989,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -20911,6 +21036,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index c7dd74b91f..e97991d96f 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -12,6 +12,7 @@ exports[`given element A and group of elements B and given both are selected whe "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -58,6 +59,7 @@ exports[`given element A and group of elements B and given both are selected whe "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -436,6 +438,7 @@ exports[`given element A and group of elements B and given both are selected whe "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -482,6 +485,7 @@ exports[`given element A and group of elements B and given both are selected whe "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -850,6 +854,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -893,9 +898,10 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "gridStep": 5, "height": 768, "hoveredElementIds": {}, - "isBindingEnabled": false, + "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -1414,6 +1420,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1460,6 +1467,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -1619,6 +1627,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -1665,6 +1674,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2001,6 +2011,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2047,6 +2058,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2244,6 +2256,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2290,6 +2303,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2422,6 +2436,7 @@ exports[`regression tests > can drag element that covers another element, while "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2468,6 +2483,7 @@ exports[`regression tests > can drag element that covers another element, while "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2745,6 +2761,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -2791,6 +2808,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2998,6 +3016,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3044,6 +3063,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -3237,6 +3257,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3283,6 +3304,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -3471,6 +3493,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3517,6 +3540,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -3727,6 +3751,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -3773,6 +3798,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -4039,6 +4065,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4085,6 +4112,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -4473,6 +4501,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4519,6 +4548,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -4754,6 +4784,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -4800,6 +4831,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -5028,6 +5060,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5074,6 +5107,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -5234,6 +5268,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5280,6 +5315,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -5432,6 +5468,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5478,6 +5515,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -5823,6 +5861,7 @@ exports[`regression tests > drags selected elements from point inside common bou "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -5869,6 +5908,7 @@ exports[`regression tests > drags selected elements from point inside common bou "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -6118,6 +6158,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "type": "freedraw", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6164,6 +6205,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -6904,6 +6946,7 @@ exports[`regression tests > given a group of selected elements with an element t "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -6950,6 +6993,7 @@ exports[`regression tests > given a group of selected elements with an element t "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -7236,6 +7280,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7282,6 +7327,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -7513,6 +7559,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7559,6 +7606,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -7746,6 +7794,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -7792,6 +7841,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -7984,6 +8034,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8030,6 +8081,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -8162,6 +8214,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8208,6 +8261,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -8340,6 +8394,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8386,6 +8441,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -8518,6 +8574,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8564,6 +8621,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -8748,6 +8806,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -8794,6 +8853,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -8976,6 +9036,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "type": "freedraw", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9022,6 +9083,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -9166,6 +9228,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9212,6 +9275,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -9396,6 +9460,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9442,6 +9507,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -9574,6 +9640,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9620,6 +9687,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -9802,6 +9870,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -9848,6 +9917,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -9980,6 +10050,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "type": "freedraw", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10026,6 +10097,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -10170,6 +10242,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10216,6 +10289,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -10348,6 +10422,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10394,6 +10469,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -10877,6 +10953,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -10923,6 +11000,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -11155,6 +11233,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11201,6 +11280,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "touch", @@ -11276,6 +11356,7 @@ exports[`regression tests > shift click on selected element should deselect it o "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11322,6 +11403,7 @@ exports[`regression tests > shift click on selected element should deselect it o "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -11474,6 +11556,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11520,6 +11603,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -11791,6 +11875,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -11837,6 +11922,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -12218,6 +12304,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12264,6 +12351,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -12856,6 +12944,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -12902,6 +12991,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -12980,6 +13070,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13026,6 +13117,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -13609,6 +13701,7 @@ exports[`regression tests > switches from group of selected elements to another "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13655,6 +13748,7 @@ exports[`regression tests > switches from group of selected elements to another "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -13946,6 +14040,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -13992,6 +14087,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -14208,6 +14304,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14254,6 +14351,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "touch", @@ -14329,6 +14427,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14375,6 +14474,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -14691,6 +14791,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "type": "text", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14737,6 +14838,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -14812,6 +14914,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -14858,6 +14961,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", diff --git a/packages/excalidraw/tests/arrowBinding.test.tsx b/packages/excalidraw/tests/arrowBinding.test.tsx new file mode 100644 index 0000000000..879b4b67eb --- /dev/null +++ b/packages/excalidraw/tests/arrowBinding.test.tsx @@ -0,0 +1,526 @@ +import { reseed } from "@excalidraw/common"; +import { + isElbowArrow, + projectFixedPointOntoDiagonal, +} from "@excalidraw/element"; + +import { pointFrom } from "@excalidraw/math"; + +import type { GlobalPoint, LocalPoint } from "@excalidraw/math"; + +import type { + ExcalidrawArrowElement, + ExcalidrawBindableElement, + ExcalidrawElement, +} from "@excalidraw/element/types"; + +import { actionToggleArrowBinding } from "../actions/actionToggleArrowBinding"; +import { Excalidraw, sceneCoordsToViewportCoords } from "../index"; + +import { API } from "./helpers/api"; +import { Pointer, UI } from "./helpers/ui"; +import { + render, + fireEvent, + mockBoundingClientRect, + restoreOriginalGetBoundingClientRect, + waitFor, + unmountComponent, +} from "./test-utils"; + +unmountComponent(); + +const { h } = window; +const mouse = new Pointer("mouse"); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Fire Ctrl (or Meta on Mac) keydown on document. */ +const ctrlKeyDown = (extra: Partial = {}) => + fireEvent.keyDown(document, { + key: "Control", + code: "ControlLeft", + ctrlKey: true, + repeat: false, + ...extra, + }); + +/** Fire Ctrl (or Meta on Mac) keyup on document (ctrlKey is false on keyup). */ +const ctrlKeyUp = () => + fireEvent.keyUp(document, { + key: "Control", + code: "ControlLeft", + ctrlKey: false, + }); + +// --------------------------------------------------------------------------- + +describe("Arrow binding – non-default case (bindingPreference: disabled)", () => { + beforeAll(() => { + mockBoundingClientRect(); + }); + + afterAll(() => { + restoreOriginalGetBoundingClientRect(); + }); + + beforeEach(async () => { + localStorage.clear(); + reseed(7); + await render(); + h.state.width = 1920; + h.state.height = 1080; + }); + + afterEach(() => { + mouse.reset(); + }); + + // ------------------------------------------------------------------------- + // actionToggleArrowBinding + // ------------------------------------------------------------------------- + + describe("actionToggleArrowBinding", () => { + it("isBindingEnabled defaults to true", () => { + expect(h.state.isBindingEnabled).toBe(true); + }); + + it("checked() reflects the current bindingPreference value", () => { + expect(actionToggleArrowBinding.checked!(h.state)).toBe(true); + + API.setAppState({ + bindingPreference: "disabled", + isBindingEnabled: false, + }); + expect(actionToggleArrowBinding.checked!(h.state)).toBe(false); + }); + + it("executing the action toggles binding from enabled → disabled", () => { + expect(h.state.isBindingEnabled).toBe(true); + API.executeAction(actionToggleArrowBinding); + expect(h.state.isBindingEnabled).toBe(false); + expect(h.state.bindingPreference).toBe("disabled"); + }); + + it("executing the action toggles binding from disabled → enabled", () => { + API.setAppState({ + bindingPreference: "disabled", + isBindingEnabled: false, + }); + + API.executeAction(actionToggleArrowBinding); + expect(h.state.isBindingEnabled).toBe(true); + expect(h.state.bindingPreference).toBe("enabled"); + }); + + it("checked() returns false after action disables binding", () => { + API.executeAction(actionToggleArrowBinding); // true → false + expect(actionToggleArrowBinding.checked!(h.state)).toBe(false); + }); + }); + + // ------------------------------------------------------------------------- + // Arrow does not bind when isBindingEnabled is false + // ------------------------------------------------------------------------- + + describe("Arrow drawing with binding disabled", () => { + /** + * Baseline: verify binding IS created with binding enabled so we know the + * spatial setup is correct. + */ + it("arrow startBinding is set when binding is enabled", async () => { + API.setElements([ + API.createElement({ + type: "rectangle", + id: "baselineRect", + x: 100, + y: 100, + width: 400, + height: 200, + }), + ]); + + expect(h.state.isBindingEnabled).toBe(true); + + UI.clickTool("arrow"); + // Start inside the rectangle so startBinding can be created + mouse.down(200, 200); + mouse.up(700, 200); + + await waitFor(() => { + const arrow = h.elements.find( + (el): el is ExcalidrawArrowElement => el.type === "arrow", + ); + expect(arrow).toBeDefined(); + expect(arrow!.startBinding).not.toBeNull(); + expect(arrow!.startBinding!.elementId).toBe("baselineRect"); + }); + }); + + it("arrow has no startBinding when binding is disabled", async () => { + API.setElements([ + API.createElement({ + type: "rectangle", + id: "rect1", + x: 100, + y: 100, + width: 400, + height: 200, + }), + ]); + + API.setAppState({ + bindingPreference: "disabled", + isBindingEnabled: false, + }); + + UI.clickTool("arrow"); + // Start inside the rectangle – binding is off, so no startBinding + mouse.down(200, 200); + mouse.up(700, 200); + + await waitFor(() => { + const arrow = h.elements.find( + (el): el is ExcalidrawArrowElement => el.type === "arrow", + ); + expect(arrow).toBeDefined(); + expect(arrow!.startBinding).toBeNull(); + }); + }); + + it("arrow has no endBinding when binding is disabled", async () => { + API.setElements([ + API.createElement({ + type: "rectangle", + id: "rect2", + x: 500, + y: 100, + width: 300, + height: 200, + }), + ]); + + API.setAppState({ + bindingPreference: "disabled", + isBindingEnabled: false, + }); + + UI.clickTool("arrow"); + // End inside the target rectangle – binding off -> no endBinding + mouse.down(100, 200); + mouse.up(600, 200); + + await waitFor(() => { + const arrow = h.elements.find( + (el): el is ExcalidrawArrowElement => el.type === "arrow", + ); + expect(arrow).toBeDefined(); + expect(arrow!.endBinding).toBeNull(); + }); + }); + + it("re-enabling binding via action causes new arrows to bind again", async () => { + API.setElements([ + API.createElement({ + type: "rectangle", + id: "rect3", + x: 100, + y: 100, + width: 400, + height: 200, + }), + ]); + + // Disable then re-enable binding + API.executeAction(actionToggleArrowBinding); // true -> false + API.executeAction(actionToggleArrowBinding); // false -> true + expect(h.state.isBindingEnabled).toBe(true); + + UI.clickTool("arrow"); + mouse.down(200, 200); + mouse.up(700, 200); + + await waitFor(() => { + const arrow = h.elements.find( + (el): el is ExcalidrawArrowElement => el.type === "arrow", + ); + expect(arrow).toBeDefined(); + expect(arrow!.startBinding).not.toBeNull(); + }); + }); + + it("elbow arrow does not snap to rectangle when binding is disabled", async () => { + const rect = API.createElement({ + type: "rectangle", + id: "rect-elbow-no-snap", + x: 900, + y: 500, + width: 200, + height: 120, + }) as ExcalidrawBindableElement; + API.setElements([rect]); + + // Turn off arrow binding + API.setAppState({ + bindingPreference: "disabled", + isBindingEnabled: false, + isMidpointSnappingEnabled: false, + }); + expect(h.state.isBindingEnabled).toBe(false); + + // Create the elbow arrow + UI.clickTool("arrow"); + API.setAppState({ currentItemArrowType: "elbow" }); + mouse.downAt(700, 400); + mouse.upAt(760, 460); + + let arrow: ExcalidrawArrowElement; + await waitFor(() => { + const maybeArrow = h.elements.find(isElbowArrow); + expect(maybeArrow).toBeDefined(); + arrow = maybeArrow!; + }); + + // Move the elbow arrow + UI.clickTool("selection"); + + const insideRectanglePoint = pointFrom(1010, 570); + const originalArrowEndGlobal = pointFrom( + arrow!.x + arrow!.points[arrow!.points.length - 1][0], + arrow!.y + arrow!.points[arrow!.points.length - 1][1], + ); + const originalArrowEndViewport = sceneCoordsToViewportCoords( + { + sceneX: originalArrowEndGlobal[0], + sceneY: originalArrowEndGlobal[1], + }, + h.state, + ); + const insideRectangleViewport = sceneCoordsToViewportCoords( + { + sceneX: insideRectanglePoint[0], + sceneY: insideRectanglePoint[1], + }, + h.state, + ); + + // End point dragged inside the rectangle; with snapping enabled this + // would snap to the rectangle outline. + mouse.moveTo(originalArrowEndViewport.x, originalArrowEndViewport.y); + mouse.down(); + mouse.moveTo(insideRectangleViewport.x, insideRectangleViewport.y); + mouse.up(); + + const updatedEnd = arrow!.points[arrow!.points.length - 1]; + const updatedEndGlobal = pointFrom( + arrow!.x + updatedEnd[0], + arrow!.y + updatedEnd[1], + ); + + expect(arrow!.startBinding).toBeNull(); + expect(arrow!.endBinding).toBeNull(); + expect(updatedEndGlobal).not.toEqual(originalArrowEndGlobal); + expect(updatedEndGlobal).toEqual(insideRectanglePoint); + }); + }); + + // ------------------------------------------------------------------------- + // Arrow does snap to midpoint when isMidpointSnappingEnabled is true + // ------------------------------------------------------------------------- + describe("Arrow doesn't snap to midpoint when midpoint snapping is disabled", () => { + it("does not snap to midpoint when midpoint snapping is turned off", () => { + const rect = API.createElement({ + type: "rectangle", + id: "rectNoMidSnap", + x: 100, + y: 100, + width: 400, + height: 200, + }) as ExcalidrawBindableElement; + const arrow = API.createElement({ + type: "arrow", + x: 0, + y: 250, + width: 502, + height: -48, + points: [pointFrom(0, 0), pointFrom(502, -48)], + }) as ExcalidrawArrowElement; + const elementsMap = new Map([ + [rect.id, rect], + [arrow.id, arrow], + ]); + const point = pointFrom(502, 202); + + const snappedWithMidpoint = projectFixedPointOntoDiagonal( + arrow, + point, + rect, + "end", + elementsMap, + h.state.zoom, + true, + ); + const snappedWithoutMidpoint = projectFixedPointOntoDiagonal( + arrow, + point, + rect, + "end", + elementsMap, + h.state.zoom, + false, + ); + + expect(snappedWithMidpoint).toEqual([500, 200]); + expect(snappedWithoutMidpoint).not.toEqual([500, 200]); + }); + }); + + // ------------------------------------------------------------------------- + // Ctrl / Cmd key toggle + // ------------------------------------------------------------------------- + + describe("Ctrl key toggle when binding preference is disabled", () => { + it("Ctrl keydown temporarily enables binding when preference is disabled", () => { + API.setAppState({ + bindingPreference: "disabled", + isBindingEnabled: false, + }); + + ctrlKeyDown(); + + expect(h.state.isBindingEnabled).toBe(true); + }); + + it("Ctrl keyup restores isBindingEnabled to false after the temporary toggle", () => { + API.setAppState({ + bindingPreference: "disabled", + isBindingEnabled: false, + }); + + ctrlKeyDown(); + expect(h.state.isBindingEnabled).toBe(true); + + ctrlKeyUp(); + expect(h.state.isBindingEnabled).toBe(false); + }); + + it("full round-trip: off → Ctrl down → on → Ctrl up → off", () => { + API.setAppState({ + bindingPreference: "disabled", + isBindingEnabled: false, + }); + + ctrlKeyDown(); + expect(h.state.isBindingEnabled).toBe(true); + + ctrlKeyUp(); + expect(h.state.isBindingEnabled).toBe(false); + }); + + it("full round-trip can be repeated after Ctrl release resets state", () => { + API.setAppState({ + bindingPreference: "disabled", + isBindingEnabled: false, + }); + + // First cycle + ctrlKeyDown(); + expect(h.state.isBindingEnabled).toBe(true); + ctrlKeyUp(); + expect(h.state.isBindingEnabled).toBe(false); + + // Second cycle + ctrlKeyDown(); + expect(h.state.isBindingEnabled).toBe(true); + ctrlKeyUp(); + expect(h.state.isBindingEnabled).toBe(false); + }); + }); + + describe("Ctrl key toggle when binding starts ON", () => { + it("Ctrl keydown temporarily disables binding when it was on", () => { + expect(h.state.isBindingEnabled).toBe(true); + + ctrlKeyDown(); + expect(h.state.isBindingEnabled).toBe(false); + }); + + it("Ctrl keyup restores isBindingEnabled to true after the temporary toggle", () => { + expect(h.state.isBindingEnabled).toBe(true); + + ctrlKeyDown(); + expect(h.state.isBindingEnabled).toBe(false); + + ctrlKeyUp(); + expect(h.state.isBindingEnabled).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // event.repeat guard + // ------------------------------------------------------------------------- + + describe("event.repeat guard", () => { + it("Ctrl keydown with repeat=true does not toggle binding (preference: disabled)", () => { + API.setAppState({ + bindingPreference: "disabled", + isBindingEnabled: false, + }); + + ctrlKeyDown({ repeat: true }); + + // Must remain off – repeat events are ignored + expect(h.state.isBindingEnabled).toBe(false); + }); + + it("Ctrl keydown with repeat=true does not toggle binding (default: on)", () => { + expect(h.state.isBindingEnabled).toBe(true); + + ctrlKeyDown({ repeat: true }); + + expect(h.state.isBindingEnabled).toBe(true); + }); + + it("pressing another key while Ctrl held does not change binding state", () => { + // Start ON, first Ctrl keydown flips to false + expect(h.state.isBindingEnabled).toBe(true); + + ctrlKeyDown(); + expect(h.state.isBindingEnabled).toBe(false); + + // A second keydown with Ctrl (e.g. pressing another key while Ctrl held) + // should leave state unchanged + fireEvent.keyDown(document, { + key: "z", + ctrlKey: true, + repeat: false, + }); + expect(h.state.isBindingEnabled).toBe(false); + + // Ctrl keyup restores to preference value + ctrlKeyUp(); + expect(h.state.isBindingEnabled).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // View-mode guard + // ------------------------------------------------------------------------- + + describe("View-mode guard", () => { + it("Ctrl keydown in viewMode does not toggle isBindingEnabled", () => { + API.setAppState({ + bindingPreference: "disabled", + isBindingEnabled: false, + viewModeEnabled: true, + }); + + ctrlKeyDown(); + + // Handler returns early in viewMode — state must stay false + expect(h.state.isBindingEnabled).toBe(false); + }); + }); +}); diff --git a/packages/excalidraw/tests/contextmenu.test.tsx b/packages/excalidraw/tests/contextmenu.test.tsx index 5bb7fee8e1..3aa50090a8 100644 --- a/packages/excalidraw/tests/contextmenu.test.tsx +++ b/packages/excalidraw/tests/contextmenu.test.tsx @@ -87,20 +87,17 @@ describe("contextMenu element", () => { clientY: 1, }); const contextMenu = UI.queryContextMenu(); - const contextMenuOptions = - contextMenu?.querySelectorAll(".context-menu li"); const expectedShortcutNames: ShortcutName[] = [ "paste", "selectAll", "gridMode", + "objectsSnapMode", "zenMode", "viewMode", - "objectsSnapMode", "stats", ]; expect(contextMenu).not.toBeNull(); - expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length); expectedShortcutNames.forEach((shortcutName) => { expect( contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`), diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 07701e17bb..ac185ab563 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -223,6 +223,7 @@ export type InteractiveCanvasAppState = Readonly< multiElement: AppState["multiElement"]; newElement: AppState["newElement"]; isBindingEnabled: AppState["isBindingEnabled"]; + isMidpointSnappingEnabled: AppState["isMidpointSnappingEnabled"]; suggestedBinding: AppState["suggestedBinding"]; isRotating: AppState["isRotating"]; elementsToHighlight: AppState["elementsToHighlight"]; @@ -301,7 +302,15 @@ export interface AppState { * - set on pointer down, updated during pointer move */ selectionElement: NonDeletedExcalidrawElement | null; + /** + * tracking current arrow binding editor state (takes into account + * `bindingPreference` and keyboard modifiers (ctrl/alt) + */ isBindingEnabled: boolean; + /** user arrow binding preference */ + bindingPreference: "enabled" | "disabled"; + /** user preference whether arrow snap to midpoints while binding */ + isMidpointSnappingEnabled: boolean; startBoundElement: NonDeleted | null; suggestedBinding: { element: NonDeleted; diff --git a/packages/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap index 8b239c7d04..affae46199 100644 --- a/packages/utils/tests/__snapshots__/export.test.ts.snap +++ b/packages/utils/tests/__snapshots__/export.test.ts.snap @@ -12,6 +12,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "type": "selection", }, "bindMode": "orbit", + "bindingPreference": "enabled", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, @@ -58,6 +59,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", From d1cff91b75d48450430ec1260c498791c066b31f Mon Sep 17 00:00:00 2001 From: Hendrik Horstmann <65970327+heinrich26@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:11:30 +0100 Subject: [PATCH 15/62] fix: spacing in the left menu (#10880) --- packages/excalidraw/components/Actions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 9372e2b243..be065d826d 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -226,7 +226,7 @@ export const SelectedShapeActions = ({ {(appState.activeTool.type === "text" || targetElements.some(isTextElement)) && ( <> - {renderAction("changeFontFamily")} +
{renderAction("changeFontFamily")}
{renderAction("changeFontSize")} {(appState.activeTool.type === "text" || suppportsHorizontalAlign(targetElements, elementsMap)) && From 47c254216b45b42e48a20de693ffc3ab15cff86f Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:48:33 +0100 Subject: [PATCH 16/62] fix(editor): disable snap-to-midpoint menu item when arrow-binding disabled (#10885) --- .../components/dropdownMenu/DropdownMenu.scss | 15 +++++++++++++++ .../components/main-menu/DefaultItems.tsx | 1 + 2 files changed, 16 insertions(+) diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss index 939ef763ce..e207203d60 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss @@ -181,6 +181,21 @@ box-shadow: 0 0 0 1px var(--color-brand-active); } + &[disabled] { + cursor: not-allowed; + opacity: 0.5; + pointer-events: none; + + &:hover { + background-color: transparent; + } + + &:active { + background-color: transparent; + box-shadow: none; + } + } + svg { width: 1rem; height: 1rem; diff --git a/packages/excalidraw/components/main-menu/DefaultItems.tsx b/packages/excalidraw/components/main-menu/DefaultItems.tsx index 8865dfa7ae..9793b62e28 100644 --- a/packages/excalidraw/components/main-menu/DefaultItems.tsx +++ b/packages/excalidraw/components/main-menu/DefaultItems.tsx @@ -469,6 +469,7 @@ const PreferencesToggleMidpointSnappingItem = () => { return ( { actionManager.executeAction(actionToggleMidpointSnapping); event.preventDefault(); From c1dbbdf678c71b93f491ce344cc7ba773f654585 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:52:41 +0100 Subject: [PATCH 17/62] feat(editor): mermaid code editor & improve parsing (#10897) --- excalidraw-app/vite.config.mts | 11 +- packages/common/src/utils.ts | 3 +- .../components/TTDDialog/CodeMirrorEditor.tsx | 239 ++++++++++++++++++ .../TTDDialog/MermaidToExcalidraw.tsx | 133 +++++++++- .../components/TTDDialog/TTDDialog.scss | 86 ++++++- .../components/TTDDialog/TTDDialogInput.tsx | 109 +++++++- .../components/TTDDialog/TTDDialogOutput.tsx | 73 +++++- .../components/TTDDialog/common.test.ts | 89 +++++++ .../excalidraw/components/TTDDialog/common.ts | 16 +- .../components/TTDDialog/mermaid-lang-lite.ts | 82 ++++++ .../TTDDialog/utils/mermaidAutoFix.test.ts | 116 +++++++++ .../TTDDialog/utils/mermaidAutoFix.ts | 175 +++++++++++++ .../TTDDialog/utils/mermaidError.test.ts | 155 ++++++++++++ .../TTDDialog/utils/mermaidError.ts | 133 ++++++++++ packages/excalidraw/locales/en.json | 3 +- packages/excalidraw/package.json | 7 +- .../tests/MermaidToExcalidraw.test.tsx | 10 +- .../MermaidToExcalidraw.test.tsx.snap | 9 +- yarn.lock | 86 ++++++- 19 files changed, 1490 insertions(+), 45 deletions(-) create mode 100644 packages/excalidraw/components/TTDDialog/CodeMirrorEditor.tsx create mode 100644 packages/excalidraw/components/TTDDialog/common.test.ts create mode 100644 packages/excalidraw/components/TTDDialog/mermaid-lang-lite.ts create mode 100644 packages/excalidraw/components/TTDDialog/utils/mermaidAutoFix.test.ts create mode 100644 packages/excalidraw/components/TTDDialog/utils/mermaidAutoFix.ts create mode 100644 packages/excalidraw/components/TTDDialog/utils/mermaidError.test.ts create mode 100644 packages/excalidraw/components/TTDDialog/utils/mermaidError.ts diff --git a/excalidraw-app/vite.config.mts b/excalidraw-app/vite.config.mts index a24d0939a6..fa8d63d956 100644 --- a/excalidraw-app/vite.config.mts +++ b/excalidraw-app/vite.config.mts @@ -106,6 +106,10 @@ export default defineConfig(({ mode }) => { if (id.includes("@excalidraw/mermaid-to-excalidraw")) { return "mermaid-to-excalidraw"; } + + if (id.includes("@codemirror/") || id.includes("@lezer/")) { + return "codemirror.chunk"; + } }, }, }, @@ -150,6 +154,11 @@ export default defineConfig(({ mode }) => { "**/locales/**", "service-worker.js", "**/*.chunk-*.js", + // CodeMirrorEditor can't be assigned a `.chunk` name via + // manualChunks because Rollup would hoist shared deps (React) + // via a static import from the main bundle, defeating lazy + // loading. So we exclude it by name instead. + "**/CodeMirrorEditor-*.js", ], runtimeCaching: [ { @@ -189,7 +198,7 @@ export default defineConfig(({ mode }) => { }, }, { - urlPattern: new RegExp(".chunk-.+.js"), + urlPattern: new RegExp("(.chunk-.+|CodeMirrorEditor-.+)\\.js"), handler: "CacheFirst", options: { cacheName: "chunk", diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 5bafa41813..6d3858a00d 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -88,7 +88,8 @@ export const isWritableElement = ( (target.type === "text" || target.type === "number" || target.type === "password" || - target.type === "search")); + target.type === "search")) || + (target instanceof HTMLElement && target.closest(".cm-editor") !== null); export const getFontFamilyString = ({ fontFamily, diff --git a/packages/excalidraw/components/TTDDialog/CodeMirrorEditor.tsx b/packages/excalidraw/components/TTDDialog/CodeMirrorEditor.tsx new file mode 100644 index 0000000000..d6866a6121 --- /dev/null +++ b/packages/excalidraw/components/TTDDialog/CodeMirrorEditor.tsx @@ -0,0 +1,239 @@ +import { useEffect, useRef } from "react"; +import { + Decoration, + EditorView, + keymap, + lineNumbers, + placeholder as cmPlaceholder, + drawSelection, +} from "@codemirror/view"; +import { Compartment, EditorState, type Extension } from "@codemirror/state"; +import { + defaultKeymap, + history, + historyKeymap, + redo, +} from "@codemirror/commands"; +import { syntaxHighlighting, HighlightStyle } from "@codemirror/language"; +import { tags } from "@lezer/highlight"; + +import type { Theme } from "@excalidraw/element/types"; + +import { mermaidLite } from "./mermaid-lang-lite"; + +export interface CodeMirrorEditorProps { + value: string; + onChange: (value: string) => void; + onKeyboardSubmit?: () => void; + placeholder?: string; + theme: Theme; + errorLine?: number | null; +} + +// ---- Dark theme ---- + +const darkTheme = EditorView.theme( + { + "&": { + backgroundColor: "#1e1e1e", + color: "#d4d4d4", + }, + ".cm-content": { caretColor: "#fff" }, + ".cm-cursor": { borderLeftColor: "#fff" }, + ".cm-gutters": { + backgroundColor: "#1e1e1e", + color: "#858585", + border: "none", + }, + ".cm-activeLineGutter": { backgroundColor: "#2a2a2a" }, + ".cm-activeLine": { backgroundColor: "#2a2a2a" }, + ".cm-errorLine": { backgroundColor: "rgba(255, 0, 0, 0.15)" }, + }, + { dark: true }, +); + +const darkHighlight = HighlightStyle.define([ + { tag: tags.keyword, color: "#569cd6" }, + { tag: tags.string, color: "#ce9178" }, + { tag: tags.comment, color: "#6a9955" }, + { tag: tags.number, color: "#b5cea8" }, + { tag: tags.operator, color: "#d4d4d4" }, + { tag: tags.punctuation, color: "#d4d4d4" }, + { tag: tags.variableName, color: "#9cdcfe" }, + { tag: tags.bracket, color: "#ffd700" }, +]); + +// ---- Light theme ---- + +const lightTheme = EditorView.theme({ + "&": { + backgroundColor: "#ffffff", + color: "#1e1e1e", + }, + ".cm-content": { caretColor: "#000" }, + ".cm-cursor": { borderLeftColor: "#000" }, + ".cm-gutters": { + backgroundColor: "#fff", + color: "#999", + border: "none", + }, + ".cm-activeLineGutter": { backgroundColor: "#e8e8e8" }, + ".cm-activeLine": { backgroundColor: "#e8e8e8" }, + ".cm-errorLine": { backgroundColor: "rgba(255, 0, 0, 0.1)" }, +}); + +const lightHighlight = HighlightStyle.define([ + { tag: tags.keyword, color: "#0000ff" }, + { tag: tags.string, color: "#a31515" }, + { tag: tags.comment, color: "#008000" }, + { tag: tags.number, color: "#098658" }, + { tag: tags.operator, color: "#1e1e1e" }, + { tag: tags.punctuation, color: "#1e1e1e" }, + { tag: tags.variableName, color: "#001080" }, + { tag: tags.bracket, color: "#af00db" }, +]); + +// ---- Error line decoration ---- + +const errorLineDeco = Decoration.line({ class: "cm-errorLine" }); + +const getErrorLineExtension = ( + errorLine: number | null | undefined, + doc: { line(n: number): { from: number }; lines: number }, +): Extension => { + if (!errorLine || errorLine < 1 || errorLine > doc.lines) { + return EditorView.decorations.of(Decoration.none); + } + const line = doc.line(errorLine); + return EditorView.decorations.of( + Decoration.set([errorLineDeco.range(line.from)]), + ); +}; + +// ---- Helpers ---- + +const getThemeExtensions = (theme: Theme) => { + if (theme === "dark") { + return [darkTheme, syntaxHighlighting(darkHighlight)]; + } + return [lightTheme, syntaxHighlighting(lightHighlight)]; +}; + +const CodeMirrorEditor = ({ + value, + onChange, + onKeyboardSubmit, + placeholder, + theme, + errorLine, +}: CodeMirrorEditorProps) => { + const containerRef = useRef(null); + const viewRef = useRef(null); + const onChangeRef = useRef(onChange); + const onKeyboardSubmitRef = useRef(onKeyboardSubmit); + const themeCompartmentRef = useRef(new Compartment()); + const errorLineCompartmentRef = useRef(new Compartment()); + + onChangeRef.current = onChange; + onKeyboardSubmitRef.current = onKeyboardSubmit; + + useEffect(() => { + if (!containerRef.current) { + return; + } + + const themeCompartment = themeCompartmentRef.current; + + const view = new EditorView({ + state: EditorState.create({ + doc: value, + extensions: [ + keymap.of([ + { + key: "Mod-Enter", + run: () => { + onKeyboardSubmitRef.current?.(); + return true; + }, + }, + // historyKeymap binds Mod-Shift-z only on Mac; add it for all platforms + { key: "Mod-Shift-z", run: redo, preventDefault: true }, + ]), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + onChangeRef.current(update.state.doc.toString()); + } + }), + history(), + keymap.of([...defaultKeymap, ...historyKeymap]), + lineNumbers(), + EditorView.lineWrapping, + themeCompartment.of(getThemeExtensions(theme)), + errorLineCompartmentRef.current.of([]), + mermaidLite(), + drawSelection({ drawRangeCursor: true }), + ...(placeholder ? [cmPlaceholder(placeholder)] : []), + ], + }), + parent: containerRef.current, + }); + + viewRef.current = view; + view.focus(); + + return () => { + view.destroy(); + viewRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Swap theme dynamically via compartment + useEffect(() => { + const view = viewRef.current; + if (!view) { + return; + } + view.dispatch({ + effects: themeCompartmentRef.current.reconfigure( + getThemeExtensions(theme), + ), + }); + }, [theme]); + + // Update error line highlight + useEffect(() => { + const view = viewRef.current; + if (!view) { + return; + } + view.dispatch({ + effects: errorLineCompartmentRef.current.reconfigure( + getErrorLineExtension(errorLine, view.state.doc), + ), + }); + }, [errorLine]); + + // Sync external value changes into EditorView + useEffect(() => { + const view = viewRef.current; + if (!view) { + return; + } + const currentDoc = view.state.doc.toString(); + if (value !== currentDoc) { + view.dispatch({ + changes: { from: 0, to: currentDoc.length, insert: value }, + }); + } + }, [value]); + + return ( +
+ ); +}; + +export default CodeMirrorEditor; diff --git a/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx b/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx index baa1298026..5f98637f47 100644 --- a/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx +++ b/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx @@ -17,6 +17,11 @@ import { TTDDialogOutput } from "./TTDDialogOutput"; import { TTDDialogPanel } from "./TTDDialogPanel"; import { TTDDialogPanels } from "./TTDDialogPanels"; import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut"; +import { + getMermaidErrorLineNumber, + isMermaidAutoFixableError, +} from "./utils/mermaidError"; +import { getMermaidAutoFixCandidates } from "./utils/mermaidAutoFix"; import { convertMermaidToExcalidraw, insertToEditor, @@ -33,6 +38,27 @@ const MERMAID_EXAMPLE = "flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]"; const debouncedSaveMermaidDefinition = debounce(saveMermaidDataToStorage, 300); +const AUTO_FIX_DEBOUNCE_MS = 500; +const AUTO_FIX_MAX_DEPTH = 4; +const AUTO_FIX_MAX_CANDIDATES = 30; + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + if ( + error && + typeof error === "object" && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return ""; +}; const MermaidToExcalidraw = ({ mermaidToExcalidrawLib, @@ -46,8 +72,16 @@ const MermaidToExcalidraw = ({ EditorLocalStorage.get(EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW) || MERMAID_EXAMPLE, ); - const deferredText = useDeferredValue(text.trim()); + const deferredText = useDeferredValue(text); const [error, setError] = useState(null); + const [autoFixCandidate, setAutoFixCandidate] = useState(null); + + const errorLine = (() => { + if (!error?.message) { + return null; + } + return getMermaidErrorLineNumber(error.message, deferredText); + })(); const canvasRef = useRef(null); const data = useRef<{ @@ -61,7 +95,7 @@ const MermaidToExcalidraw = ({ useEffect(() => { const doRender = async () => { try { - if (!deferredText) { + if (!deferredText.trim()) { resetPreview({ canvasRef, setError }); return; } @@ -98,6 +132,88 @@ const MermaidToExcalidraw = ({ [], ); + useEffect(() => { + const errorMessage = error?.message ?? ""; + const sourceText = deferredText; + const shouldTryAutoFix = + isActive && + isMermaidAutoFixableError(errorMessage) && + !!sourceText.trim() && + mermaidToExcalidrawLib.loaded; + + if (!shouldTryAutoFix) { + setAutoFixCandidate(null); + return; + } + + const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage); + if (!candidates.length) { + setAutoFixCandidate(null); + return; + } + + let cancelled = false; + const timer = setTimeout(async () => { + try { + const api = await mermaidToExcalidrawLib.api; + const seen = new Set([sourceText]); + const queue = candidates.map((candidate) => ({ + text: candidate, + depth: 1, + })); + + let triedCandidates = 0; + + while (queue.length > 0 && triedCandidates < AUTO_FIX_MAX_CANDIDATES) { + const current = queue.shift(); + if (!current || seen.has(current.text)) { + continue; + } + seen.add(current.text); + triedCandidates += 1; + + try { + await api.parseMermaidToExcalidraw(current.text); + if (!cancelled) { + setAutoFixCandidate(current.text); + } + return; + } catch (candidateError) { + if (current.depth >= AUTO_FIX_MAX_DEPTH) { + continue; + } + const nextErrorMessage = getErrorMessage(candidateError); + if (!nextErrorMessage) { + continue; + } + const nextCandidates = getMermaidAutoFixCandidates( + current.text, + nextErrorMessage, + ); + for (const nextCandidate of nextCandidates) { + if (!seen.has(nextCandidate)) { + queue.push({ + text: nextCandidate, + depth: current.depth + 1, + }); + } + } + } + } + } catch { + // ignore auto-fix probe errors + } + if (!cancelled) { + setAutoFixCandidate(null); + } + }, AUTO_FIX_DEBOUNCE_MS); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [deferredText, error?.message, isActive, mermaidToExcalidrawLib]); + const onInsertToEditor = () => { insertToEditor({ app, @@ -107,6 +223,13 @@ const MermaidToExcalidraw = ({ }); }; + const onApplyAutoFix = () => { + if (!autoFixCandidate) { + return; + } + setText(autoFixCandidate); + }; + return ( <>
@@ -130,7 +253,8 @@ const MermaidToExcalidraw = ({ setText(event.target.value)} + onChange={(value) => setText(value)} + errorLine={errorLine} onKeyboardSubmit={() => { onInsertToEditor(); }} @@ -153,6 +277,9 @@ const MermaidToExcalidraw = ({ canvasRef={canvasRef} loaded={mermaidToExcalidrawLib.loaded} error={error} + sourceText={text} + autoFixAvailable={!!autoFixCandidate} + onApplyAutoFix={onApplyAutoFix} /> diff --git a/packages/excalidraw/components/TTDDialog/TTDDialog.scss b/packages/excalidraw/components/TTDDialog/TTDDialog.scss index 3d133c5baf..e2ba3d1a8e 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialog.scss +++ b/packages/excalidraw/components/TTDDialog/TTDDialog.scss @@ -219,6 +219,49 @@ $fullScreenModalBreakpoint: 600px; } } + .ttd-dialog-input--loading { + display: flex; + align-items: center; + justify-content: center; + } + + .ttd-dialog-input--codemirror { + padding: 0; + overflow: hidden; + // Override height:100% from .ttd-dialog-input — use flex sizing + // so the editor fills remaining space without overflowing the panel + height: 0; + flex: 1 1 0; + min-height: 0; + + .cm-editor { + height: 100%; + font-family: monospace; + + &.cm-focused { + outline: none; + } + } + + .cm-scroller { + padding: 0.85rem 0; + overflow: auto; + } + + .cm-gutters { + padding-left: 0.25rem; + } + + .cm-content { + padding: 0; + } + + .cm-placeholder { + color: var(--color-gray-40); + font-style: italic; + } + } + .ttd-dialog-output-wrapper { display: flex; flex-direction: column; @@ -331,14 +374,55 @@ $fullScreenModalBreakpoint: 600px; margin-top: 0.25rem; } + .ttd-dialog-output-error-summary { + width: 100%; + max-width: 640px; + color: var(--color-gray-50); + font-size: 0.9rem; + text-align: left; + + &__headline { + font-weight: 600; + color: var(--color-gray-60); + } + + &__label { + margin-top: 0.35rem; + font-weight: 500; + } + + &__causes { + margin: 0.35rem 0 0; + padding-left: 2rem; + } + } + .ttd-dialog-output-error-message { text-align: left; font-weight: 400; color: var(--color-gray-50); word-break: break-word; white-space: pre-wrap; - max-width: 100%; + max-width: 640px; + width: 100%; font-family: monospace; + + &__caret { + color: var(--color-danger); + } + } + + .ttd-dialog-output-error-autofix-slot { + align-self: flex-start; + margin-top: 0.35rem; + min-height: 2.5rem; + display: flex; + align-items: flex-start; + } + + .ttd-dialog-output-error-autofix { + margin-top: 0; + white-space: nowrap; } } diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogInput.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogInput.tsx index 24427d52d5..3a14a9fd56 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialogInput.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialogInput.tsx @@ -1,28 +1,84 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { EVENT, KEYS } from "@excalidraw/common"; -import type { ChangeEventHandler } from "react"; +import Spinner from "../Spinner"; + +import { useUIAppState } from "../../context/ui-appState"; + +import type { ComponentType } from "react"; +import type { CodeMirrorEditorProps } from "./CodeMirrorEditor"; interface TTDDialogInputProps { input: string; placeholder: string; - onChange: ChangeEventHandler; + onChange: (value: string) => void; onKeyboardSubmit?: () => void; + errorLine?: number | null; } +type EditorState = + | { type: "loading" } + | { type: "ready"; component: ComponentType } + | { type: "fallback" }; + +const SPINNER_DELAY_MS = 300; + export const TTDDialogInput = ({ input, placeholder, onChange, onKeyboardSubmit, + errorLine, }: TTDDialogInputProps) => { const ref = useRef(null); const callbackRef = useRef(onKeyboardSubmit); callbackRef.current = onKeyboardSubmit; + const [editorState, setEditorState] = useState({ + type: "loading", + }); + const [showSpinner, setShowSpinner] = useState(false); + + const { theme } = useUIAppState(); + + // Lazy-load CodeMirror editor useEffect(() => { + let cancelled = false; + + const spinnerTimer = setTimeout(() => { + if (!cancelled) { + setShowSpinner(true); + } + }, SPINNER_DELAY_MS); + + import("./CodeMirrorEditor") + .then((mod) => { + if (!cancelled) { + setEditorState({ type: "ready", component: mod.default }); + } + }) + .catch(() => { + if (!cancelled) { + setEditorState({ type: "fallback" }); + } + }) + .finally(() => { + clearTimeout(spinnerTimer); + }); + + return () => { + cancelled = true; + clearTimeout(spinnerTimer); + }; + }, []); + + // Keyboard shortcut + focus for textarea fallback + useEffect(() => { + if (editorState.type !== "fallback") { + return; + } if (!callbackRef.current) { return; } @@ -40,15 +96,42 @@ export const TTDDialogInput = ({ textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown); }; } - }, []); + }, [editorState.type]); - return ( -
Ctrl
Enter
" -`; +exports[`Test > should open mermaid popup when active tool is mermaid 1`] = `""`; exports[`Test > should show error in preview when mermaid library throws error 1`] = ` "flowchart TD diff --git a/yarn.lock b/yarn.lock index 11de59df7d..5259da6288 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1095,6 +1095,45 @@ resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-11.0.3.tgz#e39999307b102cff3645ec4f5b3665f5297a2224" integrity sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ== +"@codemirror/commands@^6.0.0": + version "6.10.2" + resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.10.2.tgz#338bf53ab146de7bb26da4a1d32c6a6ff4d36b39" + integrity sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ== + dependencies: + "@codemirror/language" "^6.0.0" + "@codemirror/state" "^6.4.0" + "@codemirror/view" "^6.27.0" + "@lezer/common" "^1.1.0" + +"@codemirror/language@^6.0.0": + version "6.12.2" + resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.12.2.tgz#7db5a46757411cf251e8f450474c05710c27d42c" + integrity sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg== + dependencies: + "@codemirror/state" "^6.0.0" + "@codemirror/view" "^6.23.0" + "@lezer/common" "^1.5.0" + "@lezer/highlight" "^1.0.0" + "@lezer/lr" "^1.0.0" + style-mod "^4.0.0" + +"@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0": + version "6.5.4" + resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.5.4.tgz#f5be4b8c0d2310180d5f15a9f641c21ca69faf19" + integrity sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw== + dependencies: + "@marijn/find-cluster-break" "^1.0.0" + +"@codemirror/view@^6.0.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0": + version "6.39.16" + resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.39.16.tgz#e9d876aba20b31df7858abd7c2a845319c70b302" + integrity sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q== + dependencies: + "@codemirror/state" "^6.5.0" + crelt "^1.0.6" + style-mod "^4.1.0" + w3c-keyname "^2.2.4" + "@esbuild/aix-ppc64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.10.tgz#fb3922a0183d27446de00cf60d4f7baaadf98d84" @@ -1492,10 +1531,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@2.0.0-rfc3": - version "2.0.0-rfc3" - resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.0.0-rfc3.tgz#2aed27280b135086d3d23878e66751819f47c3d4" - integrity sha512-OlKySL2aZwxgvO0wKpjq5fNNWWYwYGQAVMqwG3CJZ/zEf9NotTtX+Rl/WgL6qWvNgDq8/mavOnEstC+42gqnIQ== +"@excalidraw/mermaid-to-excalidraw@2.0.0-rc4": + version "2.0.0-rc4" + resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.0.0-rc4.tgz#9d38568de8e403fefb6a162efa71e024ea1f7e03" + integrity sha512-92efu7VTYF6appGKgbDJAZEM/YXICpYPYOfl+j1E9SVilIFCJEAg7xH2lt/c44WFtffG+rWKrYwf9KUrPYy1Qw== dependencies: "@excalidraw/markdown-to-text" "0.1.2" "@mermaid-js/parser" "^0.6.3" @@ -2045,6 +2084,30 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@lezer/common@^1.0.0", "@lezer/common@^1.1.0", "@lezer/common@^1.3.0", "@lezer/common@^1.5.0": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.5.1.tgz#6e8c114ff5d36a41148e146a253734d3bb8807d3" + integrity sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw== + +"@lezer/highlight@^1.0.0": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.3.tgz#a20f324b71148a2ea9ba6ff42e58bbfaec702857" + integrity sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g== + dependencies: + "@lezer/common" "^1.3.0" + +"@lezer/lr@^1.0.0": + version "1.4.8" + resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.8.tgz#333de9bc9346057323ff09beb4cda47ccc38a498" + integrity sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA== + dependencies: + "@lezer/common" "^1.0.0" + +"@marijn/find-cluster-break@^1.0.0": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8" + integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g== + "@mermaid-js/parser@^0.6.3": version "0.6.3" resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.6.3.tgz#3ce92dad2c5d696d29e11e21109c66a7886c824e" @@ -4968,6 +5031,11 @@ crc-32@^0.3.0: resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-0.3.0.tgz#6a3d3687f5baec41f7e9b99fe1953a2e5d19775e" integrity sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA== +crelt@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72" + integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g== + cross-env@7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" @@ -9520,6 +9588,11 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +style-mod@^4.0.0, style-mod@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.3.tgz#6e9012255bb799bdac37e288f7671b5d71bf9f73" + integrity sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ== + styled-jsx@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.1.tgz#839a1c3aaacc4e735fed0781b8619ea5d0009d1f" @@ -10269,6 +10342,11 @@ vscode-uri@~3.0.8: resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f" integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw== +w3c-keyname@^2.2.4: + version "2.2.8" + resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" + integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ== + w3c-xmlserializer@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073" From 499e9d64a58b5641eb1d41678d922ea3010800ce Mon Sep 17 00:00:00 2001 From: Hendrik Horstmann <65970327+heinrich26@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:41:49 +0100 Subject: [PATCH 18/62] fix: dropdownMenu item badge position (#10895) --- packages/excalidraw/components/MobileToolBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/components/MobileToolBar.tsx b/packages/excalidraw/components/MobileToolBar.tsx index e439061217..7b541bb311 100644 --- a/packages/excalidraw/components/MobileToolBar.tsx +++ b/packages/excalidraw/components/MobileToolBar.tsx @@ -472,9 +472,9 @@ export const MobileToolBar = ({ onSelect={() => app.onMagicframeToolSelect()} icon={MagicIcon} data-testid="toolbar-magicframe" + badge={AI} > {t("toolBar.magicframe")} - AI )} From a0e93b60402f543ffb5c845dafd29d95d2f0ffc6 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:37:28 +0100 Subject: [PATCH 19/62] feat(editor): sync export theme with ui theme (#10903) --- packages/excalidraw/actions/actionExport.tsx | 3 +- packages/excalidraw/components/App.tsx | 9 +++ .../components/ImageExportDialog.tsx | 63 ++++++++++++------- .../__snapshots__/excalidraw.test.tsx.snap | 4 +- packages/excalidraw/tests/excalidraw.test.tsx | 51 ++++++++++++++- packages/excalidraw/types.ts | 1 + 6 files changed, 106 insertions(+), 25 deletions(-) diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index e47a5bb84c..453a0be1f7 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -300,7 +300,8 @@ export const actionExportWithDarkMode = register< name: "exportWithDarkMode", label: "imageExportDialog.label.darkMode", trackEvent: { category: "export", action: "toggleTheme" }, - perform: (_elements, appState, value) => { + perform: (_elements, appState, value, app) => { + app.sessionExportThemeOverride = value ? THEME.DARK : THEME.LIGHT; return { appState: { ...appState, exportWithDarkMode: value }, captureUpdate: CaptureUpdateAction.EVENTUALLY, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 0b361e0e70..75b850f8c2 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -595,6 +595,7 @@ const gesture: Gesture = { class App extends React.Component { canvas: AppClassProperties["canvas"]; interactiveCanvas: AppClassProperties["interactiveCanvas"] = null; + public sessionExportThemeOverride: AppState["theme"] | undefined; rc: RoughCanvas; unmounted: boolean = false; actionManager: ActionManager; @@ -710,6 +711,7 @@ class App extends React.Component { this.state = { ...defaultAppState, theme, + exportWithDarkMode: theme === THEME.DARK, isLoading: true, ...this.getCanvasOffsets(), viewModeEnabled, @@ -3241,6 +3243,13 @@ class App extends React.Component { const elements = this.scene.getElementsIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted(); + const shouldExportWithDarkMode = + (this.sessionExportThemeOverride ?? this.state.theme) === THEME.DARK; + + if (this.state.exportWithDarkMode !== shouldExportWithDarkMode) { + this.setState({ exportWithDarkMode: shouldExportWithDarkMode }); + } + if (!this.state.showWelcomeScreen && !elements.length) { this.setState({ showWelcomeScreen: true }); } diff --git a/packages/excalidraw/components/ImageExportDialog.tsx b/packages/excalidraw/components/ImageExportDialog.tsx index 18831fa08e..48afbaa966 100644 --- a/packages/excalidraw/components/ImageExportDialog.tsx +++ b/packages/excalidraw/components/ImageExportDialog.tsx @@ -59,6 +59,7 @@ type ImageExportModalProps = { actionManager: ActionManager; onExportImage: AppClassProperties["onExportImage"]; name: string; + exportWithDarkMode: boolean; }; const ImageExportModal = ({ @@ -68,6 +69,7 @@ const ImageExportModal = ({ actionManager, onExportImage, name, + exportWithDarkMode, }: ImageExportModalProps) => { const hasSelection = isSomeElementSelected( elementsSnapshot, @@ -79,15 +81,13 @@ const ImageExportModal = ({ const [exportWithBackground, setExportWithBackground] = useState( appStateSnapshot.exportBackground, ); - const [exportDarkMode, setExportDarkMode] = useState( - appStateSnapshot.exportWithDarkMode, - ); const [embedScene, setEmbedScene] = useState( appStateSnapshot.exportEmbedScene, ); const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale); const previewRef = useRef(null); + const previewRenderRequestIdRef = useRef(0); const [renderError, setRenderError] = useState(null); const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus(); @@ -99,7 +99,7 @@ const ImageExportModal = ({ }, [ projectName, exportWithBackground, - exportDarkMode, + exportWithDarkMode, exportScale, embedScene, resetCopyStatus, @@ -122,13 +122,18 @@ const ImageExportModal = ({ return; } + const requestId = ++previewRenderRequestIdRef.current; + const isStaleRequest = () => { + return requestId !== previewRenderRequestIdRef.current; + }; + exportToCanvas({ elements: exportedElements, appState: { ...appStateSnapshot, name: projectName, exportBackground: exportWithBackground, - exportWithDarkMode: exportDarkMode, + exportWithDarkMode, exportScale, exportEmbedScene: embedScene, }, @@ -137,25 +142,41 @@ const ImageExportModal = ({ maxWidthOrHeight: Math.max(maxWidth, maxHeight), exportingFrame, }) - .then((canvas) => { + .then(async (canvas) => { + if (isStaleRequest()) { + return; + } + + // If converting to blob fails, there's some problem that will likely + // prevent preview and export (e.g. canvas too big). + try { + await canvasToBlob(canvas); + } catch (error: any) { + if (error.name === "CANVAS_POSSIBLY_TOO_BIG") { + throw new Error(t("canvasError.canvasTooBig")); + } + throw error; + } + + if (isStaleRequest()) { + return; + } + setRenderError(null); - // if converting to blob fails, there's some problem that will - // likely prevent preview and export (e.g. canvas too big) - return canvasToBlob(canvas) - .then(() => { - previewNode.replaceChildren(canvas); - }) - .catch((e) => { - if (e.name === "CANVAS_POSSIBLY_TOO_BIG") { - throw new Error(t("canvasError.canvasTooBig")); - } - throw e; - }); + previewNode.replaceChildren(canvas); }) .catch((error) => { + if (isStaleRequest()) { + return; + } + console.error(error); setRenderError(error); }); + + return () => { + previewRenderRequestIdRef.current += 1; + }; }, [ appStateSnapshot, files, @@ -163,7 +184,7 @@ const ImageExportModal = ({ exportingFrame, projectName, exportWithBackground, - exportDarkMode, + exportWithDarkMode, exportScale, embedScene, ]); @@ -233,9 +254,8 @@ const ImageExportModal = ({ > { - setExportDarkMode(checked); actionManager.executeAction( actionExportWithDarkMode, "ui", @@ -399,6 +419,7 @@ export const ImageExportDialog = ({ actionManager={actionManager} onExportImage={onExportImage} name={name} + exportWithDarkMode={appState.exportWithDarkMode} /> ); diff --git a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap index ef0fb47e64..2ed8bc1c53 100644 --- a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap @@ -2,7 +2,7 @@ exports[` > > should render main menu with host menu items if passed from host 1`] = `