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": ` - - - - `, - "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 ( ); 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":