diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index cd0aca2683..a3f847385f 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -62,7 +62,7 @@ export const AppMainMenu: React.FC<{ {isDevEnv() && ( { + onSelect={() => { if (window.visualDebug) { delete window.visualDebug; saveDebugState({ enabled: false }); @@ -77,6 +77,7 @@ export const AppMainMenu: React.FC<{ )} + 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/colors.ts b/packages/common/src/colors.ts index 54e5c6086a..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 @@ -346,7 +345,7 @@ export const normalizeInputColor = (color: string): string | null => { if (tc.isValid()) { // testing for `#` first fixes a bug on Electron (more specfically, an // Obsidian popout window), where a hex color without `#` is considered valid - if (tc.getFormat() === "hex" && !color.startsWith("#")) { + if (["hex", "hex8"].includes(tc.getFormat()) && !color.startsWith("#")) { return `#${color}`; } return color; diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 03978584cb..4ff50335ef 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -106,6 +106,7 @@ export const CLASSES = { CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup", SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope", FRAME_NAME: "frame-name", + DROPDOWN_MENU_EVENT_WRAPPER: "dropdown-menu-event-wrapper", }; export const FONT_SIZES = { 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"; 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/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/arrows/focus.ts b/packages/element/src/arrows/focus.ts index 960524465c..fa8018adbe 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 @@ -129,6 +141,7 @@ const focusPointUpdate = ( currentBinding, bindableElement, elementsMap, + true, ); if (newPoint) { @@ -137,7 +150,7 @@ const focusPointUpdate = ( } // Also update the adjacent end if it has a binding - if (adjacentBinding) { + if (adjacentBinding && adjacentBinding.mode === "orbit") { const adjacentBindableElement = elementsMap.get( adjacentBinding.elementId, ) as ExcalidrawBindableElement; @@ -353,6 +366,7 @@ export const handleFocusPointPointerDown = ( bindableElement, elementsMap, appState, + "start", ) && pointDistance(pointerPos, focusPoint) <= hitThreshold ) { @@ -387,6 +401,7 @@ export const handleFocusPointPointerDown = ( bindableElement, elementsMap, appState, + "end", ) && pointDistance(pointerPos, focusPoint) <= hitThreshold ) { @@ -501,6 +516,7 @@ export const handleFocusPointHover = ( bindableElement, elementsMap, appState, + "start", ) && pointDistance(pointerPos, focusPoint) <= hitThreshold ) { @@ -529,6 +545,7 @@ export const handleFocusPointHover = ( bindableElement, elementsMap, appState, + "end", ) && pointDistance(pointerPos, focusPoint) <= hitThreshold ) { diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 074fe2a21f..566ef3c4e4 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1,5 +1,4 @@ import { - KEYS, arrayToMap, getFeatureFlag, invariant, @@ -27,11 +26,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, @@ -114,8 +109,9 @@ export type BindingStrategy = * * IMPORTANT: currently must be > 0 (this also applies to the computed gap) */ -export const BASE_BINDING_GAP = 10; +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 = ( @@ -140,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 => { @@ -180,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. @@ -224,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, + ); } }; @@ -800,6 +811,8 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( hit, startDragged ? "start" : "end", elementsMap, + appState.zoom, + appState.isMidpointSnappingEnabled, ) || globalPoint, } : { mode: null }; @@ -809,11 +822,24 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( startDragged ? -1 : 0, elementsMap, ); - - const other: BindingStrategy = + const pointIsCloseToOtherElement = + otherFocusPoint && otherBindableElement && - !otherFocusPointIsInElement && - appState.selectedLinearElement?.initialState.altFocusPoint + 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", element: otherBindableElement, @@ -830,9 +856,12 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( otherBindableElement, startDragged ? "end" : "start", elementsMap, + appState.zoom, + appState.isMidpointSnappingEnabled, ) || otherEndpoint, } - : { mode: undefined }; + : { mode: undefined } + : { mode: undefined }; return { start: startDragged ? current : other, @@ -992,6 +1021,7 @@ export const bindBindingElement = ( startOrEnd: "start" | "end", scene: Scene, focusPoint?: GlobalPoint, + shouldSnapToOutline = true, ): void => { const elementsMap = scene.getNonDeletedElementsMap(); @@ -1006,6 +1036,7 @@ export const bindBindingElement = ( hoveredElement, startOrEnd, elementsMap, + shouldSnapToOutline, ), }; } else { @@ -1096,7 +1127,7 @@ export const updateBoundElements = ( }); } - boundElementsVisitor(elementsMap, changedElement, (element) => { + const visitor = (element: ExcalidrawElement | undefined) => { if (!isArrowElement(element) || element.isDeleted) { return; } @@ -1168,7 +1199,9 @@ export const updateBoundElements = ( if (boundText && !boundText.isDeleted) { handleBindTextResize(element, scene, false); } - }); + }; + + boundElementsVisitor(elementsMap, changedElement, visitor); }; const updateArrowBindings = ( @@ -1337,6 +1370,7 @@ export const bindPointToSnapToElementOutline = ( startOrEnd: "start" | "end", elementsMap: ElementsMap, customIntersector?: LineSegment, + isMidpointSnappingEnabled = true, ): GlobalPoint => { const elbowed = isElbowArrow(arrowElement); const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( @@ -1376,15 +1410,13 @@ export const bindPointToSnapToElementOutline = ( const isHorizontal = headingIsHorizontal( headingForPointFromElement(bindableElement, aabb, point), ); - const snapPoint = snapToMid( - arrowElement, - bindableElement, - elementsMap, - edgePoint, - ); + const snapPoint = isMidpointSnappingEnabled + ? snapToMid(bindableElement, elementsMap, edgePoint, 0.05, arrowElement) + : undefined; + const resolved = snapPoint || point; const otherPoint = pointFrom( - isHorizontal ? bindableCenter[0] : snapPoint[0], - !isHorizontal ? bindableCenter[1] : snapPoint[1], + isHorizontal ? bindableCenter[0] : resolved[0], + !isHorizontal ? bindableCenter[1] : resolved[1], ); const intersector = customIntersector ?? @@ -1392,7 +1424,7 @@ export const bindPointToSnapToElementOutline = ( otherPoint, pointFromVector( vectorScale( - vectorNormalize(vectorFromPoint(snapPoint, otherPoint)), + vectorNormalize(vectorFromPoint(resolved, otherPoint)), Math.max(bindableElement.width, bindableElement.height) * 2, ), otherPoint, @@ -1407,14 +1439,14 @@ export const bindPointToSnapToElementOutline = ( if (!intersection) { const anotherPoint = pointFrom( - !isHorizontal ? bindableCenter[0] : snapPoint[0], - isHorizontal ? bindableCenter[1] : snapPoint[1], + !isHorizontal ? bindableCenter[0] : resolved[0], + isHorizontal ? bindableCenter[1] : resolved[1], ); const anotherIntersector = lineSegment( anotherPoint, pointFromVector( vectorScale( - vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)), + vectorNormalize(vectorFromPoint(resolved, anotherPoint)), Math.max(bindableElement.width, bindableElement.height) * 2, ), anotherPoint, @@ -1561,18 +1593,18 @@ export const avoidRectangularCorner = ( return p; }; -const snapToMid = ( - arrowElement: ExcalidrawArrowElement, +export const snapToMid = ( bindTarget: ExcalidrawBindableElement, elementsMap: ElementsMap, p: GlobalPoint, tolerance: number = 0.05, -): GlobalPoint => { + arrowElement?: ExcalidrawArrowElement, +): GlobalPoint | undefined => { const { x, y, width, height, angle } = bindTarget; const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1); const nonRotated = pointRotateRads(p, center, -angle as Radians); - const bindingGap = getBindingGap(bindTarget, arrowElement); + const bindingGap = arrowElement ? getBindingGap(bindTarget, arrowElement) : 0; // snap-to-center point is adaptive to element size, but we don't want to go // above and below certain px distance @@ -1581,7 +1613,7 @@ const snapToMid = ( // Too close to the center makes it hard to resolve direction precisely if (pointDistance(center, nonRotated) < bindingGap) { - return p; + return undefined; } if ( @@ -1590,8 +1622,8 @@ const snapToMid = ( nonRotated[1] < center[1] + verticalThreshold ) { // LEFT - return pointRotateRads( - pointFrom(x - bindingGap, center[1]), + return pointRotateRads( + pointFrom(x - bindingGap, center[1]), center, angle, ); @@ -1601,7 +1633,11 @@ const snapToMid = ( nonRotated[0] < center[0] + horizontalThreshold ) { // TOP - return pointRotateRads(pointFrom(center[0], y - bindingGap), center, angle); + return pointRotateRads( + pointFrom(center[0], y - bindingGap), + center, + angle, + ); } else if ( nonRotated[0] >= x + width / 2 && nonRotated[1] > center[1] - verticalThreshold && @@ -1609,7 +1645,7 @@ const snapToMid = ( ) { // RIGHT return pointRotateRads( - pointFrom(x + width + bindingGap, center[1]), + pointFrom(x + width + bindingGap, center[1]), center, angle, ); @@ -1620,7 +1656,7 @@ const snapToMid = ( ) { // DOWN return pointRotateRads( - pointFrom(center[0], y + height + bindingGap), + pointFrom(center[0], y + height + bindingGap), center, angle, ); @@ -1669,13 +1705,44 @@ const snapToMid = ( } } - return p; + 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, @@ -1683,9 +1750,7 @@ export const updateBoundPoint = ( binding: FixedPointBinding | null | undefined, bindableElement: ExcalidrawBindableElement, elementsMap: ElementsMap, - opts?: { - customIntersector?: LineSegment; - }, + dragging?: boolean, ): LocalPoint | null => { if ( binding == null || @@ -1700,150 +1765,139 @@ 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 : -2, + elementsMap, + ); + const otherFocusPointOrArrowPoint = + arrow.points.length === 2 + ? otherFocusPoint || otherArrowPoint + : 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, ); }; @@ -1853,6 +1907,8 @@ export const calculateFixedPointForElbowArrowBinding = ( hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", elementsMap: ElementsMap, + shouldSnapToOutline = true, + isMidpointSnappingEnabled = true, ): { fixedPoint: FixedPoint } => { const bounds = [ hoveredElement.x, @@ -1860,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, @@ -2411,21 +2475,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/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/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/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 125ef05025..e57211abbc 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"; @@ -26,6 +25,7 @@ import { import { deconstructLinearOrFreeDrawElement, + getSnapOutlineMidPoint, isPathALoop, moveArrowAboveBindable, projectFixedPointOntoDiagonal, @@ -48,6 +48,7 @@ import { calculateFixedPointForNonElbowArrowBinding, getBindingStrategyForDraggingBindingElementEndpoints, isBindingEnabled, + snapToMid, updateBoundPoint, } from "./binding"; import { @@ -355,13 +356,23 @@ export class LinearElementEditor { app, shouldRotateWithDiscreteAngle(event), event.altKey, + 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)) { @@ -408,13 +419,15 @@ export class LinearElementEditor { altFocusPoint: !linearElementEditor.initialState.altFocusPoint && startBindingElement && - updates?.suggestedBinding?.id !== startBindingElement.id + updates?.suggestedBinding?.element.id !== startBindingElement.id ? projectFixedPointOntoDiagonal( element, pointFrom(element.x, element.y), startBindingElement, "start", elementsMap, + app.state.zoom, + app.state.isMidpointSnappingEnabled, ) : linearElementEditor.initialState.altFocusPoint, }, @@ -532,13 +545,23 @@ export class LinearElementEditor { app, shouldRotateWithDiscreteAngle(event) && singlePointDragged, event.altKey, + 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)) { @@ -607,11 +630,11 @@ export class LinearElementEditor { const altFocusPointBindableElement = endIsSelected && // The "other" end (i.e. "end") is dragged startBindingElement && - updates?.suggestedBinding?.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap + updates?.suggestedBinding?.element.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap ? startBindingElement : startIsSelected && // The "other" end (i.e. "start") is dragged endBindingElement && - updates?.suggestedBinding?.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap + updates?.suggestedBinding?.element.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap ? endBindingElement : null; @@ -631,6 +654,8 @@ export class LinearElementEditor { altFocusPointBindableElement, "start", elementsMap, + app.state.zoom, + app.state.isMidpointSnappingEnabled, ) : linearElementEditor.initialState.altFocusPoint, }, @@ -1519,6 +1544,10 @@ export class LinearElementEditor { endBinding?: FixedPointBinding | null; moveMidPointsWithElement?: boolean | null; }, + options?: { + isBindingEnabled?: boolean; + isMidpointSnappingEnabled?: boolean; + }, ) { const { points } = element; @@ -1587,6 +1616,8 @@ export class LinearElementEditor { otherUpdates, { isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging), + isBindingEnabled: options?.isBindingEnabled, + isMidpointSnappingEnabled: options?.isMidpointSnappingEnabled, }, ); } @@ -1701,6 +1732,8 @@ export class LinearElementEditor { isDragging?: boolean; zoom?: AppState["zoom"]; sceneElementsMap?: NonDeletedSceneElementsMap; + isBindingEnabled?: boolean; + isMidpointSnappingEnabled?: boolean; }, ) { if (isElbowArrow(element)) { @@ -1721,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? @@ -2080,6 +2115,7 @@ const pointDraggingUpdates = ( app: AppClassProperties, angleLocked: boolean, altKey: boolean, + linearElementEditor: LinearElementEditor, ): { positions: PointsPositionUpdates; updates?: PointMoveOtherUpdates; @@ -2127,13 +2163,29 @@ const pointDraggingUpdates = ( ); if (isElbowArrow(element)) { + const suggestedBindingElement = startIsDragged + ? start.element + : endIsDragged + ? end.element + : null; + return { positions: naiveDraggingPoints, updates: { - suggestedBinding: startIsDragged - ? start.element - : endIsDragged - ? end.element + suggestedBinding: suggestedBindingElement + ? { + element: suggestedBindingElement, + midPoint: app.state.isMidpointSnappingEnabled + ? snapToMid( + suggestedBindingElement, + elementsMap, + pointFrom( + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + ), + ) + : undefined, + } : null, }, }; @@ -2226,7 +2278,20 @@ const pointDraggingUpdates = ( (updates.startBinding.mode === "orbit" || !getFeatureFlag("COMPLEX_BINDINGS")) ) { - updates.suggestedBinding = start.element; + updates.suggestedBinding = start.element + ? { + element: start.element, + midPoint: getSnapOutlineMidPoint( + pointFrom( + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + ), + start.element, + elementsMap, + app.state.zoom, + ), + } + : null; } } else if (startIsDragged) { updates.suggestedBinding = app.state.suggestedBinding; @@ -2252,7 +2317,20 @@ const pointDraggingUpdates = ( (updates.endBinding.mode === "orbit" || !getFeatureFlag("COMPLEX_BINDINGS")) ) { - updates.suggestedBinding = end.element; + updates.suggestedBinding = end.element + ? { + element: end.element, + midPoint: getSnapOutlineMidPoint( + pointFrom( + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + ), + end.element, + elementsMap, + app.state.zoom, + ), + } + : null; } } else if (endIsDragged) { updates.suggestedBinding = app.state.suggestedBinding; @@ -2292,19 +2370,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 = @@ -2340,9 +2405,7 @@ const pointDraggingUpdates = ( nextArrow.endBinding, endBindable, elementsMap, - { - customIntersector: endCustomIntersector, - }, + endIsDragged, ) || nextArrow.points[nextArrow.points.length - 1] : nextArrow.points[nextArrow.points.length - 1]; @@ -2373,7 +2436,7 @@ const pointDraggingUpdates = ( nextArrow.startBinding, startBindable, elementsMap, - { customIntersector: startCustomIntersector }, + startIsDragged, ) || nextArrow.points[0] : nextArrow.points[0]; 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/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/element/src/utils.ts b/packages/element/src/utils.ts index c8e6889864..96e09bcbf1 100644 --- a/packages/element/src/utils.ts +++ b/packages/element/src/utils.ts @@ -7,6 +7,7 @@ import { } from "@excalidraw/common"; import { + bezierEquation, curve, curveCatmullRomCubicApproxPoints, curveOffsetPoints, @@ -27,19 +28,30 @@ import { import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math"; -import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types"; +import type { + AppState, + NormalizedZoomValue, + Zoom, +} from "@excalidraw/excalidraw/types"; import { elementCenterPoint, getDiamondPoints } from "./bounds"; import { generateLinearCollisionShape } from "./shape"; -import { isPointInElement } from "./collision"; +import { hitElementItself, isPointInElement } from "./collision"; import { LinearElementEditor } from "./linearElementEditor"; import { isRectangularElement } from "./typeChecks"; +import { maxBindingDistance_simple } from "./binding"; + +import { + getGlobalFixedPointForBindableElement, + normalizeFixedPoint, +} from "./binding"; import type { ElementsMap, ExcalidrawArrowElement, + ExcalidrawBindableElement, ExcalidrawDiamondElement, ExcalidrawElement, ExcalidrawFreeDrawElement, @@ -329,24 +341,10 @@ export function deconstructRectanguloidElement( return shape; } -/** - * Get the **unrotated** building components of a diamond element - * in the form of line segments and curves as a tuple, in this order. - * - * @param element The element to deconstruct - * @param offset An optional offset - * @returns Tuple of line **unrotated** segments (0) and curves (1) - */ -export function deconstructDiamondElement( +export function getDiamondBaseCorners( element: ExcalidrawDiamondElement, offset: number = 0, -): [LineSegment[], Curve[]] { - const cachedShape = getElementShapesCacheEntry(element, offset); - - if (cachedShape) { - return cachedShape; - } - +): Curve[] { const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = getDiamondPoints(element); const verticalRadius = element.roundness @@ -363,7 +361,7 @@ export function deconstructDiamondElement( pointFrom(element.x + leftX, element.y + leftY), ]; - const baseCorners = [ + return [ curve( pointFrom( right[0] - verticalRadius, @@ -413,6 +411,27 @@ export function deconstructDiamondElement( ), ), // TOP ]; +} + +/** + * Get the **unrotated** building components of a diamond element + * in the form of line segments and curves as a tuple, in this order. + * + * @param element The element to deconstruct + * @param offset An optional offset + * @returns Tuple of line **unrotated** segments (0) and curves (1) + */ +export function deconstructDiamondElement( + element: ExcalidrawDiamondElement, + offset: number = 0, +): [LineSegment[], Curve[]] { + const cachedShape = getElementShapesCacheEntry(element, offset); + + if (cachedShape) { + return cachedShape; + } + + const baseCorners = getDiamondBaseCorners(element, offset); const corners = baseCorners.map( (corner) => @@ -570,28 +589,131 @@ const getDiagonalsForBindableElement = ( return [diagonalOne, diagonalTwo]; }; +export const getSnapOutlineMidPoint = ( + point: GlobalPoint, + element: ExcalidrawBindableElement, + elementsMap: ElementsMap, + zoom: AppState["zoom"], +) => { + const center = elementCenterPoint(element, elementsMap); + const sideMidpoints = + element.type === "diamond" + ? getDiamondBaseCorners(element).map((curve) => { + const point = bezierEquation(curve, 0.5); + const rotatedPoint = pointRotateRads(point, center, element.angle); + + return pointFrom(rotatedPoint[0], rotatedPoint[1]); + }) + : [ + // RIGHT midpoint + pointRotateRads( + pointFrom( + element.x + element.width, + element.y + element.height / 2, + ), + center, + element.angle, + ), + // BOTTOM midpoint + pointRotateRads( + pointFrom( + element.x + element.width / 2, + element.y + element.height, + ), + center, + element.angle, + ), + // LEFT midpoint + pointRotateRads( + pointFrom(element.x, element.y + element.height / 2), + center, + element.angle, + ), + // TOP midpoint + pointRotateRads( + pointFrom(element.x + element.width / 2, element.y), + center, + element.angle, + ), + ]; + const candidate = sideMidpoints.find( + (midpoint) => + pointDistance(point, midpoint) <= + maxBindingDistance_simple(zoom) + element.strokeWidth / 2 && + !hitElementItself({ + point, + element, + threshold: 0, + elementsMap, + overrideShouldTestInside: true, + }), + ); + + return candidate; +}; + export const projectFixedPointOntoDiagonal = ( arrow: ExcalidrawArrowElement, point: GlobalPoint, - element: ExcalidrawElement, + element: ExcalidrawBindableElement, 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; } + if (isMidpointSnappingEnabled) { + const sideMidPoint = getSnapOutlineMidPoint( + point, + element, + elementsMap, + zoom, + ); + if (sideMidPoint) { + return sideMidPoint; + } + } + + // Do the projection onto the diagonals (or center lines + // for non-rectangular shapes) const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement( element, 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), @@ -603,18 +725,22 @@ export const projectFixedPointOntoDiagonal = ( ), a, ); - const intersector = lineSegment(point, b); + const intersector = lineSegment(b, a); const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector); const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector); const d1 = p1 && pointDistance(a, p1); const d2 = p2 && pointDistance(a, p2); - let p = null; + let projection = null; if (d1 != null && d2 != null) { - p = d1 < d2 ? p1 : p2; + projection = d1 < d2 ? p1 : p2; } else { - p = p1 || p2 || null; + projection = p1 || p2 || null; } - return p && isPointInElement(p, element, elementsMap) ? p : null; + if (projection && isPointInElement(projection, element, elementsMap)) { + return projection; + } + + return null; }; 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); + }); +}); diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index 4c9ab3825d..9485dcb222 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -1317,7 +1317,7 @@ describe("Test Linear Elements", () => { const textElement = h.elements[2] as ExcalidrawTextElementWithContainer; expect(arrow.endBinding?.elementId).toBe(rect.id); - expect(arrow.width).toBeCloseTo(399); + expect(arrow.width).toBeCloseTo(404); expect(rect.x).toBe(400); expect(rect.y).toBe(0); expect( @@ -1336,7 +1336,7 @@ describe("Test Linear Elements", () => { mouse.downAt(rect.x, rect.y); mouse.moveTo(200, 0); mouse.upAt(200, 0); - expect(arrow.width).toBeCloseTo(199); + expect(arrow.width).toBeCloseTo(204); expect(rect.x).toBe(200); expect(rect.y).toBe(0); expect(handleBindTextResizeSpy).toHaveBeenCalledWith( diff --git a/packages/element/tests/resize.test.tsx b/packages/element/tests/resize.test.tsx index b51d537e37..15e52dfa1b 100644 --- a/packages/element/tests/resize.test.tsx +++ b/packages/element/tests/resize.test.tsx @@ -1350,8 +1350,8 @@ describe("multiple selection", () => { expect(boundArrow.x).toBeCloseTo(380 * scaleX); expect(boundArrow.y).toBeCloseTo(240 * scaleY); - expect(boundArrow.points[1][0]).toBeCloseTo(59.7979); - expect(boundArrow.points[1][1]).toBeCloseTo(-79.7305); + expect(boundArrow.points[1][0]).toBeCloseTo(63.40354208105561); + expect(boundArrow.points[1][1]).toBeCloseTo(-84.53805610807356); expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo( boundArrow.x + boundArrow.points[1][0] / 2, 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/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index e0f59a5655..9e529621a7 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -108,7 +108,6 @@ export const actionFinalize = register({ return map; }, new Map()) ?? new Map(); - bindOrUnbindBindingElement( element, draggedPoints, diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 2d6b040a34..93fe0ada1a 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", @@ -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} @@ -1828,6 +1830,7 @@ export const actionChangeArrowType = register({ startElement, "start", elementsMap, + appState.isBindingEnabled, ), } : null; @@ -1841,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/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts index ca593c3402..ca4119d251 100644 --- a/packages/excalidraw/actions/shortcuts.ts +++ b/packages/excalidraw/actions/shortcuts.ts @@ -55,7 +55,8 @@ export type ShortcutName = | "saveScene" | "imageExport" | "commandPalette" - | "searchMenu"; + | "searchMenu" + | "toolLock"; const shortcutMap: Record = { toggleTheme: [getShortcutKey("Shift+Alt+D")], @@ -117,6 +118,7 @@ const shortcutMap: Record = { toggleShortcuts: [getShortcutKey("?")], searchMenu: [getShortcutKey("CtrlOrCmd+F")], wrapSelectionInFrame: [], + toolLock: [getShortcutKey("Q")], }; export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { 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 087b1b795e..e51865b2ea 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, @@ -71,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, @@ -83,7 +84,6 @@ export const getDefaultAppState = (): Omit< openPopup: null, openSidebar: null, openDialog: null, - pasteDialog: { shown: false, data: null }, previousSelectedElementIds: {}, resizingElement: null, scrolledOutside: false, @@ -150,7 +150,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 }, @@ -193,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, @@ -212,7 +213,6 @@ const APP_STATE_STORAGE_CONF = (< openPopup: { browser: false, export: false, server: false }, openSidebar: { browser: true, export: false, server: false }, openDialog: { browser: false, export: false, server: false }, - pasteDialog: { browser: false, export: false, server: false }, previousSelectedElementIds: { browser: true, export: false, server: false }, resizingElement: { browser: false, export: false, server: false }, scrolledOutside: { browser: true, export: false, server: false }, diff --git a/packages/excalidraw/charts.test.ts b/packages/excalidraw/charts.test.ts index 94fa92fa0c..16e161ca40 100644 --- a/packages/excalidraw/charts.test.ts +++ b/packages/excalidraw/charts.test.ts @@ -1,8 +1,40 @@ -import { tryParseCells, tryParseNumber, VALID_SPREADSHEET } from "./charts"; +import { FONT_FAMILY } from "@excalidraw/common"; +import { + DEFAULT_CHART_COLOR_INDEX, + getAllColorsSpecificShade, +} from "@excalidraw/common"; + +import type { + ExcalidrawLineElement, + ExcalidrawTextElement, +} from "@excalidraw/element/types"; + +import { + isSpreadsheetValidForChartType, + renderSpreadsheet, + tryParseCells, + tryParseNumber, +} from "./charts"; import type { Spreadsheet } from "./charts"; describe("charts", () => { + const getRotatedBounds = (element: ExcalidrawTextElement) => { + const cos = Math.abs(Math.cos(element.angle)); + const sin = Math.abs(Math.sin(element.angle)); + const rotatedWidth = element.width * cos + element.height * sin; + const rotatedHeight = element.width * sin + element.height * cos; + const centerX = element.x + element.width / 2; + const centerY = element.y + element.height / 2; + return { + left: centerX - rotatedWidth / 2, + right: centerX + rotatedWidth / 2, + top: centerY - rotatedHeight / 2, + bottom: centerY + rotatedHeight / 2, + centerX, + }; + }; + describe("tryParseNumber", () => { it.each<[string, number]>([ ["1", 1], @@ -42,11 +74,11 @@ describe("charts", () => { const result = tryParseCells(spreadsheet); - expect(result.type).toBe(VALID_SPREADSHEET); + expect(result.ok).toBe(true); - const { title, labels, values } = ( - result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet } - ).spreadsheet; + const { title, labels, series } = ( + result as { ok: true; data: Spreadsheet } + ).data; expect(title).toEqual("value"); expect(labels).toEqual([ @@ -57,7 +89,9 @@ describe("charts", () => { "05:00", "06:00", ]); - expect(values).toEqual([61, -60, 85, -67, 54, 95]); + expect(series).toEqual([ + { title: "value", values: [61, -60, 85, -67, 54, 95] }, + ]); }); it("Uses the second column as the label if it is not a number", () => { @@ -73,11 +107,11 @@ describe("charts", () => { const result = tryParseCells(spreadsheet); - expect(result.type).toBe(VALID_SPREADSHEET); + expect(result.ok).toBe(true); - const { title, labels, values } = ( - result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet } - ).spreadsheet; + const { title, labels, series } = ( + result as { ok: true; data: Spreadsheet } + ).data; expect(title).toEqual("value"); expect(labels).toEqual([ @@ -88,7 +122,9 @@ describe("charts", () => { "05:00", "06:00", ]); - expect(values).toEqual([61, -60, 85, -67, 54, 95]); + expect(series).toEqual([ + { title: "value", values: [61, -60, 85, -67, 54, 95] }, + ]); }); it("treats the first column as labels if both columns are numbers", () => { @@ -104,15 +140,1026 @@ describe("charts", () => { const result = tryParseCells(spreadsheet); - expect(result.type).toBe(VALID_SPREADSHEET); + expect(result.ok).toBe(true); - const { title, labels, values } = ( - result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet } - ).spreadsheet; + const { title, labels, series } = ( + result as { ok: true; data: Spreadsheet } + ).data; expect(title).toEqual("value"); expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]); - expect(values).toEqual([61, -60, 85, -67, 54, 95]); + expect(series).toEqual([ + { title: "value", values: [61, -60, 85, -67, 54, 95] }, + ]); + }); + + it("parses multi-series cells for radar charts", () => { + const spreadsheet = [ + ["Metric", "Player A", "Player B", "Player C"], + ["Speed", "80", "60", "75"], + ["Strength", "65", "85", "70"], + ["Agility", "90", "70", "88"], + ["Intelligence", "70", "88", "92"], + ["Stamina", "85", "75", "80"], + ]; + + const result = tryParseCells(spreadsheet); + + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toEqual("Metric"); + expect(parsed.labels).toEqual([ + "Speed", + "Strength", + "Agility", + "Intelligence", + "Stamina", + ]); + expect(parsed.series).toEqual([ + { title: "Player A", values: [80, 65, 90, 70, 85] }, + { title: "Player B", values: [60, 85, 70, 88, 75] }, + { title: "Player C", values: [75, 70, 88, 92, 80] }, + ]); + }); + + it("treats first row as title+series headers only when all cells are non-numeric", () => { + const spreadsheet = [ + ["Trait", "10", "20"], + ["Physical Strength", "4", "8"], + ["Strategy", "6", "9"], + ["Charisma", "7", "5"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toBeNull(); + expect(parsed.labels?.[0]).toEqual("Trait"); + expect(parsed.series[0].title).toEqual("Series 1"); + expect(parsed.series[1].title).toEqual("Series 2"); + }); + + it("supports header row with series labels but no chart title", () => { + const spreadsheet = [ + ["", "Dunk", "Egg"], + ["Physical Strength", "10", "2"], + ["Swordsmanship", "8", "1"], + ["Political Instinct", "3", "9"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toBeNull(); + expect(parsed.labels).toEqual([ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + ]); + expect(parsed.series).toEqual([ + { title: "Dunk", values: [10, 8, 3] }, + { title: "Egg", values: [2, 1, 9] }, + ]); + }); + + it("parses 2-row multi-series data with header row", () => { + const spreadsheet = [ + ["trait", "Dunk", "Egg"], + ["Physical Strength", "10", "2"], + ["Swordsmanship skill", "8", "1"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toEqual("trait"); + expect(parsed.labels).toEqual([ + "Physical Strength", + "Swordsmanship skill", + ]); + expect(parsed.series).toEqual([ + { title: "Dunk", values: [10, 8] }, + { title: "Egg", values: [2, 1] }, + ]); + }); + + it("parses 2-row multi-series data without header and keeps first column as labels", () => { + const spreadsheet = [ + ["Physical Strength", "10", "2"], + ["Swordsmanship skill", "8", "1"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toBeNull(); + expect(parsed.labels).toEqual([ + "Physical Strength", + "Swordsmanship skill", + ]); + expect(parsed.series).toEqual([ + { title: "Series 1", values: [10, 8] }, + { title: "Series 2", values: [2, 1] }, + ]); + }); + + it("always interprets 2-column data as label in first column and numeric value in second", () => { + const spreadsheet = [ + ["10", "2"], + ["8", "Swordsmanship skill"], + ["6", "3"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result).toEqual({ + ok: false, + reason: "Value is not numeric", + }); + }); + }); + + describe("isSpreadsheetValidForChartType", () => { + it("rejects radar charts with only 2 dimensions", () => { + const spreadsheet: Spreadsheet = { + title: "trait", + labels: ["Physical Strength", "Swordsmanship skill"], + series: [ + { title: "Dunk", values: [10, 8] }, + { title: "Egg", values: [2, 1] }, + ], + }; + + expect(isSpreadsheetValidForChartType(spreadsheet, "radar")).toBe(false); + expect(isSpreadsheetValidForChartType(spreadsheet, "bar")).toBe(true); + expect(isSpreadsheetValidForChartType(spreadsheet, "line")).toBe(true); + }); + + it("accepts radar charts with 3 or more dimensions", () => { + const spreadsheet: Spreadsheet = { + title: "trait", + labels: [ + "Physical Strength", + "Swordsmanship skill", + "Political Instinct", + ], + series: [ + { title: "Dunk", values: [10, 8, 3] }, + { title: "Egg", values: [2, 1, 9] }, + ], + }; + + expect(isSpreadsheetValidForChartType(spreadsheet, "radar")).toBe(true); + }); + }); + + describe("renderSpreadsheet", () => { + it("renders grouped bars and legend for multi-series bar charts", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5] }, + { title: "Egg", values: [2, 1, 9, 8, 9] }, + { title: "Aerion", values: [7, 8, 7, 4, 5] }, + ], + }; + + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + const bars = elements!.filter( + (element) => + element.type === "rectangle" && + element.strokeWidth === 1 && + element.opacity === 100 && + !element.roundness, + ); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const axisLabels = textElements.filter((element) => + spreadsheet.labels?.includes(element.originalText || ""), + ); + const legendLabels = textElements.filter((element) => + spreadsheet.series.some( + (series) => series.title === element.originalText, + ), + ); + + const axisBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + const legendTopY = Math.min( + ...legendLabels.map((legendLabel) => legendLabel.y), + ); + + expect(bars).toHaveLength( + spreadsheet.series.length * spreadsheet.series[0].values.length, + ); + expect(legendLabels).toHaveLength(spreadsheet.series.length); + expect(legendTopY).toBeGreaterThan(axisBottomY + 2); + }); + + it("spreads grouped bar series colors across palette", () => { + const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "S1", values: [1, 2, 3, 4, 5] }, + { title: "S2", values: [2, 3, 4, 5, 1] }, + { title: "S3", values: [3, 4, 5, 1, 2] }, + { title: "S4", values: [4, 5, 1, 2, 3] }, + ], + }; + + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + randomSpy.mockRestore(); + + const bars = elements!.filter( + (element) => + element.type === "rectangle" && + element.strokeWidth === 1 && + element.opacity === 100 && + !element.roundness, + ); + const uniqueColors = Array.from( + new Set(bars.map((bar) => bar.backgroundColor)), + ); + const colorIndices = uniqueColors.map((color) => + palette.findIndex((paletteColor) => paletteColor === color), + ); + + expect(uniqueColors).toHaveLength(spreadsheet.series.length); + expect(colorIndices.every((index) => index >= 0)).toBe(true); + + const circularDistance = (first: number, second: number) => { + const absoluteDistance = Math.abs(first - second); + return Math.min(absoluteDistance, palette.length - absoluteDistance); + }; + const minDistance = Math.min( + ...colorIndices.flatMap((index, i) => + colorIndices + .slice(i + 1) + .map((other) => circularDistance(index, other)), + ), + ); + expect(minDistance).toBeGreaterThan(1); + }); + + it("renders grouped bars for parsed multi-series cells without header row", () => { + const cells = [ + ["Physical Strength", "10", "2", "7"], + ["Swordsmanship", "8", "1", "8"], + ["Political Instinct", "3", "9", "7"], + ["Book Knowledge", "2.5", "8", "4"], + ]; + const parsedResult = tryParseCells(cells); + expect(parsedResult.ok).toBe(true); + const parsedSpreadsheet = ( + parsedResult as { + ok: true; + data: Spreadsheet; + } + ).data; + + const elements = renderSpreadsheet("bar", parsedSpreadsheet, 0, 0); + const bars = elements!.filter( + (element) => + element.type === "rectangle" && + element.strokeWidth === 1 && + element.opacity === 100 && + !element.roundness, + ); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const legendLabels = textElements + .map((element) => element.originalText) + .filter((text): text is string => typeof text === "string"); + + expect(bars).toHaveLength( + parsedSpreadsheet.series[0].values.length * + parsedSpreadsheet.series.length, + ); + expect(legendLabels).toContain("Series 1"); + expect(legendLabels).toContain("Series 2"); + expect(legendLabels).toContain("Series 3"); + }); + + it("makes multi-series bar charts wider than single-series bar charts", () => { + const singleSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [{ title: "Trait", values: [10, 8, 3, 2.5] }], + }; + const multiSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5] }, + { title: "Egg", values: [2, 1, 9, 8] }, + { title: "Aerion", values: [7, 8, 7, 4] }, + ], + }; + + const singleElements = renderSpreadsheet("bar", singleSeries, 0, 0); + const multiElements = renderSpreadsheet("bar", multiSeries, 0, 0); + const getXAxisWidth = (elements: ReturnType) => + elements!.find( + (element): element is ExcalidrawLineElement => + element.type === "line" && + element.strokeStyle === "solid" && + element.points[0][1] === 0 && + element.points[1][1] === 0 && + element.points[1][0] > 0, + )?.width || 0; + + expect(getXAxisWidth(multiElements)).toBeGreaterThan( + getXAxisWidth(singleElements), + ); + }); + + it("makes multi-series line charts wider than single-series line charts", () => { + const singleSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [{ title: "Trait", values: [10, 8, 3, 2.5] }], + }; + const multiSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5] }, + { title: "Egg", values: [2, 1, 9, 8] }, + { title: "Aerion", values: [7, 8, 7, 4] }, + ], + }; + + const singleElements = renderSpreadsheet("line", singleSeries, 0, 0); + const multiElements = renderSpreadsheet("line", multiSeries, 0, 0); + const getXAxisWidth = (elements: ReturnType) => + elements!.find( + (element): element is ExcalidrawLineElement => + element.type === "line" && + element.strokeStyle === "solid" && + element.points[0][1] === 0 && + element.points[1][1] === 0 && + element.points[1][0] > 0, + )?.width || 0; + + expect(getXAxisWidth(multiElements)).toBeGreaterThan( + getXAxisWidth(singleElements), + ); + }); + + it("wraps grouped bar labels with spaces and still ellipsifies long single words", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Supercalifragilisticexpialidocious", + "Data Flow", + "Logic Layer", + ], + series: [ + { title: "Dunk", values: [8, 3, 2.5] }, + { title: "Egg", values: [1, 9, 8] }, + { title: "Aerion", values: [8, 7, 4] }, + ], + }; + + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + const longWordLabel = elements!.find( + (element): element is ExcalidrawTextElement => + element.type === "text" && + Math.abs(element.angle) > 0 && + element.text.includes("..."), + ); + const spacedLabels = elements!.filter( + (element): element is ExcalidrawTextElement => + element.type === "text" && + (element.originalText === "Data Flow" || + element.originalText === "Logic Layer"), + ); + + expect(longWordLabel).toBeDefined(); + expect(longWordLabel?.text).toContain("..."); + expect(longWordLabel?.originalText).toBe(longWordLabel?.text); + expect( + (longWordLabel?.text || "").replace("...", "").length, + ).toBeGreaterThan(0); + expect(spacedLabels.some((label) => label.text.includes("\n"))).toBe( + true, + ); + expect( + spacedLabels.every( + (label) => !!label.originalText && !label.originalText.includes("\n"), + ), + ).toBe(true); + }); + + it("keeps single-series bar x-axis labels below axis and avoids neighbor overlap", () => { + const spreadsheet: Spreadsheet = { + title: "Dunk", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "charisma", + "courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [{ title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }], + }; + + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + const axisLabels = elements!.filter( + (element): element is ExcalidrawTextElement => + element.type === "text" && Math.abs(element.angle) > 0, + ); + + expect(axisLabels).toHaveLength(spreadsheet.labels!.length); + + const bounds = axisLabels.map(getRotatedBounds); + for (const bound of bounds) { + expect(bound.top).toBeGreaterThan(0); + } + + const sortedBounds = bounds.sort( + (left, right) => left.centerX - right.centerX, + ); + for (let index = 1; index < sortedBounds.length; index++) { + expect(sortedBounds[index - 1].right).toBeLessThanOrEqual( + sortedBounds[index].left + 2, + ); + } + }); + + it("renders one line per series and one dot per data point for multi-series line charts", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { title: "Team A", values: [42150, 8300, 95400, 7820, 310500] }, + { title: "Team B", values: [63400, 3150, 51200, 4670, 125800] }, + ], + }; + + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + const seriesLines = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && element.strokeWidth === 2, + ); + const dots = elements!.filter( + (element) => element.type === "ellipse" && element.strokeWidth === 2, + ); + + expect(seriesLines).toHaveLength(spreadsheet.series.length); + expect(dots).toHaveLength( + spreadsheet.series.length * spreadsheet.series[0].values.length, + ); + }); + + it("spreads line series colors across palette to avoid similar adjacent colors", () => { + const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "S1", values: [1, 2, 3, 4, 5] }, + { title: "S2", values: [2, 3, 4, 5, 1] }, + { title: "S3", values: [3, 4, 5, 1, 2] }, + { title: "S4", values: [4, 5, 1, 2, 3] }, + ], + }; + + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + randomSpy.mockRestore(); + + const seriesLines = elements!.filter( + (element) => element.type === "line" && element.strokeWidth === 2, + ); + const colorIndices = seriesLines.map((line) => + palette.findIndex((color) => color === line.strokeColor), + ); + + expect(colorIndices.every((index) => index >= 0)).toBe(true); + + const circularDistance = (first: number, second: number) => { + const absoluteDistance = Math.abs(first - second); + return Math.min(absoluteDistance, palette.length - absoluteDistance); + }; + const minDistance = Math.min( + ...colorIndices.flatMap((index, i) => + colorIndices + .slice(i + 1) + .map((other) => circularDistance(index, other)), + ), + ); + + expect(minDistance).toBeGreaterThan(1); + }); + + it("uses colorSeed to deterministically pick chart colors", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [ + { title: "S1", values: [1, 2, 3, 4] }, + { title: "S2", values: [4, 3, 2, 1] }, + { title: "S3", values: [2, 3, 4, 1] }, + ], + }; + + const getSeriesLineColors = (seed: number) => { + const elements = renderSpreadsheet("line", spreadsheet, 0, 0, seed); + return elements! + .filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && element.strokeWidth === 2, + ) + .map((line) => line.strokeColor); + }; + + expect(getSeriesLineColors(0.125)).toEqual(getSeriesLineColors(0.125)); + expect(getSeriesLineColors(0.125)).not.toEqual( + getSeriesLineColors(0.875), + ); + }); + + it("renders multi-series line legend below axis labels with clearance", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { title: "Team A", values: [42150, 8300, 95400, 12600, 310500] }, + { title: "Team B", values: [63400, 3150, 51200, 9200, 125800] }, + ], + }; + + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const axisLabels = textElements.filter((element) => + spreadsheet.labels?.includes(element.originalText || ""), + ); + const legendLabels = textElements.filter((element) => + spreadsheet.series.some( + (series) => series.title === element.originalText, + ), + ); + + const axisBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + const legendTopY = Math.min( + ...legendLabels.map((legendLabel) => legendLabel.y), + ); + + expect(axisLabels.length).toBeGreaterThan(0); + expect(legendLabels.length).toBe(2); + expect(legendTopY).toBeGreaterThan(axisBottomY + 2); + }); + + it("keeps multi-series line x-axis labels below axis and avoids neighbor overlap", () => { + const spreadsheet: Spreadsheet = { + title: "trait", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "charisma", + "courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }, + { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] }, + ], + }; + + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + const axisLabels = elements!.filter( + (element): element is ExcalidrawTextElement => + element.type === "text" && Math.abs(element.angle) > 0, + ); + + expect(axisLabels).toHaveLength(spreadsheet.labels!.length); + + const bounds = axisLabels.map(getRotatedBounds); + for (const bound of bounds) { + expect(bound.top).toBeGreaterThan(0); + } + + const sortedBounds = bounds.sort( + (left, right) => left.centerX - right.centerX, + ); + for (let index = 1; index < sortedBounds.length; index++) { + expect(sortedBounds[index - 1].right).toBeLessThanOrEqual( + sortedBounds[index].left + 2, + ); + } + }); + + it("renders one closed polygon line per radar series", () => { + const spreadsheet: Spreadsheet = { + title: "Metric", + labels: ["Speed", "Strength", "Agility", "Intelligence", "Stamina"], + series: [ + { title: "Player A", values: [80, 65, 90, 70, 85] }, + { title: "Player B", values: [60, 85, 70, 88, 75] }, + { title: "Player C", values: [75, 70, 88, 92, 80] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const seriesPolygons = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + + expect(seriesPolygons).toHaveLength(3); + for (const polygon of seriesPolygons) { + expect(polygon.points[0]).toEqual( + polygon.points[polygon.points.length - 1], + ); + } + }); + + it("normalizes multi-series radar values with global scale", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { title: "Series 1", values: [40000, 8300, 95400, 7820, 5000000] }, + { title: "Series 2", values: [76000, 3150, 51200, 4670, 60000] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const seriesPolygons = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + + const series1 = seriesPolygons[0]; + const series2 = seriesPolygons[1]; + const getRadius = (point: readonly [number, number]) => + Math.hypot(point[0], point[1]); + + // On alpha axis, second series is about ~1.9x first series. + const alphaRatio = + getRadius(series2.points[0]!) / getRadius(series1.points[0]!); + expect(alphaRatio).toBeCloseTo(76000 / 40000, 1); + + // On epsilon axis, first series should dominate strongly. + const epsilonRatio = + getRadius(series1.points[4]!) / getRadius(series2.points[4]!); + expect(epsilonRatio).toBeGreaterThan(50); + }); + + // it("always renders radar step rings regardless of axis scale ratio", () => { + // const spreadsheet: Spreadsheet = { + // title: "Scores", + // labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + // series: [ + // { title: "Series 1", values: [40000, 8300, 95400, 7820, 5000000] }, + // { title: "Series 2", values: [76000, 3150, 51200, 4670, 60000] }, + // ], + // }; + + // const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + // const stepRings = elements!.filter( + // (element) => + // element.type === "line" && + // "polygon" in element && + // element.polygon && + // element.strokeStyle === "solid" && + // element.strokeWidth === 1, + // ); + + // expect(stepRings).toHaveLength(4); + // }); + + it("uses log normalization for highly skewed single-series radar data", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { + title: "Scores", + values: [40000, 8300, 95400, 7820, 5000000], + }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const seriesPolygons = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + + const polygon = seriesPolygons[0]; + const getRadius = (point: readonly [number, number]) => + Math.hypot(point[0], point[1]); + + const alphaRadius = getRadius(polygon.points[0]!); + const epsilonRadius = getRadius(polygon.points[4]!); + + // With linear scaling this would collapse near 0; log keeps it visible. + expect(alphaRadius).toBeGreaterThan(40); + expect(epsilonRadius).toBeGreaterThan(alphaRadius); + }); + + it("does not render 0/max value labels for radar charts", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { + title: "Scores", + values: [40000, 8300, 95400, 7820, 5000000], + }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + + expect(textElements.some((element) => element.text === "0")).toBe(false); + expect( + textElements.some( + (element) => + element.text === + Math.max(...spreadsheet.series[0].values).toLocaleString(), + ), + ).toBe(false); + }); + + it("wraps long radar axis labels instead of ellipsifying", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "Charisma", + "Courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }, + { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const wrappedAxisLabels = textElements.filter( + (element) => + element.text.includes("\n") && + element.text !== "Trait" && + element.text !== "Dunk" && + element.text !== "Egg", + ); + + expect(wrappedAxisLabels.length).toBeGreaterThan(0); + expect( + wrappedAxisLabels.every( + (element) => + typeof element.originalText === "string" && + !element.originalText.includes("\n"), + ), + ).toBe(true); + expect( + textElements.some( + (element) => element.text.includes("...") && element.text !== "Dunk", + ), + ).toBe(false); + expect( + textElements.some( + (element) => + element.originalText === "Stubbornness" && + !element.text.includes("\n") && + element.text === "Stubbornness", + ), + ).toBe(true); + expect( + textElements.some( + (element) => + element.originalText === "Physical Strength" && + element.text.includes("Physical\nStrength"), + ), + ).toBe(true); + + const topLabel = textElements.find( + (element) => element.originalText === "Physical Strength", + ); + const topSpokeY = Math.min( + ...elements! + .filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + !element.polygon && + element.strokeStyle === "solid" && + element.strokeWidth === 1, + ) + .map((element) => element.y + element.points[1][1]), + ); + expect(topLabel).toBeDefined(); + expect(topLabel!.y + topLabel!.height).toBeLessThan(topSpokeY - 2); + }); + + it("renders radar title and series legend labels in Lilita One", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["Physical Strength", "Swordsmanship", "Strategy", "Charisma"], + series: [ + { title: "Dunk", values: [10, 8, 5, 7] }, + { title: "Egg", values: [2, 1, 9, 8] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const title = textElements.find((element) => + element.text.includes("Trait"), + ); + const dunkLabel = textElements.find((element) => element.text === "Dunk"); + const eggLabel = textElements.find((element) => element.text === "Egg"); + + expect(title?.fontFamily).toBe(FONT_FAMILY["Lilita One"]); + expect(title?.originalText).toBe("Trait"); + expect(dunkLabel?.fontFamily).toBe(FONT_FAMILY["Lilita One"]); + expect(eggLabel?.fontFamily).toBe(FONT_FAMILY["Lilita One"]); + }); + + it("positions radar title with vertical clearance above axis labels", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "Charisma", + "Courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }, + { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const title = textElements.find( + (element) => element.fontFamily === FONT_FAMILY["Lilita One"], + ); + const axisLabels = textElements.filter( + (element) => + element.fontFamily === FONT_FAMILY.Excalifont && + element.text !== "Dunk" && + element.text !== "Egg", + ); + const topAxisLabelY = Math.min(...axisLabels.map((element) => element.y)); + + expect(title).toBeDefined(); + expect(title!.y + title!.height).toBeLessThan(topAxisLabelY - 4); + }); + + it("spreads radar series colors across palette to avoid similar adjacent colors", () => { + const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "S1", values: [1, 2, 3, 4, 5] }, + { title: "S2", values: [2, 3, 4, 5, 1] }, + { title: "S3", values: [3, 4, 5, 1, 2] }, + { title: "S4", values: [4, 5, 1, 2, 3] }, + ], + }; + + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + randomSpy.mockRestore(); + + const seriesPolygons = elements!.filter( + (element) => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + const colorIndices = seriesPolygons.map((polygon) => + palette.findIndex((color) => color === polygon.strokeColor), + ); + + expect(colorIndices.every((index) => index >= 0)).toBe(true); + + const circularDistance = (first: number, second: number) => { + const absoluteDistance = Math.abs(first - second); + return Math.min(absoluteDistance, palette.length - absoluteDistance); + }; + const minDistance = Math.min( + ...colorIndices.flatMap((index, i) => + colorIndices + .slice(i + 1) + .map((other) => circularDistance(index, other)), + ), + ); + + expect(minDistance).toBeGreaterThan(1); + }); + + it("positions series legend below the lowest axis label with clearance", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Psychological Warfare", + "Divine Favor", + "Confidence", + "Morale", + "Armor Protection long wrapped label from above", + "Accuracy", + "Agility", + "Weapon Reach", + ], + series: [ + { title: "David", values: [6, 7, 8, 9, 7, 8, 6, 9] }, + { title: "Goliath", values: [9, 3, 2, 6, 10, 2, 8, 1] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const axisLabels = textElements.filter((element) => + spreadsheet.labels?.includes(element.originalText), + ); + const legendLabels = textElements.filter((element) => + spreadsheet.series.some( + (series) => series.title === element.originalText, + ), + ); + + const axisBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + const legendTopY = Math.min( + ...legendLabels.map((legendLabel) => legendLabel.y), + ); + + expect(axisLabels.length).toBeGreaterThan(0); + expect(legendLabels.length).toBeGreaterThan(0); + expect(legendTopY).toBeGreaterThan(axisBottomY + 2); }); }); }); diff --git a/packages/excalidraw/charts.ts b/packages/excalidraw/charts.ts deleted file mode 100644 index 26f8802936..0000000000 --- a/packages/excalidraw/charts.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { pointFrom } from "@excalidraw/math"; - -import { - COLOR_PALETTE, - DEFAULT_CHART_COLOR_INDEX, - getAllColorsSpecificShade, - DEFAULT_FONT_FAMILY, - DEFAULT_FONT_SIZE, - VERTICAL_ALIGN, - randomId, - isDevEnv, - FONT_SIZES, -} from "@excalidraw/common"; - -import { - newTextElement, - newLinearElement, - newElement, -} from "@excalidraw/element"; - -import type { Radians } from "@excalidraw/math"; - -import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; - -export type ChartElements = readonly NonDeletedExcalidrawElement[]; - -const BAR_WIDTH = 32; -const BAR_GAP = 12; -const BAR_HEIGHT = 256; -const GRID_OPACITY = 50; - -export interface Spreadsheet { - title: string | null; - labels: string[] | null; - values: number[]; -} - -export const NOT_SPREADSHEET = "NOT_SPREADSHEET"; -export const VALID_SPREADSHEET = "VALID_SPREADSHEET"; - -type ParseSpreadsheetResult = - | { type: typeof NOT_SPREADSHEET; reason: string } - | { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }; - -/** - * @private exported for testing - */ -export const tryParseNumber = (s: string): number | null => { - const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s); - if (!match) { - return null; - } - return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, "")); -}; - -const isNumericColumn = (lines: string[][], columnIndex: number) => - lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null); - -/** - * @private exported for testing - */ -export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => { - const numCols = cells[0].length; - - if (numCols > 2) { - return { type: NOT_SPREADSHEET, reason: "More than 2 columns" }; - } - - if (numCols === 1) { - if (!isNumericColumn(cells, 0)) { - return { type: NOT_SPREADSHEET, reason: "Value is not numeric" }; - } - - const hasHeader = tryParseNumber(cells[0][0]) === null; - const values = (hasHeader ? cells.slice(1) : cells).map((line) => - tryParseNumber(line[0]), - ); - - if (values.length < 2) { - return { type: NOT_SPREADSHEET, reason: "Less than two rows" }; - } - - return { - type: VALID_SPREADSHEET, - spreadsheet: { - title: hasHeader ? cells[0][0] : null, - labels: null, - values: values as number[], - }, - }; - } - - const labelColumnNumeric = isNumericColumn(cells, 0); - const valueColumnNumeric = isNumericColumn(cells, 1); - - if (!labelColumnNumeric && !valueColumnNumeric) { - return { type: NOT_SPREADSHEET, reason: "Value is not numeric" }; - } - - const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric - ? [0, 1] - : [1, 0]; - const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null; - const rows = hasHeader ? cells.slice(1) : cells; - - if (rows.length < 2) { - return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" }; - } - - return { - type: VALID_SPREADSHEET, - spreadsheet: { - title: hasHeader ? cells[0][valueColumnIndex] : null, - labels: rows.map((row) => row[labelColumnIndex]), - values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!), - }, - }; -}; - -const transposeCells = (cells: string[][]) => { - const nextCells: string[][] = []; - for (let col = 0; col < cells[0].length; col++) { - const nextCellRow: string[] = []; - for (let row = 0; row < cells.length; row++) { - nextCellRow.push(cells[row][col]); - } - nextCells.push(nextCellRow); - } - return nextCells; -}; - -export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => { - // Copy/paste from excel, spreadsheets, tsv, csv. - // For now we only accept 2 columns with an optional header - - // Check for tab separated values - let lines = text - .trim() - .split("\n") - .map((line) => line.trim().split("\t")); - - // Check for comma separated files - if (lines.length && lines[0].length !== 2) { - lines = text - .trim() - .split("\n") - .map((line) => line.trim().split(",")); - } - - if (lines.length === 0) { - return { type: NOT_SPREADSHEET, reason: "No values" }; - } - - const numColsFirstLine = lines[0].length; - const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine); - - if (!isSpreadsheet) { - return { - type: NOT_SPREADSHEET, - reason: "All rows don't have same number of columns", - }; - } - - const result = tryParseCells(lines); - if (result.type !== VALID_SPREADSHEET) { - const transposedResults = tryParseCells(transposeCells(lines)); - if (transposedResults.type === VALID_SPREADSHEET) { - return transposedResults; - } - } - return result; -}; - -const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); - -// Put all the common properties here so when the whole chart is selected -// the properties dialog shows the correct selected values -const commonProps = { - fillStyle: "hachure", - fontFamily: DEFAULT_FONT_FAMILY, - fontSize: DEFAULT_FONT_SIZE, - opacity: 100, - roughness: 1, - strokeColor: COLOR_PALETTE.black, - roundness: null, - strokeStyle: "solid", - strokeWidth: 1, - verticalAlign: VERTICAL_ALIGN.MIDDLE, - locked: false, -} as const; - -const getChartDimensions = (spreadsheet: Spreadsheet) => { - const chartWidth = - (BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP; - const chartHeight = BAR_HEIGHT + BAR_GAP * 2; - return { chartWidth, chartHeight }; -}; - -const chartXLabels = ( - spreadsheet: Spreadsheet, - x: number, - y: number, - groupId: string, - backgroundColor: string, -): ChartElements => { - return ( - spreadsheet.labels?.map((label, index) => { - return newTextElement({ - groupIds: [groupId], - backgroundColor, - ...commonProps, - text: label.length > 8 ? `${label.slice(0, 5)}...` : label, - x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2, - y: y + BAR_GAP / 2, - width: BAR_WIDTH, - angle: 5.87 as Radians, - fontSize: FONT_SIZES.sm, - textAlign: "center", - verticalAlign: "top", - }); - }) || [] - ); -}; - -const chartYLabels = ( - spreadsheet: Spreadsheet, - x: number, - y: number, - groupId: string, - backgroundColor: string, -): ChartElements => { - const minYLabel = newTextElement({ - groupIds: [groupId], - backgroundColor, - ...commonProps, - x: x - BAR_GAP, - y: y - BAR_GAP, - text: "0", - textAlign: "right", - }); - - const maxYLabel = newTextElement({ - groupIds: [groupId], - backgroundColor, - ...commonProps, - x: x - BAR_GAP, - y: y - BAR_HEIGHT - minYLabel.height / 2, - text: Math.max(...spreadsheet.values).toLocaleString(), - textAlign: "right", - }); - - return [minYLabel, maxYLabel]; -}; - -const chartLines = ( - spreadsheet: Spreadsheet, - x: number, - y: number, - groupId: string, - backgroundColor: string, -): ChartElements => { - const { chartWidth, chartHeight } = getChartDimensions(spreadsheet); - const xLine = newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x, - y, - width: chartWidth, - points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], - }); - - const yLine = newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x, - y, - height: chartHeight, - points: [pointFrom(0, 0), pointFrom(0, -chartHeight)], - }); - - const maxLine = newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x, - y: y - BAR_HEIGHT - BAR_GAP, - strokeStyle: "dotted", - width: chartWidth, - opacity: GRID_OPACITY, - points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], - }); - - return [xLine, yLine, maxLine]; -}; - -// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g -const chartBaseElements = ( - spreadsheet: Spreadsheet, - x: number, - y: number, - groupId: string, - backgroundColor: string, - debug?: boolean, -): ChartElements => { - const { chartWidth, chartHeight } = getChartDimensions(spreadsheet); - - const title = spreadsheet.title - ? newTextElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - text: spreadsheet.title, - x: x + chartWidth / 2, - y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE, - roundness: null, - textAlign: "center", - }) - : null; - - const debugRect = debug - ? newElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "rectangle", - x, - y: y - chartHeight, - width: chartWidth, - height: chartHeight, - strokeColor: COLOR_PALETTE.black, - fillStyle: "solid", - opacity: 6, - }) - : null; - - return [ - ...(debugRect ? [debugRect] : []), - ...(title ? [title] : []), - ...chartXLabels(spreadsheet, x, y, groupId, backgroundColor), - ...chartYLabels(spreadsheet, x, y, groupId, backgroundColor), - ...chartLines(spreadsheet, x, y, groupId, backgroundColor), - ]; -}; - -const chartTypeBar = ( - spreadsheet: Spreadsheet, - x: number, - y: number, -): ChartElements => { - const max = Math.max(...spreadsheet.values); - const groupId = randomId(); - const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)]; - - const bars = spreadsheet.values.map((value, index) => { - const barHeight = (value / max) * BAR_HEIGHT; - return newElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "rectangle", - x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP, - y: y - barHeight - BAR_GAP, - width: BAR_WIDTH, - height: barHeight, - }); - }); - - return [ - ...bars, - ...chartBaseElements( - spreadsheet, - x, - y, - groupId, - backgroundColor, - isDevEnv(), - ), - ]; -}; - -const chartTypeLine = ( - spreadsheet: Spreadsheet, - x: number, - y: number, -): ChartElements => { - const max = Math.max(...spreadsheet.values); - const groupId = randomId(); - const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)]; - - let index = 0; - const points = []; - for (const value of spreadsheet.values) { - const cx = index * (BAR_WIDTH + BAR_GAP); - const cy = -(value / max) * BAR_HEIGHT; - points.push([cx, cy]); - index++; - } - - const maxX = Math.max(...points.map((element) => element[0])); - const maxY = Math.max(...points.map((element) => element[1])); - const minX = Math.min(...points.map((element) => element[0])); - const minY = Math.min(...points.map((element) => element[1])); - - const line = newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x: x + BAR_GAP + BAR_WIDTH / 2, - y: y - BAR_GAP, - height: maxY - minY, - width: maxX - minX, - strokeWidth: 2, - points: points as any, - }); - - const dots = spreadsheet.values.map((value, index) => { - const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2; - const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2; - return newElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - fillStyle: "solid", - strokeWidth: 2, - type: "ellipse", - x: x + cx + BAR_WIDTH / 2, - y: y + cy - BAR_GAP * 2, - width: BAR_GAP, - height: BAR_GAP, - }); - }); - - const lines = spreadsheet.values.map((value, index) => { - const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2; - const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP; - return newLinearElement({ - backgroundColor, - groupIds: [groupId], - ...commonProps, - type: "line", - x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2, - y: y - cy, - height: cy, - strokeStyle: "dotted", - opacity: GRID_OPACITY, - points: [pointFrom(0, 0), pointFrom(0, cy)], - }); - }); - - return [ - ...chartBaseElements( - spreadsheet, - x, - y, - groupId, - backgroundColor, - isDevEnv(), - ), - line, - ...lines, - ...dots, - ]; -}; - -export const renderSpreadsheet = ( - chartType: string, - spreadsheet: Spreadsheet, - x: number, - y: number, -): ChartElements => { - if (chartType === "line") { - return chartTypeLine(spreadsheet, x, y); - } - return chartTypeBar(spreadsheet, x, y); -}; diff --git a/packages/excalidraw/charts/charts.bar.ts b/packages/excalidraw/charts/charts.bar.ts new file mode 100644 index 0000000000..b1a7759606 --- /dev/null +++ b/packages/excalidraw/charts/charts.bar.ts @@ -0,0 +1,103 @@ +import { isDevEnv } from "@excalidraw/common"; + +import { newElement } from "@excalidraw/element"; + +import { commonProps } from "./charts.constants"; +import { + chartBaseElements, + chartXLabels, + createSeriesLegend, + getBackgroundColor, + getCartesianChartLayout, + getChartDimensions, + getColorOffset, + getRotatedTextElementBottom, + getSeriesColors, +} from "./charts.helpers"; + +import type { ChartElements, Spreadsheet } from "./charts.types"; + +export const renderBarChart = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + colorSeed?: number, +): ChartElements => { + const series = spreadsheet.series; + const layout = getCartesianChartLayout("bar", series.length); + const max = Math.max( + 1, + ...series.flatMap((seriesData) => + seriesData.values.map((value) => Math.max(0, value)), + ), + ); + const colorOffset = getColorOffset(colorSeed); + const backgroundColor = getBackgroundColor(colorOffset); + const seriesColors = getSeriesColors(series.length, colorOffset); + const interBarGap = + series.length > 1 + ? Math.max(1, Math.floor(layout.gap / (series.length + 1))) + : 0; + const barWidth = + series.length > 1 + ? Math.max( + 2, + (layout.slotWidth - interBarGap * (series.length - 1)) / + series.length, + ) + : layout.slotWidth; + const clusterWidth = + series.length * barWidth + interBarGap * (series.length - 1); + const clusterOffset = (layout.slotWidth - clusterWidth) / 2; + + const bars = series[0].values.flatMap((_, categoryIndex) => + series.map((seriesData, seriesIndex) => { + const value = Math.max(0, seriesData.values[categoryIndex] ?? 0); + const barHeight = (value / max) * layout.chartHeight; + const barColor = + series.length > 1 ? seriesColors[seriesIndex] : backgroundColor; + return newElement({ + backgroundColor: barColor, + ...commonProps, + type: "rectangle", + fillStyle: series.length > 1 ? "solid" : commonProps.fillStyle, + strokeColor: series.length > 1 ? barColor : commonProps.strokeColor, + x: + x + + categoryIndex * (layout.slotWidth + layout.gap) + + layout.gap + + clusterOffset + + seriesIndex * (barWidth + interBarGap), + y: y - barHeight - layout.gap, + width: barWidth, + height: barHeight, + }); + }), + ); + + const baseElements = chartBaseElements( + spreadsheet, + x, + y, + backgroundColor, + layout, + max, + isDevEnv(), + ); + const xLabels = chartXLabels(spreadsheet, x, y, backgroundColor, layout); + const xLabelsBottomY = Math.max( + y + layout.gap / 2, + ...xLabels.map((label) => getRotatedTextElementBottom(label)), + ); + const { chartWidth } = getChartDimensions(spreadsheet, layout); + const seriesLegend = createSeriesLegend( + series, + seriesColors, + x + chartWidth / 2, + xLabelsBottomY, + y + layout.gap * 5, + backgroundColor, + ); + + return [...baseElements, ...bars, ...seriesLegend]; +}; diff --git a/packages/excalidraw/charts/charts.constants.ts b/packages/excalidraw/charts/charts.constants.ts new file mode 100644 index 0000000000..4cb23da11b --- /dev/null +++ b/packages/excalidraw/charts/charts.constants.ts @@ -0,0 +1,63 @@ +import { + COLOR_PALETTE, + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + VERTICAL_ALIGN, +} from "@excalidraw/common"; + +import type { Radians } from "@excalidraw/math"; + +export const CARTESIAN_BASE_SLOT_WIDTH = 44; +export const CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES = 22; +export const CARTESIAN_BAR_SLOT_EXTRA_MAX = 66; +export const CARTESIAN_LINE_SLOT_WIDTH = 48; +export const CARTESIAN_GAP = 14; +export const CARTESIAN_BAR_HEIGHT = 304; +export const CARTESIAN_LINE_HEIGHT = 320; +export const CARTESIAN_LABEL_ROTATION = 5.87 as Radians; +export const CARTESIAN_LABEL_MIN_WIDTH = 28; +export const CARTESIAN_LABEL_SLOT_PADDING = 4; +export const CARTESIAN_LABEL_AXIS_CLEARANCE = 2; +export const CARTESIAN_LABEL_MAX_WIDTH_BUFFER = 10; +export const CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER = 10; +export const CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER = 8; + +export const BAR_GAP = 12; +export const BAR_HEIGHT = 256; +export const GRID_OPACITY = 10; + +export const RADAR_GRID_LEVELS = 4; +export const RADAR_LABEL_OFFSET = BAR_GAP * 2; +export const RADAR_PADDING = BAR_GAP * 2; +export const RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD = 100; +export const RADAR_AXIS_LABEL_MAX_WIDTH = 140; +export const RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD = 0.35; +export const RADAR_AXIS_LABEL_CLEARANCE = BAR_GAP / 2; +export const RADAR_LEGEND_SWATCH_SIZE = 20; +export const RADAR_LEGEND_ITEM_GAP = BAR_GAP * 2; +export const RADAR_LEGEND_TEXT_GAP = BAR_GAP; + +// Put all common chart element properties here so properties dialog +// shows stable values when selecting chart groups. +export const commonProps = { + fillStyle: "hachure", + fontFamily: DEFAULT_FONT_FAMILY, + fontSize: DEFAULT_FONT_SIZE, + opacity: 100, + roughness: 1, + strokeColor: COLOR_PALETTE.black, + roundness: null, + strokeStyle: "solid", + strokeWidth: 1, + verticalAlign: VERTICAL_ALIGN.MIDDLE, + locked: false, +} as const; + +export type CartesianChartType = "bar" | "line"; + +export type CartesianChartLayout = { + slotWidth: number; + gap: number; + chartHeight: number; + xLabelMaxWidth: number; +}; diff --git a/packages/excalidraw/charts/charts.helpers.ts b/packages/excalidraw/charts/charts.helpers.ts new file mode 100644 index 0000000000..18097b1df9 --- /dev/null +++ b/packages/excalidraw/charts/charts.helpers.ts @@ -0,0 +1,865 @@ +import { pointFrom } from "@excalidraw/math"; + +import { + COLOR_PALETTE, + DEFAULT_CHART_COLOR_INDEX, + FONT_FAMILY, + FONT_SIZES, + ROUNDNESS, + DEFAULT_FONT_SIZE, + getAllColorsSpecificShade, + getFontString, + getLineHeight, + ROUGHNESS, +} from "@excalidraw/common"; + +import { + getApproxMinLineWidth, + measureText, + newElement, + newLinearElement, + newTextElement, + wrapText, +} from "@excalidraw/element"; + +import type { + ChartType, + ExcalidrawTextElement, +} from "@excalidraw/element/types"; +import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; + +import { + BAR_GAP, + CARTESIAN_BAR_HEIGHT, + CARTESIAN_BASE_SLOT_WIDTH, + CARTESIAN_BAR_SLOT_EXTRA_MAX, + CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES, + CARTESIAN_GAP, + CARTESIAN_LABEL_AXIS_CLEARANCE, + CARTESIAN_LABEL_MAX_WIDTH_BUFFER, + CARTESIAN_LABEL_MIN_WIDTH, + CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER, + CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER, + CARTESIAN_LABEL_ROTATION, + CARTESIAN_LABEL_SLOT_PADDING, + CARTESIAN_LINE_HEIGHT, + CARTESIAN_LINE_SLOT_WIDTH, + GRID_OPACITY, + RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD, + RADAR_AXIS_LABEL_CLEARANCE, + RADAR_AXIS_LABEL_MAX_WIDTH, + RADAR_LABEL_OFFSET, + RADAR_LEGEND_ITEM_GAP, + RADAR_LEGEND_SWATCH_SIZE, + RADAR_LEGEND_TEXT_GAP, + RADAR_PADDING, + RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD, + BAR_HEIGHT, + commonProps, + type CartesianChartLayout, + type CartesianChartType, +} from "./charts.constants"; + +import type { + ChartElements, + Spreadsheet, + SpreadsheetSeries, +} from "./charts.types"; + +const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + +const getSpreadsheetDimensionCount = (spreadsheet: Spreadsheet) => + spreadsheet.labels?.length ?? spreadsheet.series[0]?.values.length ?? 0; + +export const isSpreadsheetValidForChartType = ( + spreadsheet: Spreadsheet | null, + chartType: ChartType, +) => { + if (!spreadsheet) { + return false; + } + + const dimensionCount = getSpreadsheetDimensionCount(spreadsheet); + if (dimensionCount < 2) { + return false; + } + + if (chartType === "radar") { + return dimensionCount >= 3; + } + + return true; +}; + +const getSeriesAwareSlotWidth = ( + baseSlotWidth: number, + seriesCount: number, +) => { + const extraSlotWidth = + seriesCount <= 1 + ? 0 + : Math.min( + CARTESIAN_BAR_SLOT_EXTRA_MAX, + (seriesCount - 1) * CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES, + ); + return baseSlotWidth + extraSlotWidth; +}; + +export const getCartesianChartLayout = ( + chartType: CartesianChartType, + seriesCount: number, +): CartesianChartLayout => { + if (chartType === "line") { + const slotWidth = getSeriesAwareSlotWidth( + CARTESIAN_LINE_SLOT_WIDTH, + seriesCount, + ); + return { + slotWidth, + gap: CARTESIAN_GAP, + chartHeight: CARTESIAN_LINE_HEIGHT, + xLabelMaxWidth: + slotWidth + CARTESIAN_GAP * 3 + CARTESIAN_LABEL_MAX_WIDTH_BUFFER, + }; + } + + const slotWidth = getSeriesAwareSlotWidth( + CARTESIAN_BASE_SLOT_WIDTH, + seriesCount, + ); + return { + slotWidth, + gap: CARTESIAN_GAP, + chartHeight: CARTESIAN_BAR_HEIGHT, + xLabelMaxWidth: + slotWidth + CARTESIAN_GAP * 3 + CARTESIAN_LABEL_MAX_WIDTH_BUFFER, + }; +}; + +export const getChartDimensions = ( + spreadsheet: Spreadsheet, + layout: CartesianChartLayout, +) => { + const chartWidth = + (layout.slotWidth + layout.gap) * spreadsheet.series[0].values.length + + layout.gap; + const chartHeight = layout.chartHeight + layout.gap * 2; + return { chartWidth, chartHeight }; +}; + +export const getRadarDimensions = () => { + const chartWidth = BAR_HEIGHT + RADAR_PADDING * 2; + const chartHeight = BAR_HEIGHT + RADAR_PADDING * 2; + return { chartWidth, chartHeight }; +}; + +const getCircularDistance = ( + firstIndex: number, + secondIndex: number, + paletteSize: number, +) => { + const absoluteDistance = Math.abs(firstIndex - secondIndex); + return Math.min(absoluteDistance, paletteSize - absoluteDistance); +}; + +export const getSeriesColors = ( + seriesCount: number, + colorOffset: number, +): readonly string[] => { + if (seriesCount <= 0 || bgColors.length === 0) { + return []; + } + + const paletteSize = bgColors.length; + const startIndex = ((colorOffset % paletteSize) + paletteSize) % paletteSize; + const selectedIndices = [startIndex]; + const maxUniqueColors = Math.min(seriesCount, paletteSize); + const availableIndices = new Set( + Array.from({ length: paletteSize }, (_, index) => index).filter( + (index) => index !== startIndex, + ), + ); + + while (selectedIndices.length < maxUniqueColors) { + let bestIndex = -1; + let bestMinDistance = -1; + let bestAverageDistance = -1; + + for (const candidateIndex of availableIndices) { + const distances = selectedIndices.map((selectedIndex) => + getCircularDistance(candidateIndex, selectedIndex, paletteSize), + ); + const minDistance = Math.min(...distances); + const averageDistance = + distances.reduce((total, distance) => total + distance, 0) / + distances.length; + + if ( + minDistance > bestMinDistance || + (minDistance === bestMinDistance && + averageDistance > bestAverageDistance) + ) { + bestIndex = candidateIndex; + bestMinDistance = minDistance; + bestAverageDistance = averageDistance; + } + } + + selectedIndices.push(bestIndex); + availableIndices.delete(bestIndex); + } + + return Array.from( + { length: seriesCount }, + (_, index) => bgColors[selectedIndices[index % selectedIndices.length]], + ); +}; + +export const getColorOffset = (colorSeed?: number) => { + if (bgColors.length === 0) { + return 0; + } + + if (typeof colorSeed !== "number" || !Number.isFinite(colorSeed)) { + return Math.floor(Math.random() * bgColors.length); + } + + const seedText = colorSeed.toString(); + let hash = 0; + for (let index = 0; index < seedText.length; index++) { + hash = (hash * 31 + seedText.charCodeAt(index)) | 0; + } + return Math.abs(hash) % bgColors.length; +}; + +export const getBackgroundColor = (colorOffset: number) => + bgColors[colorOffset]; + +export const getRadarValueScale = ( + series: SpreadsheetSeries[], + _labelsLength: number, +) => { + const allValues = series.flatMap((s) => + s.values.map((value) => Math.max(0, value)), + ); + const positiveValues = allValues.filter((value) => value > 0); + const max = Math.max(1, ...allValues); + const minPositive = + positiveValues.length > 0 ? Math.min(...positiveValues) : 1; + const useLogScale = + series.length === 1 && + minPositive > 0 && + max / minPositive >= RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD; + + return { + renderSteps: false, + normalize: (value: number, _axisIndex: number) => { + const safeValue = Math.max(0, value); + return useLogScale + ? Math.log10(safeValue + 1) / Math.log10(max + 1) + : safeValue / max; + }, + }; +}; + +const shouldWrapRadarText = (text: string) => /\s/.test(text.trim()); + +export const getRadarDisplayText = ( + text: string, + fontString: ReturnType, + maxWidth: number, +) => { + return shouldWrapRadarText(text) + ? wrapText(text, fontString, maxWidth) + : text; +}; + +export const createRadarAxisLabels = ( + labels: readonly string[], + angles: readonly number[], + centerX: number, + centerY: number, + radius: number, + backgroundColor: string, +): { + axisLabels: ChartElements; + axisLabelTopY: number; + axisLabelBottomY: number; +} => { + const fontFamily = FONT_FAMILY.Excalifont; + const fontSize = FONT_SIZES.sm; + const lineHeight = getLineHeight(fontFamily); + const fontString = getFontString({ fontFamily, fontSize }); + const baseLabelWidth = Math.min( + RADAR_AXIS_LABEL_MAX_WIDTH, + radius * (labels.length > 8 ? 0.56 : 0.72), + ); + const minLabelWidth = getApproxMinLineWidth(fontString, lineHeight); + + const axisLabels = labels.map((label, index) => { + const angle = angles[index]; + const longestWordWidth = Math.max( + 0, + ...label + .trim() + .split(/\s+/) + .filter(Boolean) + .map((word) => measureText(word, fontString, lineHeight).width), + ); + const maxLabelWidth = Math.max( + minLabelWidth, + baseLabelWidth, + longestWordWidth, + ); + const displayLabel = getRadarDisplayText(label, fontString, maxLabelWidth); + const metrics = measureText(displayLabel, fontString, lineHeight); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + const textAlign: "left" | "center" | "right" = + cos > RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD + ? "left" + : cos < -RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD + ? "right" + : "center"; + + // Keep labels outside the radar ring by projecting text extents + // onto the axis direction. + const centerAlignedXExtent = textAlign === "center" ? metrics.width / 2 : 0; + const projectedExtent = + Math.abs(cos) * centerAlignedXExtent + + Math.abs(sin) * (metrics.height / 2); + const radialOffset = + RADAR_LABEL_OFFSET + projectedExtent + RADAR_AXIS_LABEL_CLEARANCE; + const anchorX = centerX + cos * (radius + radialOffset); + const anchorY = centerY + sin * (radius + radialOffset); + + const yNudge = + sin > RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD + ? BAR_GAP / 3 + : sin < -RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD + ? -BAR_GAP / 3 + : 0; + + return newTextElement({ + backgroundColor, + ...commonProps, + text: displayLabel, + originalText: label, + x: anchorX, + y: anchorY + yNudge, + fontFamily, + fontSize, + lineHeight, + textAlign, + verticalAlign: "middle", + }); + }); + + const axisLabelTopY = Math.min(...axisLabels.map((axisLabel) => axisLabel.y)); + const axisLabelBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + return { axisLabels, axisLabelTopY, axisLabelBottomY }; +}; + +export const createSeriesLegend = ( + series: SpreadsheetSeries[], + seriesColors: readonly string[], + centerX: number, + minLegendTopY: number, + fallbackLegendY: number, + backgroundColor: string, +): ChartElements => { + if (series.length <= 1) { + return []; + } + + const fontFamily = FONT_FAMILY["Lilita One"]; + const fontSize = FONT_SIZES.lg; + const lineHeight = getLineHeight(fontFamily); + const fontString = getFontString({ fontFamily, fontSize }); + const legendItems = series.map((seriesItem, index) => { + const label = seriesItem.title?.trim() || `Series ${index + 1}`; + const displayLabel = getRadarDisplayText(label, fontString, BAR_HEIGHT); + const metrics = measureText(displayLabel, fontString, lineHeight); + const itemWidth = + RADAR_LEGEND_SWATCH_SIZE + RADAR_LEGEND_TEXT_GAP + metrics.width; + return { + label, + displayLabel, + color: seriesColors[index], + width: itemWidth, + height: metrics.height, + }; + }); + const maxLegendHalfHeight = Math.max( + RADAR_LEGEND_SWATCH_SIZE / 2, + ...legendItems.map((item) => item.height / 2), + ); + const legendY = Math.max( + fallbackLegendY, + minLegendTopY + maxLegendHalfHeight + RADAR_LABEL_OFFSET, + ); + + const pillPaddingX = RADAR_LEGEND_ITEM_GAP; + const pillPaddingY = RADAR_LEGEND_SWATCH_SIZE * 0.6; + const totalLegendWidth = + legendItems.reduce((total, item) => total + item.width, 0) + + RADAR_LEGEND_ITEM_GAP * Math.max(0, legendItems.length - 1); + const pillWidth = totalLegendWidth + pillPaddingX * 2; + const pillHeight = maxLegendHalfHeight * 2 + pillPaddingY * 2; + + const legendElements: NonDeletedExcalidrawElement[] = []; + + // rounded pill background + legendElements.push( + newElement({ + ...commonProps, + backgroundColor: "transparent", + type: "rectangle", + fillStyle: "solid", + strokeColor: COLOR_PALETTE.black, + x: centerX - pillWidth / 2, + y: legendY - pillHeight / 2, + width: pillWidth, + height: pillHeight, + roughness: ROUGHNESS.architect, + roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, + }), + ); + + let cursorX = centerX - totalLegendWidth / 2; + + legendItems.forEach((item) => { + // solid filled swatch + legendElements.push( + newElement({ + ...commonProps, + backgroundColor: item.color, + type: "rectangle", + x: cursorX, + y: legendY - RADAR_LEGEND_SWATCH_SIZE / 2, + width: RADAR_LEGEND_SWATCH_SIZE, + height: RADAR_LEGEND_SWATCH_SIZE, + fillStyle: "solid", + strokeColor: item.color, + roughness: ROUGHNESS.architect, + roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, + }), + ); + + // label in default (black) color + legendElements.push( + newTextElement({ + ...commonProps, + text: item.displayLabel, + originalText: item.label, + autoResize: false, + x: cursorX + RADAR_LEGEND_SWATCH_SIZE + RADAR_LEGEND_TEXT_GAP, + y: legendY, + fontFamily, + fontSize, + lineHeight, + textAlign: "left", + verticalAlign: "middle", + }), + ); + + cursorX += item.width + RADAR_LEGEND_ITEM_GAP; + }); + + return legendElements; +}; + +const ellipsifyTextToWidth = ( + text: string, + maxWidth: number, + fontString: ReturnType, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + if (measureText(text, fontString, lineHeight).width <= maxWidth) { + return text; + } + + let end = text.length; + while (end > 1) { + const candidate = `${text.slice(0, end)}...`; + if (measureText(candidate, fontString, lineHeight).width <= maxWidth) { + return candidate; + } + end--; + } + + return text[0] ? `${text[0]}...` : text; +}; + +const wrapOrEllipsifyTextToWidth = ( + text: string, + maxWidth: number, + fontString: ReturnType, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + if (measureText(text, fontString, lineHeight).width <= maxWidth) { + return { wrapped: false, text }; + } + + const words = text.trim().split(/\s+/).filter(Boolean); + if (words.length > 1) { + const hasLongWord = words.some((word) => { + return measureText(word, fontString, lineHeight).width > maxWidth; + }); + if ( + !hasLongWord && + maxWidth >= getApproxMinLineWidth(fontString, lineHeight) + ) { + return { wrapped: true, text: wrapText(text, fontString, maxWidth) }; + } + } + + return { + wrapped: false, + text: ellipsifyTextToWidth(text, maxWidth, fontString, lineHeight), + }; +}; + +const getRotatedBoundingBox = ( + width: number, + height: number, + angle: number, +) => { + const cos = Math.abs(Math.cos(angle)); + const sin = Math.abs(Math.sin(angle)); + return { + width: width * cos + height * sin, + height: width * sin + height * cos, + }; +}; + +type CartesianAxisLabelSpec = { + originalText: string; + text: string; + wrapped: boolean; + metrics: ReturnType; + rotatedWidth: number; + rotatedHeight: number; +}; + +const isEllipsifiedLabel = (text: string) => text.includes("..."); + +const getCartesianAxisLabelSpec = ( + label: string, + maxLabelWidth: number, + maxRotatedWidth: number, + fontString: ReturnType, + lineHeight: ExcalidrawTextElement["lineHeight"], +): CartesianAxisLabelSpec => { + const minWidth = Math.max( + CARTESIAN_LABEL_MIN_WIDTH, + Math.ceil(getApproxMinLineWidth(fontString, lineHeight)), + ); + const maxWidth = Math.max(minWidth, Math.floor(maxLabelWidth)); + const candidateWidths: number[] = []; + for (let width = maxWidth; width >= minWidth; width -= 4) { + candidateWidths.push(width); + } + if (candidateWidths[candidateWidths.length - 1] !== minWidth) { + candidateWidths.push(minWidth); + } + + const getRank = (spec: CartesianAxisLabelSpec) => { + const ellipsified = isEllipsifiedLabel(spec.text); + const visibleChars = spec.text + .replace(/\.\.\./g, "") + .replace(/\n/g, "").length; + const lineCount = spec.text.split("\n").length; + return { + ellipsified, + visibleChars, + lineCount, + }; + }; + + const shouldPrefer = ( + candidate: CartesianAxisLabelSpec, + current: CartesianAxisLabelSpec, + ) => { + const candidateRank = getRank(candidate); + const currentRank = getRank(current); + if (candidateRank.ellipsified !== currentRank.ellipsified) { + return !candidateRank.ellipsified; + } + if (candidateRank.visibleChars !== currentRank.visibleChars) { + return candidateRank.visibleChars > currentRank.visibleChars; + } + if (candidateRank.lineCount !== currentRank.lineCount) { + return candidateRank.lineCount < currentRank.lineCount; + } + return candidate.rotatedHeight < current.rotatedHeight; + }; + + let bestFit: CartesianAxisLabelSpec | null = null; + let bestOverflowAny: { + overflow: number; + spec: CartesianAxisLabelSpec; + } | null = null; + let bestOverflowNonEllipsified: { + overflow: number; + spec: CartesianAxisLabelSpec; + } | null = null; + + for (const width of candidateWidths) { + const { wrapped, text } = wrapOrEllipsifyTextToWidth( + label, + width, + fontString, + lineHeight, + ); + const metrics = measureText(text, fontString, lineHeight); + const rotated = getRotatedBoundingBox( + metrics.width, + metrics.height, + CARTESIAN_LABEL_ROTATION, + ); + const spec = { + originalText: label, + text, + metrics, + rotatedWidth: rotated.width, + rotatedHeight: rotated.height, + wrapped, + }; + const overflow = rotated.width - maxRotatedWidth; + if (overflow <= 0) { + if (!bestFit || shouldPrefer(spec, bestFit)) { + bestFit = spec; + } + continue; + } + if ( + !bestOverflowAny || + overflow < bestOverflowAny.overflow || + (overflow === bestOverflowAny.overflow && + shouldPrefer(spec, bestOverflowAny.spec)) + ) { + bestOverflowAny = { overflow, spec }; + } + if ( + !isEllipsifiedLabel(spec.text) && + (!bestOverflowNonEllipsified || + overflow < bestOverflowNonEllipsified.overflow || + (overflow === bestOverflowNonEllipsified.overflow && + shouldPrefer(spec, bestOverflowNonEllipsified.spec))) + ) { + bestOverflowNonEllipsified = { overflow, spec }; + } + } + + if (bestFit) { + return bestFit; + } + + if ( + bestOverflowNonEllipsified && + bestOverflowAny && + bestOverflowNonEllipsified.overflow <= + bestOverflowAny.overflow + CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER + ) { + return bestOverflowNonEllipsified.spec; + } + + return bestOverflowAny!.spec; +}; + +export const getRotatedTextElementBottom = ( + element: NonDeletedExcalidrawElement, +) => { + if (element.type !== "text") { + return element.y + element.height; + } + const rotated = getRotatedBoundingBox( + element.width, + element.height, + element.angle, + ); + return element.y + element.height / 2 + rotated.height / 2; +}; + +export const chartXLabels = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + backgroundColor: string, + layout: CartesianChartLayout, +): ChartElements => { + const fontFamily = commonProps.fontFamily; + const fontSize = FONT_SIZES.sm; + const lineHeight = getLineHeight(fontFamily); + const fontString = getFontString({ fontFamily, fontSize }); + const maxRotatedWidth = Math.max( + 1, + layout.slotWidth + + layout.gap - + CARTESIAN_LABEL_SLOT_PADDING * 2 + + CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER, + ); + const axisY = y; + + return ( + spreadsheet.labels?.map((label, index) => { + const labelSpec = getCartesianAxisLabelSpec( + label, + layout.xLabelMaxWidth, + maxRotatedWidth, + fontString, + lineHeight, + ); + const centerX = + x + + index * (layout.slotWidth + layout.gap) + + layout.gap + + layout.slotWidth / 2; + const labelY = + axisY + + CARTESIAN_LABEL_AXIS_CLEARANCE + + (labelSpec.rotatedHeight - labelSpec.metrics.height) / 2; + + return newTextElement({ + backgroundColor, + ...commonProps, + text: labelSpec.text, + originalText: labelSpec.wrapped ? label : labelSpec.text, + autoResize: !labelSpec.wrapped, + x: centerX, + y: labelY, + angle: CARTESIAN_LABEL_ROTATION, + fontSize, + lineHeight, + textAlign: "center", + verticalAlign: "top", + }); + }) || [] + ); +}; + +const chartYLabels = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + backgroundColor: string, + layout: CartesianChartLayout, + maxValue = Math.max(...spreadsheet.series[0].values), +): ChartElements => { + const minYLabel = newTextElement({ + backgroundColor, + ...commonProps, + x: x - layout.gap, + y: y - layout.gap, + text: "0", + textAlign: "right", + }); + + const maxYLabel = newTextElement({ + backgroundColor, + ...commonProps, + x: x - layout.gap, + y: y - layout.chartHeight - minYLabel.height / 2, + text: maxValue.toLocaleString(), + textAlign: "right", + }); + + return [minYLabel, maxYLabel]; +}; + +const chartLines = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + backgroundColor: string, + layout: CartesianChartLayout, +): ChartElements => { + const { chartWidth, chartHeight } = getChartDimensions(spreadsheet, layout); + const xLine = newLinearElement({ + backgroundColor, + ...commonProps, + type: "line", + x, + y, + width: chartWidth, + points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], + }); + + const yLine = newLinearElement({ + backgroundColor, + ...commonProps, + type: "line", + x, + y, + height: chartHeight, + points: [pointFrom(0, 0), pointFrom(0, -chartHeight)], + }); + + const maxLine = newLinearElement({ + backgroundColor, + ...commonProps, + type: "line", + x, + y: y - layout.chartHeight - layout.gap, + strokeStyle: "dotted", + width: chartWidth, + opacity: GRID_OPACITY, + points: [pointFrom(0, 0), pointFrom(chartWidth, 0)], + }); + + return [xLine, yLine, maxLine]; +}; + +// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g +export const chartBaseElements = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + backgroundColor: string, + layout: CartesianChartLayout, + maxValue = Math.max(...spreadsheet.series[0].values), + debug?: boolean, +): ChartElements => { + const { chartWidth, chartHeight } = getChartDimensions(spreadsheet, layout); + + const title = spreadsheet.title + ? newTextElement({ + backgroundColor, + ...commonProps, + text: spreadsheet.title, + x: x + chartWidth / 2, + y: y - layout.chartHeight - layout.gap * 2 - DEFAULT_FONT_SIZE, + roundness: null, + textAlign: "center", + fontSize: FONT_SIZES.xl, + fontFamily: FONT_FAMILY["Lilita One"], + }) + : null; + + const debugRect = debug + ? newElement({ + backgroundColor, + ...commonProps, + type: "rectangle", + x, + y: y - chartHeight, + width: chartWidth, + height: chartHeight, + strokeColor: COLOR_PALETTE.black, + fillStyle: "solid", + opacity: 6, + }) + : null; + + return [ + ...(debugRect ? [debugRect] : []), + ...(title ? [title] : []), + ...chartXLabels(spreadsheet, x, y, backgroundColor, layout), + ...chartYLabels(spreadsheet, x, y, backgroundColor, layout, maxValue), + ...chartLines(spreadsheet, x, y, backgroundColor, layout), + ]; +}; diff --git a/packages/excalidraw/charts/charts.line.ts b/packages/excalidraw/charts/charts.line.ts new file mode 100644 index 0000000000..b08774d8b3 --- /dev/null +++ b/packages/excalidraw/charts/charts.line.ts @@ -0,0 +1,130 @@ +import { pointFrom } from "@excalidraw/math"; + +import { isDevEnv } from "@excalidraw/common"; + +import { newElement, newLinearElement } from "@excalidraw/element"; + +import type { LocalPoint } from "@excalidraw/math"; + +import { GRID_OPACITY, commonProps } from "./charts.constants"; +import { + chartBaseElements, + chartXLabels, + createSeriesLegend, + getBackgroundColor, + getCartesianChartLayout, + getChartDimensions, + getColorOffset, + getRotatedTextElementBottom, + getSeriesColors, +} from "./charts.helpers"; + +import type { ChartElements, Spreadsheet } from "./charts.types"; + +export const renderLineChart = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + colorSeed?: number, +): ChartElements => { + const series = spreadsheet.series; + const layout = getCartesianChartLayout("line", series.length); + const max = Math.max(1, ...series.flatMap((seriesData) => seriesData.values)); + const colorOffset = getColorOffset(colorSeed); + const backgroundColor = getBackgroundColor(colorOffset); + const seriesColors = getSeriesColors(series.length, colorOffset); + + const lines = series.map((seriesData, seriesIndex) => { + const points = seriesData.values.map((value, valueIndex) => + pointFrom( + valueIndex * (layout.slotWidth + layout.gap), + -(value / max) * layout.chartHeight, + ), + ); + + const maxX = Math.max(...points.map((point) => point[0])); + const maxY = Math.max(...points.map((point) => point[1])); + const minX = Math.min(...points.map((point) => point[0])); + const minY = Math.min(...points.map((point) => point[1])); + + return newLinearElement({ + backgroundColor: "transparent", + ...commonProps, + type: "line", + x: x + layout.gap + layout.slotWidth / 2, + y: y - layout.gap, + height: maxY - minY, + width: maxX - minX, + strokeColor: seriesColors[seriesIndex], + strokeWidth: 2, + points, + }); + }); + + const dots = series.flatMap((seriesData, seriesIndex) => + seriesData.values.map((value, valueIndex) => { + const cx = valueIndex * (layout.slotWidth + layout.gap) + layout.gap / 2; + const cy = -(value / max) * layout.chartHeight + layout.gap / 2; + return newElement({ + backgroundColor: seriesColors[seriesIndex], + ...commonProps, + fillStyle: "solid", + strokeColor: seriesColors[seriesIndex], + strokeWidth: 2, + type: "ellipse", + x: x + cx + layout.slotWidth / 2, + y: y + cy - layout.gap * 2, + width: layout.gap, + height: layout.gap, + }); + }), + ); + + const guideValues = series[0].values.map((_, valueIndex) => + Math.max( + 0, + ...series.map((seriesData) => seriesData.values[valueIndex] ?? 0), + ), + ); + const guides = guideValues.map((value, valueIndex) => { + const cx = valueIndex * (layout.slotWidth + layout.gap) + layout.gap / 2; + const cy = (value / max) * layout.chartHeight + layout.gap / 2 + layout.gap; + return newLinearElement({ + backgroundColor, + ...commonProps, + type: "line", + x: x + cx + layout.slotWidth / 2 + layout.gap / 2, + y: y - cy, + height: cy, + strokeStyle: "dotted", + opacity: GRID_OPACITY, + points: [pointFrom(0, 0), pointFrom(0, cy)], + }); + }); + + const baseElements = chartBaseElements( + spreadsheet, + x, + y, + backgroundColor, + layout, + max, + isDevEnv(), + ); + const xLabels = chartXLabels(spreadsheet, x, y, backgroundColor, layout); + const xLabelsBottomY = Math.max( + y + layout.gap / 2, + ...xLabels.map((label) => getRotatedTextElementBottom(label)), + ); + const { chartWidth } = getChartDimensions(spreadsheet, layout); + const seriesLegend = createSeriesLegend( + series, + seriesColors, + x + chartWidth / 2, + xLabelsBottomY, + y + layout.gap * 5, + backgroundColor, + ); + + return [...baseElements, ...lines, ...guides, ...dots, ...seriesLegend]; +}; diff --git a/packages/excalidraw/charts/charts.parse.ts b/packages/excalidraw/charts/charts.parse.ts new file mode 100644 index 0000000000..f6d71fdf69 --- /dev/null +++ b/packages/excalidraw/charts/charts.parse.ts @@ -0,0 +1,174 @@ +import { type ParseSpreadsheetResult } from "./charts.types"; + +/** + * @private exported for testing + */ +export const tryParseNumber = (s: string): number | null => { + const match = + /^([-+]?)[$\u20AC\u00A3\u00A5\u20A9]?([-+]?)([\d.,]+)[%]?$/.exec(s); + if (!match) { + return null; + } + return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, "")); +}; + +const isNumericColumn = (lines: string[][], columnIndex: number) => + lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null); + +/** + * @private exported for testing + */ +export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => { + const numCols = cells[0].length; + + if (numCols > 2) { + const hasHeader = cells[0].every((cell) => tryParseNumber(cell) === null); + const rows = hasHeader ? cells.slice(1) : cells; + + if (rows.length < 1) { + return { ok: false, reason: "No data rows" }; + } + + const invalidNumericColumn = rows.some((row) => + row.slice(1).some((value) => tryParseNumber(value) === null), + ); + if (invalidNumericColumn) { + return { ok: false, reason: "Value is not numeric" }; + } + + // When there are more value columns than data rows, the data is in + // "wide" format — transpose so columns become labels (dimensions) + // and rows become series. This enables e.g. radar charts for wide data. + const numValueCols = numCols - 1; + if (numValueCols > rows.length) { + const labels = hasHeader ? cells[0].slice(1).map((h) => h.trim()) : null; + const series = rows.map((row) => ({ + title: row[0]?.trim() || null, + values: row.slice(1).map((v) => tryParseNumber(v)!), + })); + const title = + series.length === 1 + ? series[0].title + : hasHeader + ? cells[0][0].trim() || null + : null; + return { + ok: true, + data: { title, labels, series }, + }; + } + + const series = cells[0].slice(1).map((seriesTitle, index) => { + const valueColumnIndex = index + 1; + const fallbackTitle = `Series ${valueColumnIndex}`; + return { + title: hasHeader ? seriesTitle.trim() || fallbackTitle : fallbackTitle, + values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!), + }; + }); + + return { + ok: true, + data: { + title: hasHeader ? cells[0][0].trim() || null : null, + labels: rows.map((row) => row[0]), + series, + }, + }; + } + + if (numCols === 1) { + if (!isNumericColumn(cells, 0)) { + return { ok: false, reason: "Value is not numeric" }; + } + + const hasHeader = tryParseNumber(cells[0][0]) === null; + const title = hasHeader ? cells[0][0] : null; + const values = (hasHeader ? cells.slice(1) : cells).map((line) => + tryParseNumber(line[0]), + ); + + if (values.length < 2) { + return { ok: false, reason: "Less than two rows" }; + } + + return { + ok: true, + data: { + title, + labels: null, + series: [{ title, values: values as number[] }], + }, + }; + } + + const hasHeader = tryParseNumber(cells[0][1]) === null; + const rows = hasHeader ? cells.slice(1) : cells; + + if (rows.length < 2) { + return { ok: false, reason: "Less than 2 rows" }; + } + + const invalidNumericColumn = rows.some( + (row) => tryParseNumber(row[1]) === null, + ); + if (invalidNumericColumn) { + return { ok: false, reason: "Value is not numeric" }; + } + + const title = hasHeader ? cells[0][1] : null; + + return { + ok: true, + data: { + title, + labels: rows.map((row) => row[0]), + series: [{ title, values: rows.map((row) => tryParseNumber(row[1])!) }], + }, + }; +}; + +export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => { + // Copy/paste from excel, spreadsheets, TSV, CSV, semicolon-separated. + const parseDelimitedLines = (delimiter: "\t" | "," | ";") => + text + .replace(/\r\n?/g, "\n") + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => line.split(delimiter).map((cell) => cell.trim())); + + // Score each delimiter: prefer consistent column counts with the most columns. + // A delimiter that produces all single-column rows likely isn't the right one. + const candidates = (["\t", ",", ";"] as const).map((delimiter) => { + const parsed = parseDelimitedLines(delimiter); + const numCols = parsed[0]?.length ?? 0; + const isConsistent = + parsed.length > 0 && parsed.every((line) => line.length === numCols); + return { delimiter, parsed, numCols, isConsistent }; + }); + + // Prefer: consistent + most columns. Among ties, tab > comma > semicolon + // (the array order already encodes this priority). + const best = + candidates.find((c) => c.isConsistent && c.numCols > 1) ?? + candidates.find((c) => c.isConsistent) ?? + candidates[0]; + + const lines = best.parsed; + + if (lines.length === 0) { + return { ok: false, reason: "No values" }; + } + + const numColsFirstLine = lines[0].length; + const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine); + + if (!isSpreadsheet) { + return { + ok: false, + reason: "All rows don't have same number of columns", + }; + } + + return tryParseCells(lines); +}; diff --git a/packages/excalidraw/charts/charts.radar.ts b/packages/excalidraw/charts/charts.radar.ts new file mode 100644 index 0000000000..6606a4af60 --- /dev/null +++ b/packages/excalidraw/charts/charts.radar.ts @@ -0,0 +1,199 @@ +import { pointFrom } from "@excalidraw/math"; + +import { + FONT_FAMILY, + FONT_SIZES, + getFontString, + getLineHeight, + ROUGHNESS, +} from "@excalidraw/common"; + +import { + measureText, + newLinearElement, + newTextElement, +} from "@excalidraw/element"; + +import type { LocalPoint } from "@excalidraw/math"; + +import { + BAR_GAP, + BAR_HEIGHT, + GRID_OPACITY, + RADAR_GRID_LEVELS, + RADAR_LABEL_OFFSET, + commonProps, +} from "./charts.constants"; +import { + createRadarAxisLabels, + createSeriesLegend, + getBackgroundColor, + getColorOffset, + getRadarDimensions, + getRadarDisplayText, + getRadarValueScale, + getSeriesColors, + isSpreadsheetValidForChartType, +} from "./charts.helpers"; + +import type { ChartElements, Spreadsheet } from "./charts.types"; + +export const renderRadarChart = ( + spreadsheet: Spreadsheet, + x: number, + y: number, + colorSeed?: number, +): ChartElements | null => { + if (!isSpreadsheetValidForChartType(spreadsheet, "radar")) { + return null; + } + + const labels = + spreadsheet.labels ?? + spreadsheet.series[0].values.map((_, index) => `Value ${index + 1}`); + + const series = spreadsheet.series; + const { normalize, renderSteps } = getRadarValueScale(series, labels.length); + const colorOffset = getColorOffset(colorSeed); + const backgroundColor = getBackgroundColor(colorOffset); + const seriesColors = getSeriesColors(series.length, colorOffset); + const { chartWidth, chartHeight } = getRadarDimensions(); + const centerX = x + chartWidth / 2; + const centerY = y - chartHeight / 2; + const radius = BAR_HEIGHT / 2; + const angles = labels.map( + (_, index) => -Math.PI / 2 + (Math.PI * 2 * index) / labels.length, + ); + + const { axisLabels, axisLabelTopY, axisLabelBottomY } = createRadarAxisLabels( + labels, + angles, + centerX, + centerY, + radius, + backgroundColor, + ); + + const titleFontFamily = FONT_FAMILY["Lilita One"]; + const titleFontSize = FONT_SIZES.xl; + const titleLineHeight = getLineHeight(titleFontFamily); + const titleFontString = getFontString({ + fontFamily: titleFontFamily, + fontSize: titleFontSize, + }); + const titleText = spreadsheet.title + ? getRadarDisplayText( + spreadsheet.title, + titleFontString, + chartWidth + RADAR_LABEL_OFFSET * 2, + ) + : null; + const titleTextMetrics = titleText + ? measureText(titleText, titleFontString, titleLineHeight) + : null; + const title = titleText + ? newTextElement({ + backgroundColor, + ...commonProps, + text: titleText, + originalText: spreadsheet.title ?? titleText, + x: x + chartWidth / 2, + y: axisLabelTopY - RADAR_LABEL_OFFSET - titleTextMetrics!.height / 2, + fontFamily: titleFontFamily, + fontSize: titleFontSize, + lineHeight: titleLineHeight, + textAlign: "center", + }) + : null; + + const radarGridLines = renderSteps + ? Array.from({ length: RADAR_GRID_LEVELS }, (_, levelIndex) => { + const levelRatio = (levelIndex + 1) / RADAR_GRID_LEVELS; + const levelRadius = radius * levelRatio; + const points = angles.map((angle) => + pointFrom( + Math.cos(angle) * levelRadius, + Math.sin(angle) * levelRadius, + ), + ); + points.push(pointFrom(points[0][0], points[0][1])); + + return newLinearElement({ + backgroundColor: "transparent", + ...commonProps, + type: "line", + x: centerX, + y: centerY, + width: levelRadius * 2, + height: levelRadius * 2, + strokeStyle: "solid", + roughness: ROUGHNESS.architect, + opacity: GRID_OPACITY, + polygon: true, + points, + }); + }) + : []; + + const spokes = angles.map((angle) => { + const px = Math.cos(angle) * radius; + const py = Math.sin(angle) * radius; + return newLinearElement({ + backgroundColor: "transparent", + ...commonProps, + type: "line", + x: centerX, + y: centerY, + width: Math.abs(px), + height: Math.abs(py), + strokeStyle: "solid", + roughness: ROUGHNESS.architect, + opacity: GRID_OPACITY, + points: [pointFrom(0, 0), pointFrom(px, py)], + }); + }); + + const seriesPolygons = series.map((seriesData, index) => { + const points = angles.map((angle, axisIndex) => { + const value = seriesData.values[axisIndex] ?? 0; + const pointRadius = normalize(value, axisIndex) * radius; + return pointFrom( + Math.cos(angle) * pointRadius, + Math.sin(angle) * pointRadius, + ); + }); + points.push(pointFrom(points[0][0], points[0][1])); + + return newLinearElement({ + backgroundColor: "transparent", + ...commonProps, + type: "line", + x: centerX, + y: centerY, + width: radius * 2, + height: radius * 2, + strokeColor: seriesColors[index], + strokeWidth: 2, + polygon: true, + points, + }); + }); + + const seriesLegend = createSeriesLegend( + series, + seriesColors, + centerX, + axisLabelBottomY, + y + BAR_GAP * 5, + backgroundColor, + ); + + return [ + ...(title ? [title] : []), + ...axisLabels, + ...radarGridLines, + ...spokes, + ...seriesPolygons, + ...seriesLegend, + ]; +}; diff --git a/packages/excalidraw/charts/charts.types.ts b/packages/excalidraw/charts/charts.types.ts new file mode 100644 index 0000000000..29f3971a58 --- /dev/null +++ b/packages/excalidraw/charts/charts.types.ts @@ -0,0 +1,18 @@ +import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; + +export type ChartElements = readonly NonDeletedExcalidrawElement[]; + +export interface Spreadsheet { + title: string | null; + labels: string[] | null; + series: SpreadsheetSeries[]; +} + +export interface SpreadsheetSeries { + title: string | null; + values: number[]; +} + +export type ParseSpreadsheetResult = + | { ok: false; reason: string } + | { ok: true; data: Spreadsheet }; diff --git a/packages/excalidraw/charts/index.ts b/packages/excalidraw/charts/index.ts new file mode 100644 index 0000000000..d806546a49 --- /dev/null +++ b/packages/excalidraw/charts/index.ts @@ -0,0 +1,38 @@ +import type { ChartType } from "@excalidraw/element/types"; + +import { renderBarChart } from "./charts.bar"; +import { renderLineChart } from "./charts.line"; +import { + tryParseCells, + tryParseNumber, + tryParseSpreadsheet, +} from "./charts.parse"; +import { renderRadarChart } from "./charts.radar"; + +import type { ChartElements, Spreadsheet } from "./charts.types"; + +export { + type ParseSpreadsheetResult, + type Spreadsheet, + type SpreadsheetSeries, + type ChartElements, +} from "./charts.types"; + +export { isSpreadsheetValidForChartType } from "./charts.helpers"; +export { tryParseCells, tryParseNumber, tryParseSpreadsheet }; + +export const renderSpreadsheet = ( + chartType: ChartType, + spreadsheet: Spreadsheet, + x: number, + y: number, + colorSeed?: number, +): ChartElements | null => { + if (chartType === "line") { + return renderLineChart(spreadsheet, x, y, colorSeed); + } + if (chartType === "radar") { + return renderRadarChart(spreadsheet, x, y, colorSeed); + } + return renderBarChart(spreadsheet, x, y, colorSeed); +}; diff --git a/packages/excalidraw/clipboard.test.ts b/packages/excalidraw/clipboard.test.ts index 2115c3eff2..6f2b6fc374 100644 --- a/packages/excalidraw/clipboard.test.ts +++ b/packages/excalidraw/clipboard.test.ts @@ -155,67 +155,4 @@ describe("parseClipboard()", () => { }, ]); }); - - it("should parse spreadsheet from either text/plain and text/html", async () => { - let clipboardData; - // ------------------------------------------------------------------------- - clipboardData = await parseClipboard( - await parseDataTransferEvent( - createPasteEvent({ - types: { - "text/plain": `a b - 1 2 - 4 5 - 7 10`, - }, - }), - ), - ); - expect(clipboardData.spreadsheet).toEqual({ - title: "b", - labels: ["1", "4", "7"], - values: [2, 5, 10], - }); - // ------------------------------------------------------------------------- - clipboardData = await parseClipboard( - await parseDataTransferEvent( - createPasteEvent({ - types: { - "text/html": `a b - 1 2 - 4 5 - 7 10`, - }, - }), - ), - ); - expect(clipboardData.spreadsheet).toEqual({ - title: "b", - labels: ["1", "4", "7"], - values: [2, 5, 10], - }); - // ------------------------------------------------------------------------- - clipboardData = await parseClipboard( - await parseDataTransferEvent( - createPasteEvent({ - types: { - "text/html": ` - -
ab
12
45
710
- - `, - "text/plain": `a b - 1 2 - 4 5 - 7 10`, - }, - }), - ), - ); - expect(clipboardData.spreadsheet).toEqual({ - title: "b", - labels: ["1", "4", "7"], - values: [2, 5, 10], - }); - }); }); diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 6033b857af..165534741f 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -33,12 +33,8 @@ import { normalizeFile, } from "./data/blob"; -import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; - import type { FileSystemHandle } from "./data/filesystem"; -import type { Spreadsheet } from "./charts"; - import type { BinaryFiles } from "./types"; type ElementsClipboard = { @@ -50,7 +46,6 @@ type ElementsClipboard = { export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[]; export interface ClipboardData { - spreadsheet?: Spreadsheet; elements?: readonly ExcalidrawElement[]; files?: BinaryFiles; text?: string; @@ -215,16 +210,6 @@ export const copyToClipboard = async ( ); }; -const parsePotentialSpreadsheet = ( - text: string, -): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => { - const result = tryParseSpreadsheet(text); - if (result.type === VALID_SPREADSHEET) { - return { spreadsheet: result.spreadsheet }; - } - return null; -}; - /** internal, specific to parsing paste events. Do not reuse. */ function parseHTMLTree(el: ChildNode) { let result: PastedMixedContent = []; @@ -551,19 +536,6 @@ export const parseClipboard = async ( }; } - try { - // if system clipboard contains spreadsheet, use it even though it's - // technically possible it's staler than in-app clipboard - const spreadsheetResult = - !isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value); - - if (spreadsheetResult) { - return spreadsheetResult; - } - } catch (error: any) { - console.error(error); - } - try { const systemClipboardData = JSON.parse(parsedEventData.value); const programmaticAPI = diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 18791f8f9b..be065d826d 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import { useRef, useState } from "react"; -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import { CLASSES, @@ -226,7 +226,7 @@ export const SelectedShapeActions = ({ {(appState.activeTool.type === "text" || targetElements.some(isTextElement)) && ( <> - {renderAction("changeFontFamily")} +
{renderAction("changeFontFamily")}
{renderAction("changeFontSize")} {(appState.activeTool.type === "text" || suppportsHorizontalAlign(targetElements, elementsMap)) && @@ -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 fa9a02b492..0b361e0e70 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 { @@ -118,7 +119,6 @@ import { fixBindingsAfterDeletion, getHoveredElementForBinding, isBindingEnabled, - shouldEnableBindingForPointerEvent, updateBoundElements, LinearElementEditor, newElementWith, @@ -249,6 +249,7 @@ import { maxBindingDistance_simple, convertToExcalidrawElements, type ExcalidrawElementSkeleton, + getSnapOutlineMidPoint, handleFocusPointDrag, handleFocusPointHover, handleFocusPointPointerDown, @@ -317,6 +318,8 @@ import { actionToggleElementLock, actionToggleLinearEditor, actionToggleObjectsSnapMode, + actionToggleArrowBinding, + actionToggleMidpointSnapping, actionToggleCropEditor, } from "../actions"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; @@ -423,6 +426,8 @@ import { EraserTrail } from "../eraser"; import { getShortcutKey } from "../shortcut"; +import { tryParseSpreadsheet } from "../charts"; + import ConvertElementTypePopup, { getConversionTypeFromElements, convertElementTypePopupAtom, @@ -441,10 +446,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"; @@ -1209,12 +1211,112 @@ 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.state.viewModeEnabled || + this.state.activeTool.type === "laser" || + 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 || + // 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 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 && this.state.activeEmbeddable?.state === "active" ) { - return; + return true; } // The delay serves two purposes @@ -1225,31 +1327,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, }), "*", ); @@ -1286,6 +1391,8 @@ class App extends React.Component { "*", ); } + + return true; } private isIframeLikeElementCenter( @@ -2628,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 = () => { @@ -3438,14 +3547,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 ------------------- @@ -4754,17 +4868,87 @@ 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: "selection" }); + 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; } - 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 }); @@ -4887,44 +5071,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); @@ -4988,15 +5136,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) @@ -5023,7 +5162,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); @@ -5082,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 }); } @@ -6137,9 +6280,8 @@ class App extends React.Component { } }; - private redirectToLink = ( + private handleElementLinkClick = ( event: React.PointerEvent, - isTouchScreen: boolean, ) => { const draggedDistance = pointDistance( pointFrom( @@ -6400,15 +6542,28 @@ class App extends React.Component { // and point const { newElement } = this.state; if (!newElement && isBindingEnabled(this.state)) { + const globalPoint = pointFrom( + scenePointerX, + scenePointerY, + ); + const elementsMap = this.scene.getNonDeletedElementsMap(); const hoveredElement = getHoveredElementForBinding( - pointFrom(scenePointerX, scenePointerY), + globalPoint, this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), + elementsMap, maxBindingDistance_simple(this.state.zoom), ); if (hoveredElement) { this.setState({ - suggestedBinding: hoveredElement, + suggestedBinding: { + element: hoveredElement, + midPoint: getSnapOutlineMidPoint( + globalPoint, + hoveredElement, + elementsMap, + this.state.zoom, + ), + }, }); } else if (this.state.suggestedBinding) { this.setState({ @@ -6591,24 +6746,31 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), maxBindingDistance_simple(this.state.zoom), ); - if ( - hit && - !isPointInElement( - pointFrom(scenePointerX, scenePointerY), - hit, - this.scene.getNonDeletedElementsMap(), - ) - ) { + const scenePointer = pointFrom(scenePointerX, scenePointerY); + const elementsMap = this.scene.getNonDeletedElementsMap(); + if (hit && !isPointInElement(scenePointer, hit, elementsMap)) { this.setState({ - suggestedBinding: hit, + suggestedBinding: { + element: hit, + midPoint: getSnapOutlineMidPoint( + scenePointer, + hit, + elementsMap, + this.state.zoom, + ), + }, }); } } - 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") @@ -6693,6 +6855,10 @@ class App extends React.Component { } } + if (isEraserActive(this.state)) { + return; + } + const hitElementMightBeLocked = this.getElementAtPosition( scenePointerX, scenePointerY, @@ -6709,18 +6875,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, @@ -6728,6 +6901,9 @@ class App extends React.Component { ); } else { hideHyperlinkToolip(); + if (isLaserTool) { + return; + } if ( hitElement && (hitElement.link || isEmbeddableElement(hitElement)) && @@ -6760,20 +6936,6 @@ class App extends React.Component { !hitElement?.locked ) { if ( - hitElement && - isIframeLikeElement(hitElement) && - this.isIframeLikeElementCenter( - 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) || @@ -6785,9 +6947,6 @@ class App extends React.Component { ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } - if (this.state.activeEmbeddable?.state === "hover") { - this.setState({ activeEmbeddable: null }); - } } } } else { @@ -6987,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 = { @@ -7207,7 +7374,6 @@ class App extends React.Component { } this.clearSelectionIfNotUsingSelection(); - this.updateBindingEnabledOnPointerMove(event); if (this.handleSelectionOnPointerDown(event, pointerDownState)) { return; @@ -7430,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, @@ -7439,26 +7612,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) { @@ -7479,20 +7635,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.handleElementLinkClick(event); } else if (this.state.viewModeEnabled) { this.setState({ activeEmbeddable: null, @@ -7552,7 +7695,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; @@ -7630,7 +7774,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); @@ -8511,7 +8658,9 @@ class App extends React.Component { ): void => { if (event.ctrlKey) { flushSync(() => { - this.setState({ isBindingEnabled: false }); + this.setState({ + isBindingEnabled: this.state.bindingPreference !== "enabled", + }); }); } @@ -8743,6 +8892,7 @@ class App extends React.Component { selectedPointsIndices: [endIdx], initialState: { ...linearElementEditor.initialState, + arrowStartIsInside: event.altKey, lastClickedPoint: endIdx, origin: pointFrom( pointerDownState.origin.x, @@ -8761,7 +8911,18 @@ class App extends React.Component { bindMode: "orbit", newElement: element, startBoundElement: boundElement, - suggestedBinding: boundElement || null, + suggestedBinding: + boundElement && isBindingElement(element) + ? { + element: boundElement, + midPoint: getSnapOutlineMidPoint( + point, + boundElement, + elementsMap, + this.state.zoom, + ), + } + : null, selectedElementIds: nextSelectedElementIds, selectedLinearElement: linearElementEditor, }; @@ -10823,25 +10984,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); - } }); } @@ -11212,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), @@ -11982,6 +12115,8 @@ class App extends React.Component { CONTEXT_MENU_SEPARATOR, actionToggleGridMode, actionToggleObjectsSnapMode, + actionToggleArrowBinding, + actionToggleMidpointSnapping, actionToggleZenMode, actionToggleViewMode, actionToggleStats, diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index 5de89f7590..f76c109280 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -1,4 +1,4 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import clsx from "clsx"; import { useRef, useEffect } from "react"; 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/FontPicker/FontPicker.test.tsx b/packages/excalidraw/components/FontPicker/FontPicker.test.tsx new file mode 100644 index 0000000000..ab92464fd5 --- /dev/null +++ b/packages/excalidraw/components/FontPicker/FontPicker.test.tsx @@ -0,0 +1,31 @@ +import { KEYS } from "@excalidraw/common"; + +import { Excalidraw } from "../.."; +import { Keyboard } from "../../tests/helpers/ui"; +import { act, render } from "../../tests/test-utils"; + +describe("FontPicker", () => { + it("should be able to open font picker", async () => { + (global as any).ResizeObserver = + (global as any).ResizeObserver || + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; + + const { queryByTestId } = await render( + , + ); + + Keyboard.keyPress(KEYS.T); + + const fontPickerTrigger = queryByTestId("font-family-show-fonts"); + + expect(fontPickerTrigger).not.toBeNull(); + + act(() => { + fontPickerTrigger!.click(); + }); + }); +}); diff --git a/packages/excalidraw/components/FontPicker/FontPicker.tsx b/packages/excalidraw/components/FontPicker/FontPicker.tsx index c52286a173..4325147e17 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.tsx +++ b/packages/excalidraw/components/FontPicker/FontPicker.tsx @@ -1,4 +1,4 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import clsx from "clsx"; import React, { useCallback, useMemo } from "react"; diff --git a/packages/excalidraw/components/FontPicker/FontPickerList.tsx b/packages/excalidraw/components/FontPicker/FontPickerList.tsx index 79dca68ce0..e5e7b85aea 100644 --- a/packages/excalidraw/components/FontPicker/FontPickerList.tsx +++ b/packages/excalidraw/components/FontPicker/FontPickerList.tsx @@ -30,10 +30,12 @@ import { PropertiesPopover } from "../PropertiesPopover"; import { QuickSearch } from "../QuickSearch"; import { ScrollableList } from "../ScrollableList"; import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup"; -import DropdownMenuItem, { +import { DropDownMenuItemBadgeType, DropDownMenuItemBadge, } from "../dropdownMenu/DropdownMenuItem"; +import MenuItemContent from "../dropdownMenu/DropdownMenuItemContent"; +import { getDropdownMenuItemClassName } from "../dropdownMenu/common"; import { FontFamilyCodeIcon, FontFamilyHeadingIcon, @@ -269,45 +271,74 @@ export const FontPickerList = React.memo( [filteredFonts, sceneFamilies], ); - const renderFont = (font: FontDescriptor, index: number) => ( - { - wrappedOnSelect(Number(e.currentTarget.value)); - }} - onMouseMove={() => { - if (hoveredFont?.value !== font.value) { - onHover(font.value); - } - }} - badge={ - font.badge && ( - - {font.badge.placeholder} - - ) + const FontPickerListItem = ({ + font, + order, + }: { + font: FontDescriptor; + order: number; + }) => { + const ref = useRef(null); + const isHovered = font.value === hoveredFont?.value; + const isSelected = font.value === selectedFontFamily; + + useEffect(() => { + if (!isHovered) { + return; } - > - {font.text} - - ); + if (order === 0) { + // scroll into the first item differently, so it's visible what is above (i.e. group title) + ref.current?.scrollIntoView?.({ block: "end" }); + } else { + ref.current?.scrollIntoView?.({ block: "nearest" }); + } + }, [isHovered, order]); + + return ( + + ); + }; const groups = []; if (sceneFilteredFonts.length) { groups.push( - {sceneFilteredFonts.map(renderFont)} + {sceneFilteredFonts.map((font, index) => ( + + ))} , ); } @@ -315,9 +346,13 @@ export const FontPickerList = React.memo( if (availableFilteredFonts.length) { groups.push( - {availableFilteredFonts.map((font, index) => - renderFont(font, index + sceneFilteredFonts.length), - )} + {availableFilteredFonts.map((font, index) => ( + + ))} , ); } diff --git a/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx b/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx index ede3a50e06..eea1cdb8d1 100644 --- a/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx +++ b/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx @@ -1,4 +1,4 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common"; diff --git a/packages/excalidraw/components/IconPicker.tsx b/packages/excalidraw/components/IconPicker.tsx index fab4f109b8..0d644ca7e9 100644 --- a/packages/excalidraw/components/IconPicker.tsx +++ b/packages/excalidraw/components/IconPicker.tsx @@ -1,4 +1,4 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import clsx from "clsx"; import React, { useEffect } from "react"; diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 17dffdfd9d..85d2701b11 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 - /> - {renderImageExportDialog()} {renderJSONExportDialog()} - {appState.pasteDialog.shown && ( + {appState.openDialog?.name === "charts" && ( setAppState({ - pasteDialog: { shown: false, data: null }, + openDialog: null, }) } /> diff --git a/packages/excalidraw/components/LibraryMenu.scss b/packages/excalidraw/components/LibraryMenu.scss index 6133bb2ecf..b0892a07f7 100644 --- a/packages/excalidraw/components/LibraryMenu.scss +++ b/packages/excalidraw/components/LibraryMenu.scss @@ -126,9 +126,10 @@ .dropdown-menu-container { width: 196px; - box-shadow: var(--library-dropdown-shadow); border-radius: var(--border-radius-lg); padding: 0.25rem 0.5rem; + + --box-shadow: var(--library-dropdown-shadow); } } diff --git a/packages/excalidraw/components/MobileToolBar.tsx b/packages/excalidraw/components/MobileToolBar.tsx index 9cd351d2ee..e439061217 100644 --- a/packages/excalidraw/components/MobileToolBar.tsx +++ b/packages/excalidraw/components/MobileToolBar.tsx @@ -375,7 +375,7 @@ export const MobileToolBar = ({ )} {/* Other Shapes */} - + setIsOtherShapesMenuOpen(false)} onSelect={() => setIsOtherShapesMenuOpen(false)} className="App-toolbar__extra-tools-dropdown" + align="start" > {!showTextToolOutside && ( void; type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void; +const getChartTypeLabel = (chartType: ChartType) => { + switch (chartType) { + case "bar": + return t("labels.chartType_bar"); + case "line": + return t("labels.chartType_line"); + case "radar": + return t("labels.chartType_radar"); + default: + return chartType; + } +}; + const ChartPreviewBtn = (props: { spreadsheet: Spreadsheet | null; chartType: ChartType; - selected: boolean; + colorSeed: number; onClick: OnInsertChart; }) => { const previewRef = useRef(null); const [chartElements, setChartElements] = useState( null, ); + const { theme } = useUIAppState(); useLayoutEffect(() => { if (!props.spreadsheet) { + setChartElements(null); return; } @@ -38,7 +60,13 @@ const ChartPreviewBtn = (props: { props.spreadsheet, 0, 0, + props.colorSeed, ); + if (!elements) { + setChartElements(null); + previewRef.current?.replaceChildren(); + return; + } setChartElements(elements); let svg: SVGSVGElement; const previewNode = previewRef.current!; @@ -49,6 +77,7 @@ const ChartPreviewBtn = (props: { { exportBackground: false, viewBackgroundColor: "#fff", + exportWithDarkMode: theme === "dark", }, null, // files { @@ -58,42 +87,108 @@ const ChartPreviewBtn = (props: { svg.querySelector(".style-fonts")?.remove(); previewNode.replaceChildren(); previewNode.appendChild(svg); - - if (props.selected) { - (previewNode.parentNode as HTMLDivElement).focus(); - } })(); return () => { previewNode.replaceChildren(); }; - }, [props.spreadsheet, props.chartType, props.selected]); + }, [props.spreadsheet, props.chartType, props.colorSeed, theme]); + + const chartTypeLabel = getChartTypeLabel(props.chartType); return ( + ); +}; + +const PlainTextPreviewBtn = (props: { + rawText: string; + onClick: OnPlainTextPaste; +}) => { + const previewRef = useRef(null); + const { theme } = useUIAppState(); + + useLayoutEffect(() => { + if (!props.rawText) { + return; + } + + const textElement = newTextElement({ + text: props.rawText, + x: 0, + y: 0, + }); + + const previewNode = previewRef.current!; + + (async () => { + const svg = await exportToSvg( + [textElement], + { + exportBackground: false, + viewBackgroundColor: "#fff", + exportWithDarkMode: theme === "dark", + }, + null, + { + skipInliningFonts: true, + }, + ); + svg.querySelector(".style-fonts")?.remove(); + previewNode.replaceChildren(); + previewNode.appendChild(svg); + })(); + + return () => { + previewNode.replaceChildren(); + }; + }, [props.rawText, theme]); + + return ( + ); }; export const PasteChartDialog = ({ - setAppState, - appState, + data, + rawText, onClose, }: { - appState: UIAppState; + data: Spreadsheet; + rawText: string; onClose: () => void; - setAppState: React.Component["setState"]; }) => { - const { onInsertElements } = useApp(); + const { onInsertElements, focusContainer } = useApp(); + const [colorSeed, setColorSeed] = useState(Math.random()); + + const handleReshuffleColors = React.useCallback(() => { + setColorSeed(Math.random()); + }, []); + const handleClose = React.useCallback(() => { if (onClose) { onClose(); @@ -103,36 +198,72 @@ export const PasteChartDialog = ({ const handleChartClick = (chartType: ChartType, elements: ChartElements) => { onInsertElements(elements); trackEvent("paste", "chart", chartType); - setAppState({ - currentChartType: chartType, - pasteDialog: { - shown: false, - data: null, - }, + onClose(); + focusContainer(); + }; + + const handlePlainTextClick = (rawText: string) => { + const textElement = newTextElement({ + text: rawText, + x: 0, + y: 0, }); + onInsertElements([textElement]); + trackEvent("paste", "chart", "plaintext"); + onClose(); + focusContainer(); }; return ( +
+ {t("labels.pasteCharts")} +
+
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleReshuffleColors(); + } + }} + > + {bucketFillIcon} +
+
+ } className={"PasteChartDialog"} autofocus={false} >
- - + {(["bar", "line", "radar"] as const).map((chartType) => { + if (!isSpreadsheetValidForChartType(data, chartType)) { + return null; + } + + return ( + + ); + })} + {rawText && ( + + )}
); diff --git a/packages/excalidraw/components/PropertiesPopover.tsx b/packages/excalidraw/components/PropertiesPopover.tsx index 151d8eff16..ee8d0231c1 100644 --- a/packages/excalidraw/components/PropertiesPopover.tsx +++ b/packages/excalidraw/components/PropertiesPopover.tsx @@ -1,4 +1,4 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import clsx from "clsx"; import React, { type ReactNode } from "react"; diff --git a/packages/excalidraw/components/Sidebar/SidebarTab.tsx b/packages/excalidraw/components/Sidebar/SidebarTab.tsx index 0525662f74..047f0c8a4c 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTab.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTab.tsx @@ -1,4 +1,4 @@ -import * as RadixTabs from "@radix-ui/react-tabs"; +import { Tabs as RadixTabs } from "radix-ui"; import type { SidebarTabName } from "../../types"; diff --git a/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx b/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx index 9f2c09bcc5..e916babebe 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx @@ -1,4 +1,4 @@ -import * as RadixTabs from "@radix-ui/react-tabs"; +import { Tabs as RadixTabs } from "radix-ui"; import type { SidebarTabName } from "../../types"; diff --git a/packages/excalidraw/components/Sidebar/SidebarTabTriggers.tsx b/packages/excalidraw/components/Sidebar/SidebarTabTriggers.tsx index 0be187b765..91eca1d9b5 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTabTriggers.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTabTriggers.tsx @@ -1,4 +1,4 @@ -import * as RadixTabs from "@radix-ui/react-tabs"; +import { Tabs as RadixTabs } from "radix-ui"; export const SidebarTabTriggers = ({ children, diff --git a/packages/excalidraw/components/Sidebar/SidebarTabs.tsx b/packages/excalidraw/components/Sidebar/SidebarTabs.tsx index 448840c4a1..de77a1b6ab 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTabs.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTabs.tsx @@ -1,4 +1,4 @@ -import * as RadixTabs from "@radix-ui/react-tabs"; +import { Tabs as RadixTabs } from "radix-ui"; import { useUIAppState } from "../../context/ui-appState"; import { useExcalidrawSetAppState } from "../App"; diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index 9ea376580b..a43a5fb600 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -135,7 +135,6 @@ describe("binding with linear elements", () => { ) as HTMLInputElement; expect(linear.startBinding).not.toBe(null); expect(inputX).not.toBeNull(); - UI.updateInput(inputX, String("184")); expect(linear.startBinding).not.toBe(null); }); diff --git a/packages/excalidraw/components/TTDDialog/Chat/Chat.scss b/packages/excalidraw/components/TTDDialog/Chat/Chat.scss index 63671ed66e..d205d19a5a 100644 --- a/packages/excalidraw/components/TTDDialog/Chat/Chat.scss +++ b/packages/excalidraw/components/TTDDialog/Chat/Chat.scss @@ -1,4 +1,4 @@ -@import "../../../css/variables.module.scss"; +@use "../../../css/variables.module.scss" as *; $verticalBreakpoint: 861px; diff --git a/packages/excalidraw/components/TTDDialog/Chat/ChatHistoryMenu.tsx b/packages/excalidraw/components/TTDDialog/Chat/ChatHistoryMenu.tsx index ef20374c7c..87c1132500 100644 --- a/packages/excalidraw/components/TTDDialog/Chat/ChatHistoryMenu.tsx +++ b/packages/excalidraw/components/TTDDialog/Chat/ChatHistoryMenu.tsx @@ -52,11 +52,7 @@ export const ChatHistoryMenu = ({ > {historyIcon} - + <> {savedChats.map((chat) => ( { 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, @@ -246,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, @@ -262,6 +266,7 @@ const getRelevantAppStateProps = ( frameRendering: appState.frameRendering, shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, exportScale: appState.exportScale, + currentItemArrowType: appState.currentItemArrowType, }); const areEqual = ( diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss index 5d0be65461..e207203d60 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss @@ -2,24 +2,35 @@ .excalidraw { .dropdown-menu { - position: absolute; - top: 2.5rem; - margin-top: 0.5rem; max-width: 16rem; + z-index: 1; &--placement-top { - top: auto; - bottom: 100%; - margin-top: 0; margin-bottom: 0.5rem; } + &__submenu-trigger { + &[aria-expanded="true"] { + .dropdown-menu-item { + background-color: var(--button-hover-bg); + } + } + } + + &__submenu-trigger-icon { + margin-left: auto; + opacity: 0.5; + svg g { + stroke-width: 2; + } + } + &--mobile { width: 100%; row-gap: 0.75rem; // When main menu is in the top toolbar, position relative to trigger - &.main-menu-dropdown { + &.main-menu { min-width: 232px; margin-top: 0; margin-bottom: 0; @@ -32,10 +43,6 @@ .dropdown-menu-container { padding: 8px 8px; box-sizing: border-box; - max-height: calc( - 100svh - var(--editor-container-padding) * 2 - 2.25rem - ); - box-shadow: var(--shadow-island); border-radius: var(--border-radius-lg); position: relative; transition: box-shadow 0.5s ease-in-out; @@ -51,14 +58,25 @@ .dropdown-menu-container { background-color: var(--island-bg-color); - overflow-y: auto; - --gap: 2; + display: flex; + flex-direction: column; + gap: 1px; + + box-shadow: var(--box-shadow, var(--shadow-island)); + + max-height: calc(100svh - var(--editor-container-padding) * 2 - 2.25rem); + + @at-root .excalidraw.theme--dark#{&} { + box-shadow: var(--box-shadow, var(--shadow-island)), + 0 0 0 1px rgba(0, 0, 0, 0.15); + } } .dropdown-menu-item-base { display: flex; column-gap: 0.625rem; + padding: 0 0.5rem; font-size: 0.875rem; color: var(--color-on-surface); width: 100%; @@ -115,11 +133,9 @@ .dropdown-menu-item { height: 2rem; - margin: 1px; padding: 0 0.5rem; - width: calc(100% - 2px); background-color: transparent; - border: 1px solid transparent; + border: none; align-items: center; cursor: pointer; border-radius: var(--border-radius-md); @@ -162,7 +178,22 @@ &:active { background-color: var(--button-hover-bg); - border-color: var(--color-brand-active); + box-shadow: 0 0 0 1px var(--color-brand-active); + } + + &[disabled] { + cursor: not-allowed; + opacity: 0.5; + pointer-events: none; + + &:hover { + background-color: transparent; + } + + &:active { + background-color: transparent; + box-shadow: none; + } } svg { @@ -223,7 +254,7 @@ } &:active { - border-color: var(--color-primary); + box-shadow: 0 0 0 1px var(--color-primary); } &[disabled] { @@ -235,7 +266,7 @@ } &:active { - border-color: transparent; + box-shadow: none; } @at-root .excalidraw.theme--dark#{&} { diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx index 6f358311a2..5b0ef39c3e 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx @@ -1,12 +1,18 @@ import React from "react"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + +import { CLASSES } from "@excalidraw/common"; + import DropdownMenuContent from "./DropdownMenuContent"; import DropdownMenuGroup from "./DropdownMenuGroup"; import DropdownMenuItem from "./DropdownMenuItem"; import DropdownMenuItemCustom from "./DropdownMenuItemCustom"; import DropdownMenuItemLink from "./DropdownMenuItemLink"; import MenuSeparator from "./DropdownMenuSeparator"; +import DropdownMenuSub from "./DropdownMenuSub"; import DropdownMenuTrigger from "./DropdownMenuTrigger"; +import DropdownMenuItemCheckbox from "./DropdownMenuItemCheckbox"; import { getMenuContentComponent, getMenuTriggerComponent, @@ -17,44 +23,47 @@ import "./DropdownMenu.scss"; const DropdownMenu = ({ children, open, - placement, }: { children?: React.ReactNode; open: boolean; - placement?: "top" | "bottom"; }) => { const MenuTriggerComp = getMenuTriggerComponent(children); const MenuContentComp = getMenuContentComponent(children); - - // clone the MenuContentComp to pass the placement prop - const MenuContentCompWithPlacement = + const MenuContentWithState = MenuContentComp && React.isValidElement(MenuContentComp) - ? React.cloneElement(MenuContentComp as React.ReactElement, { - placement, - }) + ? React.cloneElement( + MenuContentComp as React.ReactElement< + React.ComponentProps + >, + { open }, + ) : MenuContentComp; return ( -
- {MenuTriggerComp} - {open && MenuContentCompWithPlacement} -
+ +
+ {MenuTriggerComp} + {MenuContentWithState} +
+
); }; DropdownMenu.Trigger = DropdownMenuTrigger; DropdownMenu.Content = DropdownMenuContent; DropdownMenu.Item = DropdownMenuItem; +DropdownMenu.ItemCheckbox = DropdownMenuItemCheckbox; DropdownMenu.ItemLink = DropdownMenuItemLink; DropdownMenu.ItemCustom = DropdownMenuItemCustom; DropdownMenu.Group = DropdownMenuGroup; DropdownMenu.Separator = MenuSeparator; +DropdownMenu.Sub = DropdownMenuSub; export default DropdownMenu; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx index f92c9df327..79be24b969 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx @@ -1,7 +1,9 @@ import clsx from "clsx"; -import React, { useEffect, useRef } from "react"; +import React, { useCallback, useEffect, useRef } from "react"; -import { EVENT, KEYS } from "@excalidraw/common"; +import { CLASSES, EVENT, KEYS } from "@excalidraw/common"; + +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import { useOutsideClick } from "../../hooks/useOutsideClick"; import { useStable } from "../../hooks/useStable"; @@ -16,8 +18,9 @@ const MenuContent = ({ onClickOutside, className = "", onSelect, + open = true, + align = "end", style, - placement = "bottom", }: { children?: React.ReactNode; onClickOutside?: () => void; @@ -26,26 +29,36 @@ const MenuContent = ({ * Called when any menu item is selected (clicked on). */ onSelect?: (event: Event) => void; + open?: boolean; style?: React.CSSProperties; - placement?: "top" | "bottom"; + align?: "start" | "center" | "end"; }) => { const editorInterface = useEditorInterface(); const menuRef = useRef(null); const callbacksRef = useStable({ onClickOutside }); - useOutsideClick(menuRef, (event) => { - // prevents closing if clicking on the trigger button - if ( - !menuRef.current - ?.closest(".dropdown-menu-container") - ?.contains(event.target) - ) { - callbacksRef.onClickOutside?.(); - } - }); + useOutsideClick( + menuRef, + useCallback( + (event) => { + // prevents closing if clicking on the trigger button + if ( + !menuRef.current + ?.closest(`.${CLASSES.DROPDOWN_MENU_EVENT_WRAPPER}`) + ?.contains(event.target) + ) { + callbacksRef.onClickOutside?.(); + } + }, + [callbacksRef], + ), + ); useEffect(() => { + if (!open) { + return; + } const onKeyDown = (event: KeyboardEvent) => { if (event.key === KEYS.ESCAPE) { event.stopImmediatePropagation(); @@ -63,35 +76,33 @@ const MenuContent = ({ return () => { document.removeEventListener(EVENT.KEYDOWN, onKeyDown, option); }; - }, [callbacksRef]); + }, [callbacksRef, open]); const classNames = clsx(`dropdown-menu ${className}`, { "dropdown-menu--mobile": editorInterface.formFactor === "phone", - "dropdown-menu--placement-top": placement === "top", }).trim(); return ( -
event.preventDefault()} > {/* the zIndex ensures this menu has higher stacking order, see https://github.com/excalidraw/excalidraw/pull/1445 */} {editorInterface.formFactor === "phone" ? ( {children} ) : ( - + {children} )} -
+
); }; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx index 0f227a0bbd..55ffcd5844 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx @@ -1,78 +1,62 @@ -import React, { useEffect, useRef } from "react"; +import React from "react"; import { THEME } from "@excalidraw/common"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + import type { ValueOf } from "@excalidraw/common/utility-types"; import { useExcalidrawAppState } from "../App"; -import MenuItemContent from "./DropdownMenuItemContent"; import { getDropdownMenuItemClassName, - useHandleDropdownMenuItemClick, + useHandleDropdownMenuItemSelect, } from "./common"; +import MenuItemContent from "./DropdownMenuItemContent"; import type { JSX } from "react"; -const DropdownMenuItem = ({ - icon, - value, - order, - children, - shortcut, - className, - hovered, - selected, - textStyle, - onSelect, - onClick, - badge, - ...rest -}: { +export type DropdownMenuItemProps = { icon?: JSX.Element; + badge?: React.ReactNode; value?: string | number | undefined; - order?: number; onSelect?: (event: Event) => void; children: React.ReactNode; shortcut?: string; - hovered?: boolean; selected?: boolean; - textStyle?: React.CSSProperties; className?: string; - badge?: React.ReactNode; -} & Omit, "onSelect">) => { - const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect); - const ref = useRef(null); +} & Omit, "onSelect">; - useEffect(() => { - if (hovered) { - if (order === 0) { - // scroll into the first item differently, so it's visible what is above (i.e. group title) - ref.current?.scrollIntoView({ block: "end" }); - } else { - ref.current?.scrollIntoView({ block: "nearest" }); - } - } - }, [hovered, order]); +const DropdownMenuItem = ({ + icon, + badge, + value, + children, + shortcut, + className, + selected, + onSelect, + ...rest +}: DropdownMenuItemProps) => { + const handleSelect = useHandleDropdownMenuItemSelect(onSelect); return ( - + + {children} + + + ); }; DropdownMenuItem.displayName = "DropdownMenuItem"; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemCheckbox.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemCheckbox.tsx new file mode 100644 index 0000000000..c116e5bde7 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemCheckbox.tsx @@ -0,0 +1,15 @@ +import { checkIcon, emptyIcon } from "../icons"; + +import DropdownMenuItem from "./DropdownMenuItem"; + +import type { DropdownMenuItemProps } from "./DropdownMenuItem"; + +const DropdownMenuItemCheckbox = ( + props: Omit & { checked: boolean }, +) => { + return ( + + ); +}; + +export default DropdownMenuItemCheckbox; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx index d8177c50e0..4f2986c30e 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx @@ -27,9 +27,7 @@ const DropdownMenuItemContentRadio = ({ return ( <>
- + void; rel?: string; } & React.AnchorHTMLAttributes) => { - const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect); + const handleSelect = useHandleDropdownMenuItemSelect(onSelect); return ( // eslint-disable-next-line react/jsx-no-target-blank - - - {children} - - + + + {children} + + + ); }; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx index 1c6d19521d..4f880c691a 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx @@ -5,7 +5,7 @@ const MenuSeparator = () => ( style={{ height: "1px", backgroundColor: "var(--default-border-color)", - margin: ".5rem 0", + margin: "6px 0", flex: "0 0 auto", }} /> diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx new file mode 100644 index 0000000000..0ba328b189 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx @@ -0,0 +1,26 @@ +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + +import DropdownMenuSubContent from "./DropdownMenuSubContent"; +import DropdownMenuSubTrigger from "./DropdownMenuSubTrigger"; +import { + getSubMenuContentComponent, + getSubMenuTriggerComponent, +} from "./dropdownMenuUtils"; + +const DropdownMenuSub = ({ children }: { children?: React.ReactNode }) => { + const MenuTriggerComp = getSubMenuTriggerComponent(children); + const MenuContentComp = getSubMenuContentComponent(children); + return ( + + {MenuTriggerComp} + {MenuContentComp} + + ); +}; + +DropdownMenuSub.Trigger = DropdownMenuSubTrigger; +DropdownMenuSub.Content = DropdownMenuSubContent; + +DropdownMenuSub.displayName = "DropdownMenuSub"; + +export default DropdownMenuSub; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx new file mode 100644 index 0000000000..ef5debef57 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx @@ -0,0 +1,71 @@ +import clsx from "clsx"; + +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + +import { useCallback, useState } from "react"; + +import { useEditorInterface } from "../App"; +import { Island } from "../Island"; +import Stack from "../Stack"; + +const BASE_ALIGN_OFFSET = -4; +const BASE_SIDE_OFFSET = 4; + +const DropdownMenuSubContent = ({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) => { + const editorInterface = useEditorInterface(); + + const classNames = clsx(`dropdown-menu dropdown-submenu ${className}`, { + "dropdown-menu--mobile": editorInterface.formFactor === "phone", + }).trim(); + + const callbacksRef = useCallback((node: HTMLDivElement | null) => { + if (node) { + const parentContainer = node.closest(".dropdown-menu-container"); + const parentRect = parentContainer?.getBoundingClientRect(); + if (parentRect) { + const menuWidth = node.getBoundingClientRect().width; + + const viewportWidth = window.innerWidth; + const spaceRemaining = viewportWidth - parentRect.right; + if (spaceRemaining < menuWidth + 20) { + setSideOffset(spaceRemaining - menuWidth + BASE_ALIGN_OFFSET); + setAlignOffset(BASE_ALIGN_OFFSET + 8); + } + } + } + }, []); + + const [sideOffset, setSideOffset] = useState(BASE_SIDE_OFFSET); + const [alignOffset, setAlignOffset] = useState(BASE_ALIGN_OFFSET); + + return ( + + {editorInterface.formFactor === "phone" ? ( + {children} + ) : ( + + {children} + + )} + + ); +}; + +export default DropdownMenuSubContent; +DropdownMenuSubContent.displayName = "DropdownMenuSubContent"; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx new file mode 100644 index 0000000000..579d4979a7 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + +import { chevronRight } from "../icons"; + +import { getDropdownMenuItemClassName } from "./common"; +import MenuItemContent from "./DropdownMenuItemContent"; + +import type { JSX } from "react"; + +const DropdownMenuSubTrigger = ({ + children, + icon, + shortcut, + className, +}: { + children: React.ReactNode; + icon?: JSX.Element; + shortcut?: string; + className?: string; +}) => { + return ( + + + {children} + +
{chevronRight}
+
+ ); +}; + +export default DropdownMenuSubTrigger; +DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger"; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx index e1f3ef202f..469eb002b8 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx @@ -1,5 +1,7 @@ import clsx from "clsx"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + import { useEditorInterface } from "../App"; const MenuTrigger = ({ @@ -23,7 +25,7 @@ const MenuTrigger = ({ }, ).trim(); return ( - + ); }; diff --git a/packages/excalidraw/components/dropdownMenu/common.ts b/packages/excalidraw/components/dropdownMenu/common.ts index feca5c4060..27342a28f6 100644 --- a/packages/excalidraw/components/dropdownMenu/common.ts +++ b/packages/excalidraw/components/dropdownMenu/common.ts @@ -1,6 +1,6 @@ import React, { useContext } from "react"; -import { EVENT, composeEventHandlers } from "@excalidraw/common"; +import { composeEventHandlers } from "@excalidraw/common"; export const DropdownMenuContentPropsContext = React.createContext<{ onSelect?: (event: Event) => void; @@ -11,28 +11,17 @@ export const getDropdownMenuItemClassName = ( selected = false, hovered = false, ) => { - return `dropdown-menu-item dropdown-menu-item-base ${className} - ${selected ? "dropdown-menu-item--selected" : ""} ${ - hovered ? "dropdown-menu-item--hovered" : "" - }`.trim(); + return `dropdown-menu-item dropdown-menu-item-base ${className} ${ + selected ? "dropdown-menu-item--selected" : "" + } ${hovered ? "dropdown-menu-item--hovered" : ""}`.trim(); }; -export const useHandleDropdownMenuItemClick = ( - origOnClick: - | React.MouseEventHandler - | undefined, +export const useHandleDropdownMenuItemSelect = ( onSelect: ((event: Event) => void) | undefined, ) => { const DropdownMenuContentProps = useContext(DropdownMenuContentPropsContext); - return composeEventHandlers(origOnClick, (event) => { - const itemSelectEvent = new CustomEvent(EVENT.MENU_ITEM_SELECT, { - bubbles: true, - cancelable: true, - }); - onSelect?.(itemSelectEvent); - if (!itemSelectEvent.defaultPrevented) { - DropdownMenuContentProps.onSelect?.(itemSelectEvent); - } + return composeEventHandlers(onSelect, (event) => { + DropdownMenuContentProps.onSelect?.(event); }); }; diff --git a/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts b/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts index 10d91fb856..82e8ccf591 100644 --- a/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts +++ b/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts @@ -1,6 +1,6 @@ import React from "react"; -export const getMenuTriggerComponent = (children: React.ReactNode) => { +const getMenuComponent = (component: string) => (children: React.ReactNode) => { const comp = React.Children.toArray(children).find( (child) => React.isValidElement(child) && @@ -8,7 +8,7 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => { //@ts-ignore child?.type.displayName && //@ts-ignore - child.type.displayName === "DropdownMenuTrigger", + child.type.displayName === component, ); if (!comp) { return null; @@ -17,19 +17,11 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => { return comp; }; -export const getMenuContentComponent = (children: React.ReactNode) => { - const comp = React.Children.toArray(children).find( - (child) => - React.isValidElement(child) && - typeof child.type !== "string" && - //@ts-ignore - child?.type.displayName && - //@ts-ignore - child.type.displayName === "DropdownMenuContent", - ); - if (!comp) { - return null; - } - //@ts-ignore - return comp; -}; +export const getMenuTriggerComponent = getMenuComponent("DropdownMenuTrigger"); +export const getMenuContentComponent = getMenuComponent("DropdownMenuContent"); +export const getSubMenuTriggerComponent = getMenuComponent( + "DropdownMenuSubTrigger", +); +export const getSubMenuContentComponent = getMenuComponent( + "DropdownMenuSubContent", +); diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 8caf6cc163..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( @@ -2396,3 +2404,32 @@ export const presentationIcon = createIcon( , tablerIconProps, ); + +// empty placeholder icon (used for alignment in menus) +export const emptyIcon =
; + +//tabler-icons: chevron-right +export const chevronRight = createIcon( + + + + , + tablerIconProps, +); + +// tabler-icons: adjustments-horizontal +export const settingsIcon = createIcon( + + + + + + + + + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/components/main-menu/DefaultItems.tsx b/packages/excalidraw/components/main-menu/DefaultItems.tsx index 29a2761a10..9793b62e28 100644 --- a/packages/excalidraw/components/main-menu/DefaultItems.tsx +++ b/packages/excalidraw/components/main-menu/DefaultItems.tsx @@ -9,9 +9,16 @@ import { actionLoadScene, actionSaveToActiveFile, actionShortcuts, + actionToggleArrowBinding, + actionToggleGridMode, + actionToggleMidpointSnapping, + actionToggleObjectsSnapMode, actionToggleSearchMenu, + actionToggleStats, actionToggleTheme, + actionToggleZenMode, } from "../../actions"; +import { actionToggleViewMode } from "../../actions/actionToggleViewMode"; import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { trackEvent } from "../../analytics"; import { useUIAppState } from "../../context/ui-appState"; @@ -23,13 +30,16 @@ import { useExcalidrawActionManager, useExcalidrawElements, useAppProps, + useApp, } from "../App"; import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState"; import Trans from "../Trans"; import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem"; +import DropdownMenuItemCheckbox from "../dropdownMenu/DropdownMenuItemCheckbox"; import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio"; import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink"; -import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons"; +import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub"; +import { GithubIcon, DiscordIcon, XBrandIcon, settingsIcon } from "../icons"; import { boltIcon, DeviceDesktopIcon, @@ -306,10 +316,14 @@ export const ChangeCanvasBackground = () => { return null; } return ( -
+
{t("labels.canvasBackground")}
@@ -393,3 +407,191 @@ export const LiveCollaborationTrigger = ({ }; LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger"; + +const PreferencesToggleToolLockItem = () => { + const { t } = useI18n(); + const app = useApp(); + const appState = useUIAppState(); + + return ( + { + app.toggleLock(); + event.preventDefault(); + }} + > + {t("labels.preferences_toolLock")} + + ); +}; + +const PreferencesToggleSnapModeItem = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + const appState = useUIAppState(); + return ( + { + actionManager.executeAction(actionToggleObjectsSnapMode); + event.preventDefault(); + }} + > + {t("buttons.objectsSnapMode")} + + ); +}; + +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(); + const appState = useUIAppState(); + + return ( + { + actionManager.executeAction(actionToggleGridMode); + event.preventDefault(); + }} + > + {t("labels.toggleGrid")} + + ); +}; + +export const PreferencesToggleZenModeItem = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + const appState = useUIAppState(); + return ( + { + actionManager.executeAction(actionToggleZenMode); + event.preventDefault(); + }} + > + {t("buttons.zenMode")} + + ); +}; + +const PreferencesToggleViewModeItem = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + const appState = useUIAppState(); + return ( + { + actionManager.executeAction(actionToggleViewMode); + event.preventDefault(); + }} + > + {t("labels.viewMode")} + + ); +}; + +const PreferencesToggleElementPropertiesItem = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + const appState = useUIAppState(); + return ( + { + actionManager.executeAction(actionToggleStats); + event.preventDefault(); + }} + > + {t("stats.fullTitle")} + + ); +}; + +export const Preferences = ({ + children, + additionalItems, +}: { + children?: React.ReactNode; + additionalItems?: React.ReactNode; +}) => { + const { t } = useI18n(); + return ( + + + {t("labels.preferences")} + + + {children || ( + <> + + + + + + + + + + )} + {additionalItems} + + + ); +}; + +Preferences.ToggleToolLock = PreferencesToggleToolLockItem; +Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem; +Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem; +Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem; +Preferences.ToggleGridMode = PreferencesToggleGridModeItem; +Preferences.ToggleZenMode = PreferencesToggleZenModeItem; +Preferences.ToggleViewMode = PreferencesToggleViewModeItem; +Preferences.ToggleElementProperties = PreferencesToggleElementPropertiesItem; + +Preferences.displayName = "Preferences"; diff --git a/packages/excalidraw/components/main-menu/MainMenu.tsx b/packages/excalidraw/components/main-menu/MainMenu.tsx index d028231328..3755f1f0d4 100644 --- a/packages/excalidraw/components/main-menu/MainMenu.tsx +++ b/packages/excalidraw/components/main-menu/MainMenu.tsx @@ -8,6 +8,7 @@ import { t } from "../../i18n"; import { useEditorInterface, useExcalidrawSetAppState } from "../App"; import { UserList } from "../UserList"; import DropdownMenu from "../dropdownMenu/DropdownMenu"; +import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub"; import { withInternalFallback } from "../hoc/withInternalFallback"; import { HamburgerMenuIcon } from "../icons"; @@ -52,12 +53,8 @@ const MainMenu = Object.assign( onSelect={composeEventHandlers(onSelect, () => { setAppState({ openMenu: null }); })} - placement="bottom" - className={ - editorInterface.formFactor === "phone" - ? "main-menu-dropdown" - : "" - } + className="main-menu" + align="start" > {children} {editorInterface.formFactor === "phone" && @@ -84,6 +81,7 @@ const MainMenu = Object.assign( ItemCustom: DropdownMenu.ItemCustom, Group: DropdownMenu.Group, Separator: DropdownMenu.Separator, + Sub: DropdownMenuSub, DefaultItems, }, ); 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/css/styles.scss b/packages/excalidraw/css/styles.scss index 5557d50313..e0d42fa716 100644 --- a/packages/excalidraw/css/styles.scss +++ b/packages/excalidraw/css/styles.scss @@ -16,6 +16,7 @@ --zIndex-ui-context-menu: 90; --zIndex-ui-styles-popup: 100; --zIndex-ui-top: 100; + --zIndex-ui-main-menu: 110; --zIndex-ui-library: 120; --zIndex-modal: 1000; @@ -223,6 +224,18 @@ body.excalidraw-cursor-resize * { box-shadow: 0 0 0 1px var(--color-brand-hover); } + // radix doesn't allow differntiating between hover and keyboard active + // states (it's forcing :focus on both). + // + // proper handling would be to disable :focus-visible by default, and enable + // on keyboard arrows (it'd then have to be disabled again, e.g. on keydown + // or container focus) + // + // alas, that is left for another day + [data-radix-collection-item]:focus-visible { + box-shadow: none !important; + } + .buttonList { .ToolIcon__icon { all: unset !important; @@ -670,6 +683,9 @@ body.excalidraw-cursor-resize * { } } + .main-menu { + z-index: var(--zIndex-ui-main-menu); + } .main-menu-trigger { @include filledButtonOnCanvas; } diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index b78fa07f84..b77f010bd8 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -82,7 +82,12 @@ import { getNormalizedZoom, } from "../scene"; -import type { AppState, BinaryFiles, LibraryItem } from "../types"; +import type { + AppState, + BinaryFiles, + LibraryItem, + NormalizedZoomValue, +} from "../types"; import type { ImportedDataState, LegacyAppState } from "./types"; type RestoredAppState = Omit< @@ -150,7 +155,7 @@ const repairBinding = ( | ExcalidrawElbowArrowElement["startBinding"] | ExcalidrawElbowArrowElement["endBinding"] = { ...binding, - fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), + fixedPoint: normalizeFixedPoint(binding.fixedPoint), mode: binding.mode || "orbit", }; @@ -171,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; @@ -180,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; @@ -203,18 +207,36 @@ 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, elementsMap, + { value: 1 as NormalizedZoomValue }, ) || p; const { fixedPoint } = calculateFixedPointForNonElbowArrowBinding( - element, + safeElement, boundElement, startOrEnd, elementsMap, 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 765ff06cdd..7a2a7294f8 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", @@ -171,7 +175,11 @@ "linkToElement": "Link to object", "wrapSelectionInFrame": "Wrap selection in frame", "tab": "Tab", - "shapeSwitch": "Switch shape" + "shapeSwitch": "Switch shape", + "preferences": "Preferences", + "preferences_toolLock": "Tool lock", + "arrowBinding": "Arrow binding", + "midpointSnapping": "Snap to midpoints" }, "elementLink": { "title": "Link to object", diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index c7d8693d86..2f12bf6041 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -85,8 +85,7 @@ "@excalidraw/math": "0.18.0", "@excalidraw/mermaid-to-excalidraw": "2.0.0-rfc3", "@excalidraw/random-username": "1.1.0", - "@radix-ui/react-popover": "1.1.6", - "@radix-ui/react-tabs": "1.1.3", + "radix-ui": "1.4.3", "browser-fs-access": "0.29.1", "canvas-roundrect-polyfill": "0.0.1", "clsx": "1.1.1", diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 0c55dfaac4..56c308713e 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -5,6 +5,9 @@ import { type GlobalPoint, type LocalPoint, type Radians, + bezierEquation, + pointRotateRads, + pointDistance, } from "@excalidraw/math"; import { @@ -21,11 +24,13 @@ import { deconstructDiamondElement, deconstructRectanguloidElement, elementCenterPoint, + getDiamondBaseCorners, FOCUS_POINT_SIZE, getOmitSidesForEditorInterface, getTransformHandles, getTransformHandlesFromCoords, hasBoundingBox, + hitElementItself, isArrowElement, isBindableElement, isElbowArrow, @@ -33,6 +38,7 @@ import { isImageElement, isLinearElement, isLineElement, + maxBindingDistance_simple, isTextElement, LinearElementEditor, } from "@excalidraw/element"; @@ -88,8 +94,11 @@ import { strokeRectWithRotation_simple, } from "./helpers"; -import type { AppClassProperties, InteractiveCanvasAppState } from "../types"; - +import type { + AppState, + AppClassProperties, + InteractiveCanvasAppState, +} from "../types"; import type { InteractiveCanvasRenderConfig, InteractiveSceneRenderConfig, @@ -209,11 +218,14 @@ const renderSingleLinearPoint = ( const renderBindingHighlightForBindableElement_simple = ( context: CanvasRenderingContext2D, - element: ExcalidrawBindableElement, + suggestedBinding: NonNullable, elementsMap: ElementsMap, appState: InteractiveCanvasAppState, + pointerCoords: GlobalPoint | null, ) => { - const enclosingFrame = element.frameId && elementsMap.get(element.frameId); + const enclosingFrame = + suggestedBinding.element.frameId && + elementsMap.get(suggestedBinding.element.frameId); if (enclosingFrame && isFrameLikeElement(enclosingFrame)) { context.translate(enclosingFrame.x, enclosingFrame.y); @@ -236,12 +248,12 @@ const renderBindingHighlightForBindableElement_simple = ( context.translate(-enclosingFrame.x, -enclosingFrame.y); } - switch (element.type) { + switch (suggestedBinding.element.type) { case "magicframe": case "frame": context.save(); - context.translate(element.x, element.y); + context.translate(suggestedBinding.element.x, suggestedBinding.element.y); context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.strokeStyle = @@ -254,14 +266,19 @@ const renderBindingHighlightForBindableElement_simple = ( context.roundRect( 0, 0, - element.width, - element.height, + suggestedBinding.element.width, + suggestedBinding.element.height, FRAME_STYLE.radius / appState.zoom.value, ); context.stroke(); context.closePath(); } else { - context.strokeRect(0, 0, element.width, element.height); + context.strokeRect( + 0, + 0, + suggestedBinding.element.width, + suggestedBinding.element.height, + ); } context.restore(); @@ -269,30 +286,30 @@ const renderBindingHighlightForBindableElement_simple = ( default: context.save(); - const center = elementCenterPoint(element, elementsMap); + const center = elementCenterPoint(suggestedBinding.element, elementsMap); context.translate(center[0], center[1]); - context.rotate(element.angle as Radians); + context.rotate(suggestedBinding.element.angle as Radians); context.translate(-center[0], -center[1]); - context.translate(element.x, element.y); + context.translate(suggestedBinding.element.x, suggestedBinding.element.y); context.lineWidth = - clamp(1.75, element.strokeWidth, 4) / + clamp(1.75, suggestedBinding.element.strokeWidth, 4) / Math.max(0.25, appState.zoom.value); context.strokeStyle = appState.theme === THEME.DARK ? `rgba(3, 93, 161, 1)` : `rgba(106, 189, 252, 1)`; - switch (element.type) { + switch (suggestedBinding.element.type) { case "ellipse": context.beginPath(); context.ellipse( - element.width / 2, - element.height / 2, - element.width / 2, - element.height / 2, + suggestedBinding.element.width / 2, + suggestedBinding.element.height / 2, + suggestedBinding.element.width / 2, + suggestedBinding.element.height / 2, 0, 0, 2 * Math.PI, @@ -302,18 +319,20 @@ const renderBindingHighlightForBindableElement_simple = ( break; case "diamond": { - const [segments, curves] = deconstructDiamondElement(element); + const [segments, curves] = deconstructDiamondElement( + suggestedBinding.element, + ); // Draw each line segment individually segments.forEach((segment) => { context.beginPath(); context.moveTo( - segment[0][0] - element.x, - segment[0][1] - element.y, + segment[0][0] - suggestedBinding.element.x, + segment[0][1] - suggestedBinding.element.y, ); context.lineTo( - segment[1][0] - element.x, - segment[1][1] - element.y, + segment[1][0] - suggestedBinding.element.x, + segment[1][1] - suggestedBinding.element.y, ); context.stroke(); }); @@ -322,14 +341,17 @@ const renderBindingHighlightForBindableElement_simple = ( curves.forEach((curve) => { const [start, control1, control2, end] = curve; context.beginPath(); - context.moveTo(start[0] - element.x, start[1] - element.y); + context.moveTo( + start[0] - suggestedBinding.element.x, + start[1] - suggestedBinding.element.y, + ); context.bezierCurveTo( - control1[0] - element.x, - control1[1] - element.y, - control2[0] - element.x, - control2[1] - element.y, - end[0] - element.x, - end[1] - element.y, + control1[0] - suggestedBinding.element.x, + control1[1] - suggestedBinding.element.y, + control2[0] - suggestedBinding.element.x, + control2[1] - suggestedBinding.element.y, + end[0] - suggestedBinding.element.x, + end[1] - suggestedBinding.element.y, ); context.stroke(); }); @@ -338,18 +360,20 @@ const renderBindingHighlightForBindableElement_simple = ( break; default: { - const [segments, curves] = deconstructRectanguloidElement(element); + const [segments, curves] = deconstructRectanguloidElement( + suggestedBinding.element, + ); // Draw each line segment individually segments.forEach((segment) => { context.beginPath(); context.moveTo( - segment[0][0] - element.x, - segment[0][1] - element.y, + segment[0][0] - suggestedBinding.element.x, + segment[0][1] - suggestedBinding.element.y, ); context.lineTo( - segment[1][0] - element.x, - segment[1][1] - element.y, + segment[1][0] - suggestedBinding.element.x, + segment[1][1] - suggestedBinding.element.y, ); context.stroke(); }); @@ -358,14 +382,17 @@ const renderBindingHighlightForBindableElement_simple = ( curves.forEach((curve) => { const [start, control1, control2, end] = curve; context.beginPath(); - context.moveTo(start[0] - element.x, start[1] - element.y); + context.moveTo( + start[0] - suggestedBinding.element.x, + start[1] - suggestedBinding.element.y, + ); context.bezierCurveTo( - control1[0] - element.x, - control1[1] - element.y, - control2[0] - element.x, - control2[1] - element.y, - end[0] - element.x, - end[1] - element.y, + control1[0] - suggestedBinding.element.x, + control1[1] - suggestedBinding.element.y, + control2[0] - suggestedBinding.element.x, + control2[1] - suggestedBinding.element.y, + end[0] - suggestedBinding.element.x, + end[1] - suggestedBinding.element.y, ); context.stroke(); }); @@ -378,6 +405,147 @@ const renderBindingHighlightForBindableElement_simple = ( break; } + + if ( + appState.isMidpointSnappingEnabled && + (isFrameLikeElement(suggestedBinding.element) || + isBindableElement(suggestedBinding.element)) + ) { + // Draw midpoint indicators + const linearElement = appState.selectedLinearElement; + const arrow = + linearElement?.elementId && + LinearElementEditor.getElement(linearElement?.elementId, elementsMap); + const cursorIsInsideBindable = + pointerCoords && + hitElementItself({ + point: pointerCoords, + element: suggestedBinding.element, + elementsMap, + threshold: 0, + overrideShouldTestInside: true, + }); + + const isElbow = + (arrow && isElbowArrow(arrow)) || + (appState.activeTool.type === "arrow" && + appState.currentItemArrowType === "elbow"); + + if (!cursorIsInsideBindable || isElbow) { + context.save(); + + const center = elementCenterPoint(suggestedBinding.element, elementsMap); + + let midpoints: GlobalPoint[]; + if (suggestedBinding.element.type === "diamond") { + const center = elementCenterPoint( + suggestedBinding.element, + elementsMap, + ); + midpoints = getDiamondBaseCorners(suggestedBinding.element).map( + (curve) => { + const point = bezierEquation(curve, 0.5); + const rotatedPoint = pointRotateRads( + point, + center, + suggestedBinding.element.angle, + ); + + return pointFrom(rotatedPoint[0], rotatedPoint[1]); + }, + ); + } else { + const basePoints = [ + { + x: suggestedBinding.element.width, + y: suggestedBinding.element.height / 2, + }, // RIGHT + { + x: suggestedBinding.element.width / 2, + y: suggestedBinding.element.height, + }, // BOTTOM + { x: 0, y: suggestedBinding.element.height / 2 }, // LEFT + { x: suggestedBinding.element.width / 2, y: 0 }, // TOP + ]; + midpoints = basePoints.map((point) => { + const globalPoint = pointFrom( + point.x + suggestedBinding.element.x, + point.y + suggestedBinding.element.y, + ); + const rotatedPoint = pointRotateRads( + globalPoint, + center, + suggestedBinding.element.angle, + ); + return pointFrom(rotatedPoint[0], rotatedPoint[1]); + }); + } + + 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 midpointRadius = 4 / appState.zoom.value; + const highlightThreshold = + maxBindingDistance_simple(appState.zoom) + + suggestedBinding.element.strokeWidth / 2; + + midpoints.forEach((midpoint, idx) => { + const isHighlighted = + (!cursorIsInsideBindable || isElbow) && + hoveredMidpoint?.idx === idx && + hoveredMidpoint.distance <= highlightThreshold; + + // 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)` + : `rgba(106, 189, 252, 1)`; + + 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(); + } + }); + + context.restore(); + } + } }; const renderBindingHighlightForBindableElement_complex = ( @@ -631,6 +799,80 @@ const renderBindingHighlightForBindableElement_complex = ( context.fill(); context.restore(); + + if (appState.isMidpointSnappingEnabled) { + // 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, + ); + }); + + 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 { @@ -641,17 +883,21 @@ const renderBindingHighlightForBindableElement_complex = ( const renderBindingHighlightForBindableElement = ( app: AppClassProperties, context: CanvasRenderingContext2D, - element: ExcalidrawBindableElement, + suggestedBinding: AppState["suggestedBinding"], allElementsMap: NonDeletedSceneElementsMap, appState: InteractiveCanvasAppState, deltaTime: number, state?: { runtime: number }, ) => { + if (suggestedBinding === null) { + return; + } + if (getFeatureFlag("COMPLEX_BINDINGS")) { return renderBindingHighlightForBindableElement_complex( app, context, - element, + suggestedBinding.element, allElementsMap, appState, deltaTime, @@ -661,11 +907,18 @@ const renderBindingHighlightForBindableElement = ( context.save(); context.translate(appState.scrollX, appState.scrollY); + const pointerCoords = app.lastPointerMoveCoords + ? pointFrom( + app.lastPointerMoveCoords.x, + app.lastPointerMoveCoords.y, + ) + : null; renderBindingHighlightForBindableElement_simple( context, - element, + suggestedBinding, allElementsMap, appState, + pointerCoords, ); context.restore(); }; @@ -1028,6 +1281,7 @@ const renderFocusPointIndicator = ({ bindableElement, elementsMap, appState, + type, ) ) { return; diff --git a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap index 1d92ee37e0..f07f054977 100644 --- a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Test > should open mermaid popup when active tool is mermaid 1`] = ` -"