diff options
Diffstat (limited to 'packages/excalidraw/element')
42 files changed, 21520 insertions, 0 deletions
diff --git a/packages/excalidraw/element/ElementCanvasButtons.scss b/packages/excalidraw/element/ElementCanvasButtons.scss new file mode 100644 index 0000000..e69cb65 --- /dev/null +++ b/packages/excalidraw/element/ElementCanvasButtons.scss @@ -0,0 +1,14 @@ +.excalidraw { + .excalidraw-canvas-buttons { + position: absolute; + + box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%); + z-index: var(--zIndex-canvasButtons); + background: var(--island-bg-color); + border-radius: var(--border-radius-lg); + + display: flex; + flex-direction: column; + gap: 0.375rem; + } +} diff --git a/packages/excalidraw/element/ElementCanvasButtons.tsx b/packages/excalidraw/element/ElementCanvasButtons.tsx new file mode 100644 index 0000000..1bcad97 --- /dev/null +++ b/packages/excalidraw/element/ElementCanvasButtons.tsx @@ -0,0 +1,63 @@ +import type { AppState } from "../types"; +import { sceneCoordsToViewportCoords } from "../utils"; +import type { ElementsMap, NonDeletedExcalidrawElement } from "./types"; +import { getElementAbsoluteCoords } from "."; +import { useExcalidrawAppState } from "../components/App"; + +import "./ElementCanvasButtons.scss"; + +const CONTAINER_PADDING = 5; + +const getContainerCoords = ( + element: NonDeletedExcalidrawElement, + appState: AppState, + elementsMap: ElementsMap, +) => { + const [x1, y1] = getElementAbsoluteCoords(element, elementsMap); + const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords( + { sceneX: x1 + element.width, sceneY: y1 }, + appState, + ); + const x = viewportX - appState.offsetLeft + 10; + const y = viewportY - appState.offsetTop; + return { x, y }; +}; + +export const ElementCanvasButtons = ({ + children, + element, + elementsMap, +}: { + children: React.ReactNode; + element: NonDeletedExcalidrawElement; + elementsMap: ElementsMap; +}) => { + const appState = useExcalidrawAppState(); + + if ( + appState.contextMenu || + appState.newElement || + appState.resizingElement || + appState.isRotating || + appState.openMenu || + appState.viewModeEnabled + ) { + return null; + } + + const { x, y } = getContainerCoords(element, appState, elementsMap); + + return ( + <div + className="excalidraw-canvas-buttons" + style={{ + top: `${y}px`, + left: `${x}px`, + // width: CONTAINER_WIDTH, + padding: CONTAINER_PADDING, + }} + > + {children} + </div> + ); +}; diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts new file mode 100644 index 0000000..e716b86 --- /dev/null +++ b/packages/excalidraw/element/binding.ts @@ -0,0 +1,2261 @@ +import type { + ExcalidrawBindableElement, + ExcalidrawElement, + NonDeleted, + ExcalidrawLinearElement, + PointBinding, + NonDeletedExcalidrawElement, + ElementsMap, + NonDeletedSceneElementsMap, + ExcalidrawTextElement, + ExcalidrawArrowElement, + OrderedExcalidrawElement, + ExcalidrawElbowArrowElement, + FixedPoint, + SceneElementsMap, + FixedPointBinding, +} from "./types"; + +import type { Bounds } from "./bounds"; +import { + getCenterForBounds, + getElementBounds, + doBoundsIntersect, +} from "./bounds"; +import type { AppState } from "../types"; +import { isPointOnShape } from "@excalidraw/utils/collision"; +import { + isArrowElement, + isBindableElement, + isBindingElement, + isBoundToContainer, + isElbowArrow, + isFixedPointBinding, + isFrameLikeElement, + isLinearElement, + isRectanguloidElement, + isTextElement, +} from "./typeChecks"; +import type { ElementUpdate } from "./mutateElement"; +import { mutateElement } from "./mutateElement"; +import type Scene from "../scene/Scene"; +import { LinearElementEditor } from "./linearElementEditor"; +import { + arrayToMap, + isBindingFallthroughEnabled, + tupleToCoors, +} from "../utils"; +import { KEYS } from "../keys"; +import { getBoundTextElement, handleBindTextResize } from "./textElement"; +import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes"; +import { + compareHeading, + HEADING_DOWN, + HEADING_RIGHT, + HEADING_UP, + headingForPointFromElement, + vectorToHeading, + type Heading, +} from "./heading"; +import type { LocalPoint, Radians } from "@excalidraw/math"; +import { + lineSegment, + pointFrom, + pointRotateRads, + type GlobalPoint, + vectorFromPoint, + pointDistanceSq, + clamp, + pointDistance, + pointFromVector, + vectorScale, + vectorNormalize, + vectorCross, + pointsEqual, + lineSegmentIntersectionPoints, + round, + PRECISION, +} from "@excalidraw/math"; +import { intersectElementWithLineSegment } from "./collision"; +import { distanceToBindableElement } from "./distance"; + +export type SuggestedBinding = + | NonDeleted<ExcalidrawBindableElement> + | SuggestedPointBinding; + +export type SuggestedPointBinding = [ + NonDeleted<ExcalidrawLinearElement>, + "start" | "end" | "both", + NonDeleted<ExcalidrawBindableElement>, +]; + +export const shouldEnableBindingForPointerEvent = ( + event: React.PointerEvent<HTMLElement>, +) => { + return !event[KEYS.CTRL_OR_CMD]; +}; + +export const isBindingEnabled = (appState: AppState): boolean => { + return appState.isBindingEnabled; +}; + +export const FIXED_BINDING_DISTANCE = 5; +export const BINDING_HIGHLIGHT_THICKNESS = 10; +export const BINDING_HIGHLIGHT_OFFSET = 4; + +const getNonDeletedElements = ( + scene: Scene, + ids: readonly ExcalidrawElement["id"][], +): NonDeleted<ExcalidrawElement>[] => { + const result: NonDeleted<ExcalidrawElement>[] = []; + ids.forEach((id) => { + const element = scene.getNonDeletedElement(id); + if (element != null) { + result.push(element); + } + }); + return result; +}; + +export const bindOrUnbindLinearElement = ( + linearElement: NonDeleted<ExcalidrawLinearElement>, + startBindingElement: ExcalidrawBindableElement | null | "keep", + endBindingElement: ExcalidrawBindableElement | null | "keep", + elementsMap: NonDeletedSceneElementsMap, + scene: Scene, +): void => { + const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); + const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); + bindOrUnbindLinearElementEdge( + linearElement, + startBindingElement, + endBindingElement, + "start", + boundToElementIds, + unboundFromElementIds, + elementsMap, + ); + bindOrUnbindLinearElementEdge( + linearElement, + endBindingElement, + startBindingElement, + "end", + boundToElementIds, + unboundFromElementIds, + elementsMap, + ); + + const onlyUnbound = Array.from(unboundFromElementIds).filter( + (id) => !boundToElementIds.has(id), + ); + + getNonDeletedElements(scene, onlyUnbound).forEach((element) => { + mutateElement(element, { + boundElements: element.boundElements?.filter( + (element) => + element.type !== "arrow" || element.id !== linearElement.id, + ), + }); + }); +}; + +const bindOrUnbindLinearElementEdge = ( + linearElement: NonDeleted<ExcalidrawLinearElement>, + bindableElement: ExcalidrawBindableElement | null | "keep", + otherEdgeBindableElement: ExcalidrawBindableElement | null | "keep", + startOrEnd: "start" | "end", + // Is mutated + boundToElementIds: Set<ExcalidrawBindableElement["id"]>, + // Is mutated + unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>, + elementsMap: NonDeletedSceneElementsMap, +): void => { + // "keep" is for method chaining convenience, a "no-op", so just bail out + if (bindableElement === "keep") { + return; + } + + // null means break the bind, so nothing to consider here + if (bindableElement === null) { + const unbound = unbindLinearElement(linearElement, startOrEnd); + if (unbound != null) { + unboundFromElementIds.add(unbound); + } + return; + } + + // While complext arrows can do anything, simple arrow with both ends trying + // to bind to the same bindable should not be allowed, start binding takes + // precedence + if (isLinearElementSimple(linearElement)) { + if ( + otherEdgeBindableElement == null || + (otherEdgeBindableElement === "keep" + ? // TODO: Refactor - Needlessly complex + !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( + linearElement, + bindableElement, + startOrEnd, + ) + : startOrEnd === "start" || + otherEdgeBindableElement.id !== bindableElement.id) + ) { + bindLinearElement( + linearElement, + bindableElement, + startOrEnd, + elementsMap, + ); + boundToElementIds.add(bindableElement.id); + } + } else { + bindLinearElement(linearElement, bindableElement, startOrEnd, elementsMap); + boundToElementIds.add(bindableElement.id); + } +}; + +const getOriginalBindingIfStillCloseOfLinearElementEdge = ( + linearElement: NonDeleted<ExcalidrawLinearElement>, + edge: "start" | "end", + elementsMap: NonDeletedSceneElementsMap, + zoom?: AppState["zoom"], +): NonDeleted<ExcalidrawElement> | null => { + const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap); + const elementId = + edge === "start" + ? linearElement.startBinding?.elementId + : linearElement.endBinding?.elementId; + if (elementId) { + const element = elementsMap.get(elementId); + if ( + isBindableElement(element) && + bindingBorderTest(element, coors, elementsMap, zoom) + ) { + return element; + } + } + + return null; +}; + +const getOriginalBindingsIfStillCloseToArrowEnds = ( + linearElement: NonDeleted<ExcalidrawLinearElement>, + elementsMap: NonDeletedSceneElementsMap, + zoom?: AppState["zoom"], +): (NonDeleted<ExcalidrawElement> | null)[] => + ["start", "end"].map((edge) => + getOriginalBindingIfStillCloseOfLinearElementEdge( + linearElement, + edge as "start" | "end", + elementsMap, + zoom, + ), + ); + +const getBindingStrategyForDraggingArrowEndpoints = ( + selectedElement: NonDeleted<ExcalidrawLinearElement>, + isBindingEnabled: boolean, + draggingPoints: readonly number[], + elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + zoom?: AppState["zoom"], +): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => { + const startIdx = 0; + const endIdx = selectedElement.points.length - 1; + const startDragged = draggingPoints.findIndex((i) => i === startIdx) > -1; + const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1; + const start = startDragged + ? isBindingEnabled + ? getElligibleElementForBindingElement( + selectedElement, + "start", + elementsMap, + elements, + zoom, + ) + : null // If binding is disabled and start is dragged, break all binds + : !isElbowArrow(selectedElement) + ? // We have to update the focus and gap of the binding, so let's rebind + getElligibleElementForBindingElement( + selectedElement, + "start", + elementsMap, + elements, + zoom, + ) + : "keep"; + const end = endDragged + ? isBindingEnabled + ? getElligibleElementForBindingElement( + selectedElement, + "end", + elementsMap, + elements, + zoom, + ) + : null // If binding is disabled and end is dragged, break all binds + : !isElbowArrow(selectedElement) + ? // We have to update the focus and gap of the binding, so let's rebind + getElligibleElementForBindingElement( + selectedElement, + "end", + elementsMap, + elements, + zoom, + ) + : "keep"; + + return [start, end]; +}; + +const getBindingStrategyForDraggingArrowOrJoints = ( + selectedElement: NonDeleted<ExcalidrawLinearElement>, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + isBindingEnabled: boolean, + zoom?: AppState["zoom"], +): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => { + // Elbow arrows don't bind when dragged as a whole + if (isElbowArrow(selectedElement)) { + return ["keep", "keep"]; + } + + const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds( + selectedElement, + elementsMap, + zoom, + ); + const start = startIsClose + ? isBindingEnabled + ? getElligibleElementForBindingElement( + selectedElement, + "start", + elementsMap, + elements, + zoom, + ) + : null + : null; + const end = endIsClose + ? isBindingEnabled + ? getElligibleElementForBindingElement( + selectedElement, + "end", + elementsMap, + elements, + zoom, + ) + : null + : null; + + return [start, end]; +}; + +export const bindOrUnbindLinearElements = ( + selectedElements: NonDeleted<ExcalidrawLinearElement>[], + elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + scene: Scene, + isBindingEnabled: boolean, + draggingPoints: readonly number[] | null, + zoom?: AppState["zoom"], +): void => { + selectedElements.forEach((selectedElement) => { + const [start, end] = draggingPoints?.length + ? // The arrow edge points are dragged (i.e. start, end) + getBindingStrategyForDraggingArrowEndpoints( + selectedElement, + isBindingEnabled, + draggingPoints ?? [], + elementsMap, + elements, + zoom, + ) + : // The arrow itself (the shaft) or the inner joins are dragged + getBindingStrategyForDraggingArrowOrJoints( + selectedElement, + elementsMap, + elements, + isBindingEnabled, + zoom, + ); + + bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene); + }); +}; + +export const getSuggestedBindingsForArrows = ( + selectedElements: NonDeleted<ExcalidrawElement>[], + elementsMap: NonDeletedSceneElementsMap, + zoom: AppState["zoom"], +): SuggestedBinding[] => { + // HOT PATH: Bail out if selected elements list is too large + if (selectedElements.length > 50) { + return []; + } + + return ( + selectedElements + .filter(isLinearElement) + .flatMap((element) => + getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom), + ) + .filter( + (element): element is NonDeleted<ExcalidrawBindableElement> => + element !== null, + ) + // Filter out bind candidates which are in the + // same selection / group with the arrow + // + // TODO: Is it worth turning the list into a set to avoid dupes? + .filter( + (element) => + selectedElements.filter((selected) => selected.id === element?.id) + .length === 0, + ) + ); +}; + +export const maybeBindLinearElement = ( + linearElement: NonDeleted<ExcalidrawLinearElement>, + appState: AppState, + pointerCoords: { x: number; y: number }, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], +): void => { + if (appState.startBoundElement != null) { + bindLinearElement( + linearElement, + appState.startBoundElement, + "start", + elementsMap, + ); + } + + const hoveredElement = getHoveredElementForBinding( + pointerCoords, + elements, + elementsMap, + appState.zoom, + isElbowArrow(linearElement), + isElbowArrow(linearElement), + ); + + if (hoveredElement !== null) { + if ( + !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( + linearElement, + hoveredElement, + "end", + ) + ) { + bindLinearElement(linearElement, hoveredElement, "end", elementsMap); + } + } +}; + +const normalizePointBinding = ( + binding: { focus: number; gap: number }, + hoveredElement: ExcalidrawBindableElement, +) => { + let gap = binding.gap; + const maxGap = maxBindingGap( + hoveredElement, + hoveredElement.width, + hoveredElement.height, + ); + + if (gap > maxGap) { + gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET; + } + return { + ...binding, + gap, + }; +}; + +export const bindLinearElement = ( + linearElement: NonDeleted<ExcalidrawLinearElement>, + hoveredElement: ExcalidrawBindableElement, + startOrEnd: "start" | "end", + elementsMap: NonDeletedSceneElementsMap, +): void => { + if (!isArrowElement(linearElement)) { + return; + } + + const binding: PointBinding | FixedPointBinding = { + elementId: hoveredElement.id, + ...(isElbowArrow(linearElement) + ? { + ...calculateFixedPointForElbowArrowBinding( + linearElement, + hoveredElement, + startOrEnd, + elementsMap, + ), + focus: 0, + gap: 0, + } + : { + ...normalizePointBinding( + calculateFocusAndGap( + linearElement, + hoveredElement, + startOrEnd, + elementsMap, + ), + hoveredElement, + ), + }), + }; + + mutateElement(linearElement, { + [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding, + }); + + const boundElementsMap = arrayToMap(hoveredElement.boundElements || []); + if (!boundElementsMap.has(linearElement.id)) { + mutateElement(hoveredElement, { + boundElements: (hoveredElement.boundElements || []).concat({ + id: linearElement.id, + type: "arrow", + }), + }); + } +}; + +// Don't bind both ends of a simple segment +const isLinearElementSimpleAndAlreadyBoundOnOppositeEdge = ( + linearElement: NonDeleted<ExcalidrawLinearElement>, + bindableElement: ExcalidrawBindableElement, + startOrEnd: "start" | "end", +): boolean => { + const otherBinding = + linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"]; + return isLinearElementSimpleAndAlreadyBound( + linearElement, + otherBinding?.elementId, + bindableElement, + ); +}; + +export const isLinearElementSimpleAndAlreadyBound = ( + linearElement: NonDeleted<ExcalidrawLinearElement>, + alreadyBoundToId: ExcalidrawBindableElement["id"] | undefined, + bindableElement: ExcalidrawBindableElement, +): boolean => { + return ( + alreadyBoundToId === bindableElement.id && + isLinearElementSimple(linearElement) + ); +}; + +const isLinearElementSimple = ( + linearElement: NonDeleted<ExcalidrawLinearElement>, +): boolean => linearElement.points.length < 3; + +const unbindLinearElement = ( + linearElement: NonDeleted<ExcalidrawLinearElement>, + startOrEnd: "start" | "end", +): ExcalidrawBindableElement["id"] | null => { + const field = startOrEnd === "start" ? "startBinding" : "endBinding"; + const binding = linearElement[field]; + if (binding == null) { + return null; + } + mutateElement(linearElement, { [field]: null }); + return binding.elementId; +}; + +export const getHoveredElementForBinding = ( + pointerCoords: { + x: number; + y: number; + }, + elements: readonly NonDeletedExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, + zoom?: AppState["zoom"], + fullShape?: boolean, + considerAllElements?: boolean, +): NonDeleted<ExcalidrawBindableElement> | null => { + if (considerAllElements) { + let cullRest = false; + const candidateElements = getAllElementsAtPositionForBinding( + elements, + (element) => + isBindableElement(element, false) && + bindingBorderTest( + element, + pointerCoords, + elementsMap, + zoom, + (fullShape || + !isBindingFallthroughEnabled( + element as ExcalidrawBindableElement, + )) && + // disable fullshape snapping for frame elements so we + // can bind to frame children + !isFrameLikeElement(element), + ), + ).filter((element) => { + if (cullRest) { + return false; + } + + if (!isBindingFallthroughEnabled(element as ExcalidrawBindableElement)) { + cullRest = true; + } + + return true; + }) as NonDeleted<ExcalidrawBindableElement>[] | null; + + // Return early if there are no candidates or just one candidate + if (!candidateElements || candidateElements.length === 0) { + return null; + } + + if (candidateElements.length === 1) { + return candidateElements[0] as NonDeleted<ExcalidrawBindableElement>; + } + + // Prefer the shape with the border being tested (if any) + const borderTestElements = candidateElements.filter((element) => + bindingBorderTest(element, pointerCoords, elementsMap, zoom, false), + ); + if (borderTestElements.length === 1) { + return borderTestElements[0]; + } + + // Prefer smaller shapes + return candidateElements + .sort( + (a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2), + ) + .pop() as NonDeleted<ExcalidrawBindableElement>; + } + + const hoveredElement = getElementAtPositionForBinding( + elements, + (element) => + isBindableElement(element, false) && + bindingBorderTest( + element, + pointerCoords, + elementsMap, + zoom, + // disable fullshape snapping for frame elements so we + // can bind to frame children + (fullShape || !isBindingFallthroughEnabled(element)) && + !isFrameLikeElement(element), + ), + ); + + return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null; +}; + +const getElementAtPositionForBinding = ( + elements: readonly NonDeletedExcalidrawElement[], + isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean, +) => { + let hitElement = null; + // We need to to hit testing from front (end of the array) to back (beginning of the array) + // because array is ordered from lower z-index to highest and we want element z-index + // with higher z-index + for (let index = elements.length - 1; index >= 0; --index) { + const element = elements[index]; + if (element.isDeleted) { + continue; + } + if (isAtPositionFn(element)) { + hitElement = element; + break; + } + } + + return hitElement; +}; + +const getAllElementsAtPositionForBinding = ( + elements: readonly NonDeletedExcalidrawElement[], + isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean, +) => { + const elementsAtPosition: NonDeletedExcalidrawElement[] = []; + // We need to to hit testing from front (end of the array) to back (beginning of the array) + // because array is ordered from lower z-index to highest and we want element z-index + // with higher z-index + for (let index = elements.length - 1; index >= 0; --index) { + const element = elements[index]; + if (element.isDeleted) { + continue; + } + + if (isAtPositionFn(element)) { + elementsAtPosition.push(element); + } + } + + return elementsAtPosition; +}; + +const calculateFocusAndGap = ( + linearElement: NonDeleted<ExcalidrawLinearElement>, + hoveredElement: ExcalidrawBindableElement, + startOrEnd: "start" | "end", + elementsMap: NonDeletedSceneElementsMap, +): { focus: number; gap: number } => { + const direction = startOrEnd === "start" ? -1 : 1; + const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; + const adjacentPointIndex = edgePointIndex - direction; + + const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + edgePointIndex, + elementsMap, + ); + const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + adjacentPointIndex, + elementsMap, + ); + + return { + focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), + gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), + }; +}; + +// Supports translating, rotating and scaling `changedElement` with bound +// linear elements. +// Because scaling involves moving the focus points as well, it is +// done before the `changedElement` is updated, and the `newSize` is passed +// in explicitly. +export const updateBoundElements = ( + changedElement: NonDeletedExcalidrawElement, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + options?: { + simultaneouslyUpdated?: readonly ExcalidrawElement[]; + newSize?: { width: number; height: number }; + changedElements?: Map<string, OrderedExcalidrawElement>; + }, +) => { + const { newSize, simultaneouslyUpdated } = options ?? {}; + const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( + simultaneouslyUpdated, + ); + + if (!isBindableElement(changedElement)) { + return; + } + + boundElementsVisitor(elementsMap, changedElement, (element) => { + if (!isLinearElement(element) || element.isDeleted) { + return; + } + + // In case the boundElements are stale + if (!doesNeedUpdate(element, changedElement)) { + return; + } + + // Check for intersections before updating bound elements incase connected elements overlap + const startBindingElement = element.startBinding + ? elementsMap.get(element.startBinding.elementId) + : null; + const endBindingElement = element.endBinding + ? elementsMap.get(element.endBinding.elementId) + : null; + + let startBounds: Bounds | null = null; + let endBounds: Bounds | null = null; + if (startBindingElement && endBindingElement) { + startBounds = getElementBounds(startBindingElement, elementsMap); + endBounds = getElementBounds(endBindingElement, elementsMap); + } + + const bindings = { + startBinding: maybeCalculateNewGapWhenScaling( + changedElement, + element.startBinding, + newSize, + ), + endBinding: maybeCalculateNewGapWhenScaling( + changedElement, + element.endBinding, + newSize, + ), + }; + + // `linearElement` is being moved/scaled already, just update the binding + if (simultaneouslyUpdatedElementIds.has(element.id)) { + mutateElement(element, bindings, true); + return; + } + + const updates = bindableElementsVisitor( + elementsMap, + element, + (bindableElement, bindingProp) => { + if ( + bindableElement && + isBindableElement(bindableElement) && + (bindingProp === "startBinding" || bindingProp === "endBinding") && + (changedElement.id === element[bindingProp]?.elementId || + (changedElement.id === + element[ + bindingProp === "startBinding" ? "endBinding" : "startBinding" + ]?.elementId && + !doBoundsIntersect(startBounds, endBounds))) + ) { + const point = updateBoundPoint( + element, + bindingProp, + bindings[bindingProp], + bindableElement, + elementsMap, + ); + if (point) { + return { + index: + bindingProp === "startBinding" ? 0 : element.points.length - 1, + point, + }; + } + } + + return null; + }, + ).filter( + ( + update, + ): update is NonNullable<{ + index: number; + point: LocalPoint; + isDragging?: boolean; + }> => update !== null, + ); + + LinearElementEditor.movePoints(element, updates, { + ...(changedElement.id === element.startBinding?.elementId + ? { startBinding: bindings.startBinding } + : {}), + ...(changedElement.id === element.endBinding?.elementId + ? { endBinding: bindings.endBinding } + : {}), + }); + + const boundText = getBoundTextElement(element, elementsMap); + if (boundText && !boundText.isDeleted) { + handleBindTextResize(element, elementsMap, false); + } + }); +}; + +const doesNeedUpdate = ( + boundElement: NonDeleted<ExcalidrawLinearElement>, + changedElement: ExcalidrawBindableElement, +) => { + return ( + boundElement.startBinding?.elementId === changedElement.id || + boundElement.endBinding?.elementId === changedElement.id + ); +}; + +const getSimultaneouslyUpdatedElementIds = ( + simultaneouslyUpdated: readonly ExcalidrawElement[] | undefined, +): Set<ExcalidrawElement["id"]> => { + return new Set((simultaneouslyUpdated || []).map((element) => element.id)); +}; + +export const getHeadingForElbowArrowSnap = ( + p: Readonly<GlobalPoint>, + otherPoint: Readonly<GlobalPoint>, + bindableElement: ExcalidrawBindableElement | undefined | null, + aabb: Bounds | undefined | null, + elementsMap: ElementsMap, + origPoint: GlobalPoint, + zoom?: AppState["zoom"], +): Heading => { + const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p)); + + if (!bindableElement || !aabb) { + return otherPointHeading; + } + + const distance = getDistanceForBinding( + origPoint, + bindableElement, + elementsMap, + zoom, + ); + + if (!distance) { + return vectorToHeading( + vectorFromPoint( + p, + pointFrom<GlobalPoint>( + bindableElement.x + bindableElement.width / 2, + bindableElement.y + bindableElement.height / 2, + ), + ), + ); + } + + return headingForPointFromElement(bindableElement, aabb, p); +}; + +const getDistanceForBinding = ( + point: Readonly<GlobalPoint>, + bindableElement: ExcalidrawBindableElement, + elementsMap: ElementsMap, + zoom?: AppState["zoom"], +) => { + const distance = distanceToBindableElement(bindableElement, point); + const bindDistance = maxBindingGap( + bindableElement, + bindableElement.width, + bindableElement.height, + zoom, + ); + + return distance > bindDistance ? null : distance; +}; + +export const bindPointToSnapToElementOutline = ( + arrow: ExcalidrawElbowArrowElement, + bindableElement: ExcalidrawBindableElement | undefined, + startOrEnd: "start" | "end", +): GlobalPoint => { + const aabb = bindableElement && aabbForElement(bindableElement); + const localP = + arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1]; + const globalP = pointFrom<GlobalPoint>( + arrow.x + localP[0], + arrow.y + localP[1], + ); + const p = isRectanguloidElement(bindableElement) + ? avoidRectangularCorner(bindableElement, globalP) + : globalP; + + if (bindableElement && aabb) { + const center = getCenterForBounds(aabb); + + const intersection = intersectElementWithLineSegment( + bindableElement, + lineSegment( + center, + pointFromVector( + vectorScale( + vectorNormalize(vectorFromPoint(p, center)), + Math.max(bindableElement.width, bindableElement.height), + ), + center, + ), + ), + )[0]; + const currentDistance = pointDistance(p, center); + const fullDistance = Math.max( + pointDistance(intersection ?? p, center), + PRECISION, + ); + const ratio = round(currentDistance / fullDistance, 6); + + switch (true) { + case ratio > 0.9: + if ( + currentDistance - fullDistance > FIXED_BINDING_DISTANCE || + // Too close to determine vector from intersection to p + pointDistanceSq(p, intersection) < PRECISION + ) { + return p; + } + + return pointFromVector( + vectorScale( + vectorNormalize(vectorFromPoint(p, intersection ?? center)), + ratio > 1 ? FIXED_BINDING_DISTANCE : -FIXED_BINDING_DISTANCE, + ), + intersection ?? center, + ); + + default: + return headingToMidBindPoint(p, bindableElement, aabb); + } + } + + return p; +}; + +const headingToMidBindPoint = ( + p: GlobalPoint, + bindableElement: ExcalidrawBindableElement, + aabb: Bounds, +): GlobalPoint => { + const center = getCenterForBounds(aabb); + const heading = vectorToHeading(vectorFromPoint(p, center)); + + switch (true) { + case compareHeading(heading, HEADING_UP): + return pointRotateRads( + pointFrom((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]), + center, + bindableElement.angle, + ); + case compareHeading(heading, HEADING_RIGHT): + return pointRotateRads( + pointFrom(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1), + center, + bindableElement.angle, + ); + case compareHeading(heading, HEADING_DOWN): + return pointRotateRads( + pointFrom((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]), + center, + bindableElement.angle, + ); + default: + return pointRotateRads( + pointFrom(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1), + center, + bindableElement.angle, + ); + } +}; + +export const avoidRectangularCorner = ( + element: ExcalidrawBindableElement, + p: GlobalPoint, +): GlobalPoint => { + const center = pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height / 2, + ); + const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians); + + if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { + // Top left + if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) { + return pointRotateRads<GlobalPoint>( + pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y), + center, + element.angle, + ); + } + return pointRotateRads( + pointFrom(element.x, element.y - FIXED_BINDING_DISTANCE), + center, + element.angle, + ); + } else if ( + nonRotatedPoint[0] < element.x && + nonRotatedPoint[1] > element.y + element.height + ) { + // Bottom left + if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) { + return pointRotateRads( + pointFrom( + element.x, + element.y + element.height + FIXED_BINDING_DISTANCE, + ), + center, + element.angle, + ); + } + return pointRotateRads( + pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y + element.height), + center, + element.angle, + ); + } else if ( + nonRotatedPoint[0] > element.x + element.width && + nonRotatedPoint[1] > element.y + element.height + ) { + // Bottom right + if ( + nonRotatedPoint[0] - element.x < + element.width + FIXED_BINDING_DISTANCE + ) { + return pointRotateRads( + pointFrom( + element.x + element.width, + element.y + element.height + FIXED_BINDING_DISTANCE, + ), + center, + element.angle, + ); + } + return pointRotateRads( + pointFrom( + element.x + element.width + FIXED_BINDING_DISTANCE, + element.y + element.height, + ), + center, + element.angle, + ); + } else if ( + nonRotatedPoint[0] > element.x + element.width && + nonRotatedPoint[1] < element.y + ) { + // Top right + if ( + nonRotatedPoint[0] - element.x < + element.width + FIXED_BINDING_DISTANCE + ) { + return pointRotateRads( + pointFrom( + element.x + element.width, + element.y - FIXED_BINDING_DISTANCE, + ), + center, + element.angle, + ); + } + return pointRotateRads( + pointFrom(element.x + element.width + FIXED_BINDING_DISTANCE, element.y), + center, + element.angle, + ); + } + + return p; +}; + +export const snapToMid = ( + element: ExcalidrawBindableElement, + p: GlobalPoint, + tolerance: number = 0.05, +): GlobalPoint => { + const { x, y, width, height, angle } = element; + const center = pointFrom<GlobalPoint>( + x + width / 2 - 0.1, + y + height / 2 - 0.1, + ); + const nonRotated = pointRotateRads(p, center, -angle as Radians); + + // snap-to-center point is adaptive to element size, but we don't want to go + // above and below certain px distance + const verticalThrehsold = clamp(tolerance * height, 5, 80); + const horizontalThrehsold = clamp(tolerance * width, 5, 80); + + if ( + nonRotated[0] <= x + width / 2 && + nonRotated[1] > center[1] - verticalThrehsold && + nonRotated[1] < center[1] + verticalThrehsold + ) { + // LEFT + return pointRotateRads( + pointFrom(x - FIXED_BINDING_DISTANCE, center[1]), + center, + angle, + ); + } else if ( + nonRotated[1] <= y + height / 2 && + nonRotated[0] > center[0] - horizontalThrehsold && + nonRotated[0] < center[0] + horizontalThrehsold + ) { + // TOP + return pointRotateRads( + pointFrom(center[0], y - FIXED_BINDING_DISTANCE), + center, + angle, + ); + } else if ( + nonRotated[0] >= x + width / 2 && + nonRotated[1] > center[1] - verticalThrehsold && + nonRotated[1] < center[1] + verticalThrehsold + ) { + // RIGHT + return pointRotateRads( + pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]), + center, + angle, + ); + } else if ( + nonRotated[1] >= y + height / 2 && + nonRotated[0] > center[0] - horizontalThrehsold && + nonRotated[0] < center[0] + horizontalThrehsold + ) { + // DOWN + return pointRotateRads( + pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE), + center, + angle, + ); + } + + return p; +}; + +const updateBoundPoint = ( + linearElement: NonDeleted<ExcalidrawLinearElement>, + startOrEnd: "startBinding" | "endBinding", + binding: PointBinding | null | undefined, + bindableElement: ExcalidrawBindableElement, + elementsMap: ElementsMap, +): LocalPoint | null => { + if ( + binding == null || + // We only need to update the other end if this is a 2 point line element + (binding.elementId !== bindableElement.id && + linearElement.points.length > 2) + ) { + return null; + } + + const direction = startOrEnd === "startBinding" ? -1 : 1; + const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; + + if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) { + const fixedPoint = + normalizeFixedPoint(binding.fixedPoint) ?? + calculateFixedPointForElbowArrowBinding( + linearElement, + bindableElement, + startOrEnd === "startBinding" ? "start" : "end", + elementsMap, + ).fixedPoint; + const globalMidPoint = pointFrom<GlobalPoint>( + bindableElement.x + bindableElement.width / 2, + bindableElement.y + bindableElement.height / 2, + ); + const global = pointFrom<GlobalPoint>( + bindableElement.x + fixedPoint[0] * bindableElement.width, + bindableElement.y + fixedPoint[1] * bindableElement.height, + ); + const rotatedGlobal = pointRotateRads( + global, + globalMidPoint, + bindableElement.angle, + ); + + return LinearElementEditor.pointFromAbsoluteCoords( + linearElement, + rotatedGlobal, + elementsMap, + ); + } + + const adjacentPointIndex = edgePointIndex - direction; + const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + adjacentPointIndex, + elementsMap, + ); + const focusPointAbsolute = determineFocusPoint( + bindableElement, + binding.focus, + adjacentPoint, + ); + + let newEdgePoint: GlobalPoint; + + // The linear element was not originally pointing inside the bound shape, + // we can point directly at the focus point + if (binding.gap === 0) { + newEdgePoint = focusPointAbsolute; + } else { + const edgePointAbsolute = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + edgePointIndex, + elementsMap, + ); + + const center = pointFrom<GlobalPoint>( + bindableElement.x + bindableElement.width / 2, + bindableElement.y + bindableElement.height / 2, + ); + const interceptorLength = + pointDistance(adjacentPoint, edgePointAbsolute) + + pointDistance(adjacentPoint, center) + + Math.max(bindableElement.width, bindableElement.height) * 2; + const intersections = intersectElementWithLineSegment( + bindableElement, + lineSegment<GlobalPoint>( + adjacentPoint, + pointFromVector( + vectorScale( + vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)), + interceptorLength, + ), + adjacentPoint, + ), + ), + binding.gap, + ).sort( + (g, h) => + pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint), + ); + + // debugClear(); + // debugDrawPoint(intersections[0], { color: "red", permanent: true }); + // debugDrawLine( + // lineSegment<GlobalPoint>( + // adjacentPoint, + // pointFromVector( + // vectorScale( + // vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)), + // interceptorLength, + // ), + // adjacentPoint, + // ), + // ), + // { permanent: true, color: "green" }, + // ); + + if (intersections.length > 1) { + // The adjacent point is outside the shape (+ gap) + newEdgePoint = intersections[0]; + } else if (intersections.length === 1) { + // The adjacent point is inside the shape (+ gap) + newEdgePoint = focusPointAbsolute; + } else { + // Shouldn't happend, but just in case + newEdgePoint = edgePointAbsolute; + } + } + + return LinearElementEditor.pointFromAbsoluteCoords( + linearElement, + newEdgePoint, + elementsMap, + ); +}; + +export const calculateFixedPointForElbowArrowBinding = ( + linearElement: NonDeleted<ExcalidrawElbowArrowElement>, + hoveredElement: ExcalidrawBindableElement, + startOrEnd: "start" | "end", + elementsMap: ElementsMap, +): { fixedPoint: FixedPoint } => { + const bounds = [ + hoveredElement.x, + hoveredElement.y, + hoveredElement.x + hoveredElement.width, + hoveredElement.y + hoveredElement.height, + ] as Bounds; + const snappedPoint = bindPointToSnapToElementOutline( + linearElement, + hoveredElement, + startOrEnd, + ); + const globalMidPoint = pointFrom( + bounds[0] + (bounds[2] - bounds[0]) / 2, + bounds[1] + (bounds[3] - bounds[1]) / 2, + ); + const nonRotatedSnappedGlobalPoint = pointRotateRads( + snappedPoint, + globalMidPoint, + -hoveredElement.angle as Radians, + ); + + return { + fixedPoint: normalizeFixedPoint([ + (nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) / + hoveredElement.width, + (nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) / + hoveredElement.height, + ]), + }; +}; + +const maybeCalculateNewGapWhenScaling = ( + changedElement: ExcalidrawBindableElement, + currentBinding: PointBinding | null | undefined, + newSize: { width: number; height: number } | undefined, +): PointBinding | null | undefined => { + if (currentBinding == null || newSize == null) { + return currentBinding; + } + const { width: newWidth, height: newHeight } = newSize; + const { width, height } = changedElement; + const newGap = Math.max( + 1, + Math.min( + maxBindingGap(changedElement, newWidth, newHeight), + currentBinding.gap * + (newWidth < newHeight ? newWidth / width : newHeight / height), + ), + ); + + return { ...currentBinding, gap: newGap }; +}; + +const getElligibleElementForBindingElement = ( + linearElement: NonDeleted<ExcalidrawLinearElement>, + startOrEnd: "start" | "end", + elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + zoom?: AppState["zoom"], +): NonDeleted<ExcalidrawBindableElement> | null => { + return getHoveredElementForBinding( + getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), + elements, + elementsMap, + zoom, + isElbowArrow(linearElement), + isElbowArrow(linearElement), + ); +}; + +const getLinearElementEdgeCoors = ( + linearElement: NonDeleted<ExcalidrawLinearElement>, + startOrEnd: "start" | "end", + elementsMap: NonDeletedSceneElementsMap, +): { x: number; y: number } => { + const index = startOrEnd === "start" ? 0 : -1; + return tupleToCoors( + LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + index, + elementsMap, + ), + ); +}; + +// We need to: +// 1: Update elements not selected to point to duplicated elements +// 2: Update duplicated elements to point to other duplicated elements +export const fixBindingsAfterDuplication = ( + sceneElements: readonly ExcalidrawElement[], + oldElements: readonly ExcalidrawElement[], + oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>, + // There are three copying mechanisms: Copy-paste, duplication and alt-drag. + // Only when alt-dragging the new "duplicates" act as the "old", while + // the "old" elements act as the "new copy" - essentially working reverse + // to the other two. + duplicatesServeAsOld?: "duplicatesServeAsOld" | undefined, +): void => { + // First collect all the binding/bindable elements, so we only update + // each once, regardless of whether they were duplicated or not. + const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set(); + const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set(); + const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld"; + const duplicateIdToOldId = new Map( + [...oldIdToDuplicatedId].map(([key, value]) => [value, key]), + ); + oldElements.forEach((oldElement) => { + const { boundElements } = oldElement; + if (boundElements != null && boundElements.length > 0) { + boundElements.forEach((boundElement) => { + if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElement.id)) { + allBoundElementIds.add(boundElement.id); + } + }); + allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!); + } + if (isBindingElement(oldElement)) { + if (oldElement.startBinding != null) { + const { elementId } = oldElement.startBinding; + if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) { + allBindableElementIds.add(elementId); + } + } + if (oldElement.endBinding != null) { + const { elementId } = oldElement.endBinding; + if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) { + allBindableElementIds.add(elementId); + } + } + if (oldElement.startBinding != null || oldElement.endBinding != null) { + allBoundElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!); + } + } + }); + + // Update the linear elements + ( + sceneElements.filter(({ id }) => + allBoundElementIds.has(id), + ) as ExcalidrawLinearElement[] + ).forEach((element) => { + const { startBinding, endBinding } = element; + mutateElement(element, { + startBinding: newBindingAfterDuplication( + startBinding, + oldIdToDuplicatedId, + ), + endBinding: newBindingAfterDuplication(endBinding, oldIdToDuplicatedId), + }); + }); + + // Update the bindable shapes + sceneElements + .filter(({ id }) => allBindableElementIds.has(id)) + .forEach((bindableElement) => { + const oldElementId = duplicateIdToOldId.get(bindableElement.id); + const boundElements = sceneElements.find( + ({ id }) => id === oldElementId, + )?.boundElements; + + if (boundElements && boundElements.length > 0) { + mutateElement(bindableElement, { + boundElements: boundElements.map((boundElement) => + oldIdToDuplicatedId.has(boundElement.id) + ? { + id: oldIdToDuplicatedId.get(boundElement.id)!, + type: boundElement.type, + } + : boundElement, + ), + }); + } + }); +}; + +const newBindingAfterDuplication = ( + binding: PointBinding | null, + oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>, +): PointBinding | null => { + if (binding == null) { + return null; + } + return { + ...binding, + elementId: oldIdToDuplicatedId.get(binding.elementId) ?? binding.elementId, + }; +}; + +export const fixBindingsAfterDeletion = ( + sceneElements: readonly ExcalidrawElement[], + deletedElements: readonly ExcalidrawElement[], +): void => { + const elements = arrayToMap(sceneElements); + + for (const element of deletedElements) { + BoundElement.unbindAffected(elements, element, mutateElement); + BindableElement.unbindAffected(elements, element, mutateElement); + } +}; + +const newBoundElements = ( + boundElements: ExcalidrawElement["boundElements"], + idsToRemove: Set<ExcalidrawElement["id"]>, + elementsToAdd: Array<ExcalidrawElement> = [], +) => { + if (!boundElements) { + return null; + } + + const nextBoundElements = boundElements.filter( + (boundElement) => !idsToRemove.has(boundElement.id), + ); + + nextBoundElements.push( + ...elementsToAdd.map( + (x) => + ({ id: x.id, type: x.type } as + | ExcalidrawArrowElement + | ExcalidrawTextElement), + ), + ); + + return nextBoundElements; +}; + +export const bindingBorderTest = ( + element: NonDeleted<ExcalidrawBindableElement>, + { x, y }: { x: number; y: number }, + elementsMap: NonDeletedSceneElementsMap, + zoom?: AppState["zoom"], + fullShape?: boolean, +): boolean => { + const threshold = maxBindingGap(element, element.width, element.height, zoom); + + const shape = getElementShape(element, elementsMap); + return ( + isPointOnShape(pointFrom(x, y), shape, threshold) || + (fullShape === true && + pointInsideBounds(pointFrom(x, y), aabbForElement(element))) + ); +}; + +export const maxBindingGap = ( + element: ExcalidrawElement, + elementWidth: number, + elementHeight: number, + zoom?: AppState["zoom"], +): number => { + const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1; + + // Aligns diamonds with rectangles + const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1; + const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight); + + return Math.max( + 16, + // bigger bindable boundary for bigger elements + Math.min(0.25 * smallerDimension, 32), + // keep in sync with the zoomed highlight + BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET, + ); +}; + +// The focus distance is the oriented ratio between the size of +// the `element` and the "focus image" of the element on which +// all focus points lie, so it's a number between -1 and 1. +// The line going through `a` and `b` is a tangent to the "focus image" +// of the element. +const determineFocusDistance = ( + element: ExcalidrawBindableElement, + // Point on the line, in absolute coordinates + a: GlobalPoint, + // Another point on the line, in absolute coordinates (closer to element) + b: GlobalPoint, +): number => { + const center = pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + if (pointsEqual(a, b)) { + return 0; + } + + const rotatedA = pointRotateRads(a, center, -element.angle as Radians); + const rotatedB = pointRotateRads(b, center, -element.angle as Radians); + const sign = + Math.sign( + vectorCross( + vectorFromPoint(rotatedB, a), + vectorFromPoint(rotatedB, center), + ), + ) * -1; + const rotatedInterceptor = lineSegment( + rotatedB, + pointFromVector( + vectorScale( + vectorNormalize(vectorFromPoint(rotatedB, rotatedA)), + Math.max(element.width * 2, element.height * 2), + ), + rotatedB, + ), + ); + const axes = + element.type === "diamond" + ? [ + lineSegment( + pointFrom<GlobalPoint>(element.x + element.width / 2, element.y), + pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height, + ), + ), + lineSegment( + pointFrom<GlobalPoint>(element.x, element.y + element.height / 2), + pointFrom<GlobalPoint>( + element.x + element.width, + element.y + element.height / 2, + ), + ), + ] + : [ + lineSegment( + pointFrom<GlobalPoint>(element.x, element.y), + pointFrom<GlobalPoint>( + element.x + element.width, + element.y + element.height, + ), + ), + lineSegment( + pointFrom<GlobalPoint>(element.x + element.width, element.y), + pointFrom<GlobalPoint>(element.x, element.y + element.height), + ), + ]; + const interceptees = + element.type === "diamond" + ? [ + lineSegment( + pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y - element.height, + ), + pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height * 2, + ), + ), + lineSegment( + pointFrom<GlobalPoint>( + element.x - element.width, + element.y + element.height / 2, + ), + pointFrom<GlobalPoint>( + element.x + element.width * 2, + element.y + element.height / 2, + ), + ), + ] + : [ + lineSegment( + pointFrom<GlobalPoint>( + element.x - element.width, + element.y - element.height, + ), + pointFrom<GlobalPoint>( + element.x + element.width * 2, + element.y + element.height * 2, + ), + ), + lineSegment( + pointFrom<GlobalPoint>( + element.x + element.width * 2, + element.y - element.height, + ), + pointFrom<GlobalPoint>( + element.x - element.width, + element.y + element.height * 2, + ), + ), + ]; + + const ordered = [ + lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[0]), + lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[1]), + ] + .filter((p): p is GlobalPoint => p !== null) + .sort((g, h) => pointDistanceSq(g, b) - pointDistanceSq(h, b)) + .map( + (p, idx): number => + (sign * pointDistance(center, p)) / + (element.type === "diamond" + ? pointDistance(axes[idx][0], axes[idx][1]) / 2 + : Math.sqrt(element.width ** 2 + element.height ** 2) / 2), + ) + .sort((g, h) => Math.abs(g) - Math.abs(h)); + + // debugClear(); + // [ + // lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[0]), + // lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[1]), + // ] + // .filter((p): p is GlobalPoint => p !== null) + // .forEach((p) => debugDrawPoint(p, { color: "black", permanent: true })); + // debugDrawPoint(determineFocusPoint(element, ordered[0] ?? 0, rotatedA), { + // color: "red", + // permanent: true, + // }); + // debugDrawLine(rotatedInterceptor, { color: "green", permanent: true }); + // debugDrawLine(interceptees[0], { color: "red", permanent: true }); + // debugDrawLine(interceptees[1], { color: "red", permanent: true }); + + const signedDistanceRatio = ordered[0] ?? 0; + + return signedDistanceRatio; +}; + +const determineFocusPoint = ( + element: ExcalidrawBindableElement, + // The oriented, relative distance from the center of `element` of the + // returned focusPoint + focus: number, + adjacentPoint: GlobalPoint, +): GlobalPoint => { + const center = pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + if (focus === 0) { + return center; + } + + const candidates = ( + element.type === "diamond" + ? [ + pointFrom<GlobalPoint>(element.x, element.y + element.height / 2), + pointFrom<GlobalPoint>(element.x + element.width / 2, element.y), + pointFrom<GlobalPoint>( + element.x + element.width, + element.y + element.height / 2, + ), + pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height, + ), + ] + : [ + pointFrom<GlobalPoint>(element.x, element.y), + pointFrom<GlobalPoint>(element.x + element.width, element.y), + pointFrom<GlobalPoint>( + element.x + element.width, + element.y + element.height, + ), + pointFrom<GlobalPoint>(element.x, element.y + element.height), + ] + ) + .map((p) => + pointFromVector( + vectorScale(vectorFromPoint(p, center), Math.abs(focus)), + center, + ), + ) + .map((p) => pointRotateRads(p, center, element.angle as Radians)); + + const selected = [ + vectorCross( + vectorFromPoint(adjacentPoint, candidates[0]), + vectorFromPoint(candidates[1], candidates[0]), + ) > 0 && // TOP + (focus > 0 + ? vectorCross( + vectorFromPoint(adjacentPoint, candidates[1]), + vectorFromPoint(candidates[2], candidates[1]), + ) < 0 + : vectorCross( + vectorFromPoint(adjacentPoint, candidates[3]), + vectorFromPoint(candidates[0], candidates[3]), + ) < 0), + vectorCross( + vectorFromPoint(adjacentPoint, candidates[1]), + vectorFromPoint(candidates[2], candidates[1]), + ) > 0 && // RIGHT + (focus > 0 + ? vectorCross( + vectorFromPoint(adjacentPoint, candidates[2]), + vectorFromPoint(candidates[3], candidates[2]), + ) < 0 + : vectorCross( + vectorFromPoint(adjacentPoint, candidates[0]), + vectorFromPoint(candidates[1], candidates[0]), + ) < 0), + vectorCross( + vectorFromPoint(adjacentPoint, candidates[2]), + vectorFromPoint(candidates[3], candidates[2]), + ) > 0 && // BOTTOM + (focus > 0 + ? vectorCross( + vectorFromPoint(adjacentPoint, candidates[3]), + vectorFromPoint(candidates[0], candidates[3]), + ) < 0 + : vectorCross( + vectorFromPoint(adjacentPoint, candidates[1]), + vectorFromPoint(candidates[2], candidates[1]), + ) < 0), + vectorCross( + vectorFromPoint(adjacentPoint, candidates[3]), + vectorFromPoint(candidates[0], candidates[3]), + ) > 0 && // LEFT + (focus > 0 + ? vectorCross( + vectorFromPoint(adjacentPoint, candidates[0]), + vectorFromPoint(candidates[1], candidates[0]), + ) < 0 + : vectorCross( + vectorFromPoint(adjacentPoint, candidates[2]), + vectorFromPoint(candidates[3], candidates[2]), + ) < 0), + ]; + + const focusPoint = selected[0] + ? focus > 0 + ? candidates[1] + : candidates[0] + : selected[1] + ? focus > 0 + ? candidates[2] + : candidates[1] + : selected[2] + ? focus > 0 + ? candidates[3] + : candidates[2] + : focus > 0 + ? candidates[0] + : candidates[3]; + + return focusPoint; +}; + +export const bindingProperties: Set<BindableProp | BindingProp> = new Set([ + "boundElements", + "frameId", + "containerId", + "startBinding", + "endBinding", +]); + +export type BindableProp = "boundElements"; + +export type BindingProp = + | "frameId" + | "containerId" + | "startBinding" + | "endBinding"; + +type BoundElementsVisitingFunc = ( + boundElement: ExcalidrawElement | undefined, + bindingProp: BindableProp, + bindingId: string, +) => void; + +type BindableElementVisitingFunc<T> = ( + bindableElement: ExcalidrawElement | undefined, + bindingProp: BindingProp, + bindingId: string, +) => T; + +/** + * Tries to visit each bound element (does not have to be found). + */ +const boundElementsVisitor = ( + elements: ElementsMap, + element: ExcalidrawElement, + visit: BoundElementsVisitingFunc, +) => { + if (isBindableElement(element)) { + // create new instance so that possible mutations won't play a role in visiting order + const boundElements = element.boundElements?.slice() ?? []; + + // last added text should be the one we keep (~previous are duplicates) + boundElements.forEach(({ id }) => { + visit(elements.get(id), "boundElements", id); + }); + } +}; + +/** + * Tries to visit each bindable element (does not have to be found). + */ +const bindableElementsVisitor = <T>( + elements: ElementsMap, + element: ExcalidrawElement, + visit: BindableElementVisitingFunc<T>, +): T[] => { + const result: T[] = []; + + if (element.frameId) { + const id = element.frameId; + result.push(visit(elements.get(id), "frameId", id)); + } + + if (isBoundToContainer(element)) { + const id = element.containerId; + result.push(visit(elements.get(id), "containerId", id)); + } + + if (isArrowElement(element)) { + if (element.startBinding) { + const id = element.startBinding.elementId; + result.push(visit(elements.get(id), "startBinding", id)); + } + + if (element.endBinding) { + const id = element.endBinding.elementId; + result.push(visit(elements.get(id), "endBinding", id)); + } + } + + return result; +}; + +/** + * Bound element containing bindings to `frameId`, `containerId`, `startBinding` or `endBinding`. + */ +export class BoundElement { + /** + * Unbind the affected non deleted bindable elements (removing element from `boundElements`). + * - iterates non deleted bindable elements (`containerId` | `startBinding.elementId` | `endBinding.elementId`) of the current element + * - prepares updates to unbind each bindable element's `boundElements` from the current element + */ + public static unbindAffected( + elements: ElementsMap, + boundElement: ExcalidrawElement | undefined, + updateElementWith: ( + affected: ExcalidrawElement, + updates: ElementUpdate<ExcalidrawElement>, + ) => void, + ) { + if (!boundElement) { + return; + } + + bindableElementsVisitor(elements, boundElement, (bindableElement) => { + // bindable element is deleted, this is fine + if (!bindableElement || bindableElement.isDeleted) { + return; + } + + boundElementsVisitor( + elements, + bindableElement, + (_, __, boundElementId) => { + if (boundElementId === boundElement.id) { + updateElementWith(bindableElement, { + boundElements: newBoundElements( + bindableElement.boundElements, + new Set([boundElementId]), + ), + }); + } + }, + ); + }); + } + + /** + * Rebind the next affected non deleted bindable elements (adding element to `boundElements`). + * - iterates non deleted bindable elements (`containerId` | `startBinding.elementId` | `endBinding.elementId`) of the current element + * - prepares updates to rebind each bindable element's `boundElements` to the current element + * + * NOTE: rebind expects that affected elements were previously unbound with `BoundElement.unbindAffected` + */ + public static rebindAffected = ( + elements: ElementsMap, + boundElement: ExcalidrawElement | undefined, + updateElementWith: ( + affected: ExcalidrawElement, + updates: ElementUpdate<ExcalidrawElement>, + ) => void, + ) => { + // don't try to rebind element that is deleted + if (!boundElement || boundElement.isDeleted) { + return; + } + + bindableElementsVisitor( + elements, + boundElement, + (bindableElement, bindingProp) => { + // unbind from bindable elements, as bindings from non deleted elements into deleted elements are incorrect + if (!bindableElement || bindableElement.isDeleted) { + updateElementWith(boundElement, { [bindingProp]: null }); + return; + } + + // frame bindings are unidirectional, there is nothing to rebind + if (bindingProp === "frameId") { + return; + } + + if ( + bindableElement.boundElements?.find((x) => x.id === boundElement.id) + ) { + return; + } + + if (isArrowElement(boundElement)) { + // rebind if not found! + updateElementWith(bindableElement, { + boundElements: newBoundElements( + bindableElement.boundElements, + new Set(), + new Array(boundElement), + ), + }); + } + + if (isTextElement(boundElement)) { + if (!bindableElement.boundElements?.find((x) => x.type === "text")) { + // rebind only if there is no other text bound already + updateElementWith(bindableElement, { + boundElements: newBoundElements( + bindableElement.boundElements, + new Set(), + new Array(boundElement), + ), + }); + } else { + // unbind otherwise + updateElementWith(boundElement, { [bindingProp]: null }); + } + } + }, + ); + }; +} + +/** + * Bindable element containing bindings to `boundElements`. + */ +export class BindableElement { + /** + * Unbind the affected non deleted bound elements (resetting `containerId`, `startBinding`, `endBinding` to `null`). + * - iterates through non deleted `boundElements` of the current element + * - prepares updates to unbind each bound element from the current element + */ + public static unbindAffected( + elements: ElementsMap, + bindableElement: ExcalidrawElement | undefined, + updateElementWith: ( + affected: ExcalidrawElement, + updates: ElementUpdate<ExcalidrawElement>, + ) => void, + ) { + if (!bindableElement) { + return; + } + + boundElementsVisitor(elements, bindableElement, (boundElement) => { + // bound element is deleted, this is fine + if (!boundElement || boundElement.isDeleted) { + return; + } + + bindableElementsVisitor( + elements, + boundElement, + (_, bindingProp, bindableElementId) => { + // making sure there is an element to be unbound + if (bindableElementId === bindableElement.id) { + updateElementWith(boundElement, { [bindingProp]: null }); + } + }, + ); + }); + } + + /** + * Rebind the affected non deleted bound elements (for now setting only `containerId`, as we cannot rebind arrows atm). + * - iterates through non deleted `boundElements` of the current element + * - prepares updates to rebind each bound element to the current element or unbind it from `boundElements` in case of conflicts + * + * NOTE: rebind expects that affected elements were previously unbound with `BindaleElement.unbindAffected` + */ + public static rebindAffected = ( + elements: ElementsMap, + bindableElement: ExcalidrawElement | undefined, + updateElementWith: ( + affected: ExcalidrawElement, + updates: ElementUpdate<ExcalidrawElement>, + ) => void, + ) => { + // don't try to rebind element that is deleted (i.e. updated as deleted) + if (!bindableElement || bindableElement.isDeleted) { + return; + } + + boundElementsVisitor( + elements, + bindableElement, + (boundElement, _, boundElementId) => { + // unbind from bindable elements, as bindings from non deleted elements into deleted elements are incorrect + if (!boundElement || boundElement.isDeleted) { + updateElementWith(bindableElement, { + boundElements: newBoundElements( + bindableElement.boundElements, + new Set([boundElementId]), + ), + }); + return; + } + + if (isTextElement(boundElement)) { + const boundElements = bindableElement.boundElements?.slice() ?? []; + // check if this is the last element in the array, if not, there is an previously bound text which should be unbound + if ( + boundElements.reverse().find((x) => x.type === "text")?.id === + boundElement.id + ) { + if (boundElement.containerId !== bindableElement.id) { + // rebind if not bound already! + updateElementWith(boundElement, { + containerId: bindableElement.id, + } as ElementUpdate<ExcalidrawTextElement>); + } + } else { + if (boundElement.containerId !== null) { + // unbind if not unbound already + updateElementWith(boundElement, { + containerId: null, + } as ElementUpdate<ExcalidrawTextElement>); + } + + // unbind from boundElements as the element got bound to some other element in the meantime + updateElementWith(bindableElement, { + boundElements: newBoundElements( + bindableElement.boundElements, + new Set([boundElement.id]), + ), + }); + } + } + }, + ); + }; +} + +export const getGlobalFixedPointForBindableElement = ( + fixedPointRatio: [number, number], + element: ExcalidrawBindableElement, +): GlobalPoint => { + const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); + + return pointRotateRads( + pointFrom( + element.x + element.width * fixedX, + element.y + element.height * fixedY, + ), + pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height / 2, + ), + element.angle, + ); +}; + +export const getGlobalFixedPoints = ( + arrow: ExcalidrawElbowArrowElement, + elementsMap: ElementsMap, +): [GlobalPoint, GlobalPoint] => { + const startElement = + arrow.startBinding && + (elementsMap.get(arrow.startBinding.elementId) as + | ExcalidrawBindableElement + | undefined); + const endElement = + arrow.endBinding && + (elementsMap.get(arrow.endBinding.elementId) as + | ExcalidrawBindableElement + | undefined); + const startPoint = + startElement && arrow.startBinding + ? getGlobalFixedPointForBindableElement( + arrow.startBinding.fixedPoint, + startElement as ExcalidrawBindableElement, + ) + : pointFrom<GlobalPoint>( + arrow.x + arrow.points[0][0], + arrow.y + arrow.points[0][1], + ); + const endPoint = + endElement && arrow.endBinding + ? getGlobalFixedPointForBindableElement( + arrow.endBinding.fixedPoint, + endElement as ExcalidrawBindableElement, + ) + : pointFrom<GlobalPoint>( + arrow.x + arrow.points[arrow.points.length - 1][0], + arrow.y + arrow.points[arrow.points.length - 1][1], + ); + + return [startPoint, endPoint]; +}; + +export const getArrowLocalFixedPoints = ( + arrow: ExcalidrawElbowArrowElement, + elementsMap: ElementsMap, +) => { + const [startPoint, endPoint] = getGlobalFixedPoints(arrow, elementsMap); + + return [ + LinearElementEditor.pointFromAbsoluteCoords(arrow, startPoint, elementsMap), + LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap), + ]; +}; + +export const normalizeFixedPoint = <T extends FixedPoint | null>( + fixedPoint: T, +): T extends null ? null : FixedPoint => { + // 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) + ) { + return fixedPoint.map((ratio) => + Math.abs(ratio - 0.5) < 0.0001 ? 0.5001 : ratio, + ) as T extends null ? null : FixedPoint; + } + return fixedPoint as any as T extends null ? null : FixedPoint; +}; diff --git a/packages/excalidraw/element/bounds.test.ts b/packages/excalidraw/element/bounds.test.ts new file mode 100644 index 0000000..9d91d09 --- /dev/null +++ b/packages/excalidraw/element/bounds.test.ts @@ -0,0 +1,140 @@ +import type { LocalPoint } from "@excalidraw/math"; +import { pointFrom } from "@excalidraw/math"; +import { ROUNDNESS } from "../constants"; +import { arrayToMap } from "../utils"; +import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; +import type { ExcalidrawElement, ExcalidrawLinearElement } from "./types"; + +const _ce = ({ + x, + y, + w, + h, + a, + t, +}: { + x: number; + y: number; + w: number; + h: number; + a?: number; + t?: string; +}) => + ({ + type: t || "rectangle", + strokeColor: "#000", + backgroundColor: "#000", + fillStyle: "solid", + strokeWidth: 1, + roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, + roughness: 0, + opacity: 1, + x, + y, + width: w, + height: h, + angle: a, + } as ExcalidrawElement); + +describe("getElementAbsoluteCoords", () => { + it("test x1 coordinate", () => { + const element = _ce({ x: 10, y: 20, w: 10, h: 0 }); + const [x1] = getElementAbsoluteCoords(element, arrayToMap([element])); + expect(x1).toEqual(10); + }); + + it("test x2 coordinate", () => { + const element = _ce({ x: 10, y: 20, w: 10, h: 0 }); + const [, , x2] = getElementAbsoluteCoords(element, arrayToMap([element])); + expect(x2).toEqual(20); + }); + + it("test y1 coordinate", () => { + const element = _ce({ x: 0, y: 10, w: 0, h: 10 }); + const [, y1] = getElementAbsoluteCoords(element, arrayToMap([element])); + expect(y1).toEqual(10); + }); + + it("test y2 coordinate", () => { + const element = _ce({ x: 0, y: 10, w: 0, h: 10 }); + const [, , , y2] = getElementAbsoluteCoords(element, arrayToMap([element])); + expect(y2).toEqual(20); + }); +}); + +describe("getElementBounds", () => { + it("rectangle", () => { + const element = _ce({ + x: 40, + y: 30, + w: 20, + h: 10, + a: Math.PI / 4, + t: "rectangle", + }); + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); + expect(x1).toEqual(39.39339828220179); + expect(y1).toEqual(24.393398282201787); + expect(x2).toEqual(60.60660171779821); + expect(y2).toEqual(45.60660171779821); + }); + + it("diamond", () => { + const element = _ce({ + x: 40, + y: 30, + w: 20, + h: 10, + a: Math.PI / 4, + t: "diamond", + }); + + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); + + expect(x1).toEqual(42.928932188134524); + expect(y1).toEqual(27.928932188134524); + expect(x2).toEqual(57.071067811865476); + expect(y2).toEqual(42.071067811865476); + }); + + it("ellipse", () => { + const element = _ce({ + x: 40, + y: 30, + w: 20, + h: 10, + a: Math.PI / 4, + t: "ellipse", + }); + + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); + expect(x1).toEqual(42.09430584957905); + expect(y1).toEqual(27.09430584957905); + expect(x2).toEqual(57.90569415042095); + expect(y2).toEqual(42.90569415042095); + }); + + it("curved line", () => { + const element = { + ..._ce({ + t: "line", + x: 449.58203125, + y: 186.0625, + w: 170.12890625, + h: 92.48828125, + a: 0.6447741904932416, + }), + points: [ + pointFrom<LocalPoint>(0, 0), + pointFrom<LocalPoint>(67.33984375, 92.48828125), + pointFrom<LocalPoint>(-102.7890625, 52.15625), + ], + } as ExcalidrawLinearElement; + + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); + expect(x1).toEqual(360.3176068760539); + expect(y1).toEqual(185.90654264413516); + expect(x2).toEqual(480.87005902729743); + expect(y2).toEqual(320.4751269334226); + }); +}); diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts new file mode 100644 index 0000000..06f9770 --- /dev/null +++ b/packages/excalidraw/element/bounds.ts @@ -0,0 +1,1025 @@ +import type { + ExcalidrawElement, + ExcalidrawLinearElement, + Arrowhead, + ExcalidrawFreeDrawElement, + NonDeleted, + ExcalidrawTextElementWithContainer, + ElementsMap, +} from "./types"; +import rough from "roughjs/bin/rough"; +import type { Point as RoughPoint } from "roughjs/bin/geometry"; +import type { Drawable, Op } from "roughjs/bin/core"; +import type { AppState } from "../types"; +import { generateRoughOptions } from "../scene/Shape"; +import { + isArrowElement, + isBoundToContainer, + isFreeDrawElement, + isLinearElement, + isTextElement, +} from "./typeChecks"; +import { rescalePoints } from "../points"; +import { getBoundTextElement, getContainerElement } from "./textElement"; +import { LinearElementEditor } from "./linearElementEditor"; +import { ShapeCache } from "../scene/ShapeCache"; +import { arrayToMap, invariant } from "../utils"; +import type { + Degrees, + GlobalPoint, + LineSegment, + LocalPoint, + Radians, +} from "@excalidraw/math"; +import { + degreesToRadians, + lineSegment, + pointFrom, + pointDistance, + pointFromArray, + pointRotateRads, +} from "@excalidraw/math"; +import type { Mutable } from "../utility-types"; +import { getCurvePathOps } from "@excalidraw/utils/geometry/shape"; + +export type RectangleBox = { + x: number; + y: number; + width: number; + height: number; + angle: number; +}; + +type MaybeQuadraticSolution = [number | null, number | null] | false; + +/** + * x and y position of top left corner, x and y position of bottom right corner + */ +export type Bounds = readonly [ + minX: number, + minY: number, + maxX: number, + maxY: number, +]; + +export type SceneBounds = readonly [ + sceneX: number, + sceneY: number, + sceneX2: number, + sceneY2: number, +]; + +export class ElementBounds { + private static boundsCache = new WeakMap< + ExcalidrawElement, + { + bounds: Bounds; + version: ExcalidrawElement["version"]; + } + >(); + + static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) { + const cachedBounds = ElementBounds.boundsCache.get(element); + + if ( + cachedBounds?.version && + cachedBounds.version === element.version && + // we don't invalidate cache when we update containers and not labels, + // which is causing problems down the line. Fix TBA. + !isBoundToContainer(element) + ) { + return cachedBounds.bounds; + } + const bounds = ElementBounds.calculateBounds(element, elementsMap); + + ElementBounds.boundsCache.set(element, { + version: element.version, + bounds, + }); + + return bounds; + } + + private static calculateBounds( + element: ExcalidrawElement, + elementsMap: ElementsMap, + ): Bounds { + let bounds: Bounds; + + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); + if (isFreeDrawElement(element)) { + const [minX, minY, maxX, maxY] = getBoundsFromPoints( + element.points.map(([x, y]) => + pointRotateRads( + pointFrom(x, y), + pointFrom(cx - element.x, cy - element.y), + element.angle, + ), + ), + ); + + return [ + minX + element.x, + minY + element.y, + maxX + element.x, + maxY + element.y, + ]; + } else if (isLinearElement(element)) { + bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap); + } else if (element.type === "diamond") { + const [x11, y11] = pointRotateRads( + pointFrom(cx, y1), + pointFrom(cx, cy), + element.angle, + ); + const [x12, y12] = pointRotateRads( + pointFrom(cx, y2), + pointFrom(cx, cy), + element.angle, + ); + const [x22, y22] = pointRotateRads( + pointFrom(x1, cy), + pointFrom(cx, cy), + element.angle, + ); + const [x21, y21] = pointRotateRads( + pointFrom(x2, cy), + pointFrom(cx, cy), + element.angle, + ); + const minX = Math.min(x11, x12, x22, x21); + const minY = Math.min(y11, y12, y22, y21); + const maxX = Math.max(x11, x12, x22, x21); + const maxY = Math.max(y11, y12, y22, y21); + bounds = [minX, minY, maxX, maxY]; + } else if (element.type === "ellipse") { + const w = (x2 - x1) / 2; + const h = (y2 - y1) / 2; + const cos = Math.cos(element.angle); + const sin = Math.sin(element.angle); + const ww = Math.hypot(w * cos, h * sin); + const hh = Math.hypot(h * cos, w * sin); + bounds = [cx - ww, cy - hh, cx + ww, cy + hh]; + } else { + const [x11, y11] = pointRotateRads( + pointFrom(x1, y1), + pointFrom(cx, cy), + element.angle, + ); + const [x12, y12] = pointRotateRads( + pointFrom(x1, y2), + pointFrom(cx, cy), + element.angle, + ); + const [x22, y22] = pointRotateRads( + pointFrom(x2, y2), + pointFrom(cx, cy), + element.angle, + ); + const [x21, y21] = pointRotateRads( + pointFrom(x2, y1), + pointFrom(cx, cy), + element.angle, + ); + const minX = Math.min(x11, x12, x22, x21); + const minY = Math.min(y11, y12, y22, y21); + const maxX = Math.max(x11, x12, x22, x21); + const maxY = Math.max(y11, y12, y22, y21); + bounds = [minX, minY, maxX, maxY]; + } + + return bounds; + } +} + +// Scene -> Scene coords, but in x1,x2,y1,y2 format. +// +// If the element is created from right to left, the width is going to be negative +// This set of functions retrieves the absolute position of the 4 points. +export const getElementAbsoluteCoords = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, + includeBoundText: boolean = false, +): [number, number, number, number, number, number] => { + if (isFreeDrawElement(element)) { + return getFreeDrawElementAbsoluteCoords(element); + } else if (isLinearElement(element)) { + return LinearElementEditor.getElementAbsoluteCoords( + element, + elementsMap, + includeBoundText, + ); + } else if (isTextElement(element)) { + const container = elementsMap + ? getContainerElement(element, elementsMap) + : null; + if (isArrowElement(container)) { + const { x, y } = LinearElementEditor.getBoundTextElementPosition( + container, + element as ExcalidrawTextElementWithContainer, + elementsMap, + ); + return [ + x, + y, + x + element.width, + y + element.height, + x + element.width / 2, + y + element.height / 2, + ]; + } + } + return [ + element.x, + element.y, + element.x + element.width, + element.y + element.height, + element.x + element.width / 2, + element.y + element.height / 2, + ]; +}; + +/* + * for a given element, `getElementLineSegments` returns line segments + * that can be used for visual collision detection (useful for frames) + * as opposed to bounding box collision detection + */ +export const getElementLineSegments = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, +): LineSegment<GlobalPoint>[] => { + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); + + const center: GlobalPoint = pointFrom(cx, cy); + + if (isLinearElement(element) || isFreeDrawElement(element)) { + const segments: LineSegment<GlobalPoint>[] = []; + + let i = 0; + + while (i < element.points.length - 1) { + segments.push( + lineSegment( + pointRotateRads( + pointFrom( + element.points[i][0] + element.x, + element.points[i][1] + element.y, + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + element.points[i + 1][0] + element.x, + element.points[i + 1][1] + element.y, + ), + center, + element.angle, + ), + ), + ); + i++; + } + + return segments; + } + + const [nw, ne, sw, se, n, s, w, e] = ( + [ + [x1, y1], + [x2, y1], + [x1, y2], + [x2, y2], + [cx, y1], + [cx, y2], + [x1, cy], + [x2, cy], + ] as GlobalPoint[] + ).map((point) => pointRotateRads(point, center, element.angle)); + + if (element.type === "diamond") { + return [ + lineSegment(n, w), + lineSegment(n, e), + lineSegment(s, w), + lineSegment(s, e), + ]; + } + + if (element.type === "ellipse") { + return [ + lineSegment(n, w), + lineSegment(n, e), + lineSegment(s, w), + lineSegment(s, e), + lineSegment(n, w), + lineSegment(n, e), + lineSegment(s, w), + lineSegment(s, e), + ]; + } + + return [ + lineSegment(nw, ne), + lineSegment(sw, se), + lineSegment(nw, sw), + lineSegment(ne, se), + lineSegment(nw, e), + lineSegment(sw, e), + lineSegment(ne, w), + lineSegment(se, w), + ]; +}; + +/** + * Scene -> Scene coords, but in x1,x2,y1,y2 format. + * + * Rectangle here means any rectangular frame, not an excalidraw element. + */ +export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => { + return [ + boxSceneCoords.x, + boxSceneCoords.y, + boxSceneCoords.x + boxSceneCoords.width, + boxSceneCoords.y + boxSceneCoords.height, + boxSceneCoords.x + boxSceneCoords.width / 2, + boxSceneCoords.y + boxSceneCoords.height / 2, + ]; +}; + +export const getDiamondPoints = (element: ExcalidrawElement) => { + // Here we add +1 to avoid these numbers to be 0 + // otherwise rough.js will throw an error complaining about it + const topX = Math.floor(element.width / 2) + 1; + const topY = 0; + const rightX = element.width; + const rightY = Math.floor(element.height / 2) + 1; + const bottomX = topX; + const bottomY = element.height; + const leftX = 0; + const leftY = rightY; + + return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY]; +}; + +// reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes +const getBezierValueForT = ( + t: number, + p0: number, + p1: number, + p2: number, + p3: number, +) => { + const oneMinusT = 1 - t; + return ( + Math.pow(oneMinusT, 3) * p0 + + 3 * Math.pow(oneMinusT, 2) * t * p1 + + 3 * oneMinusT * Math.pow(t, 2) * p2 + + Math.pow(t, 3) * p3 + ); +}; + +const solveQuadratic = ( + p0: number, + p1: number, + p2: number, + p3: number, +): MaybeQuadraticSolution => { + const i = p1 - p0; + const j = p2 - p1; + const k = p3 - p2; + + const a = 3 * i - 6 * j + 3 * k; + const b = 6 * j - 6 * i; + const c = 3 * i; + + const sqrtPart = b * b - 4 * a * c; + const hasSolution = sqrtPart >= 0; + + if (!hasSolution) { + return false; + } + + let s1 = null; + let s2 = null; + + let t1 = Infinity; + let t2 = Infinity; + + if (a === 0) { + t1 = t2 = -c / b; + } else { + t1 = (-b + Math.sqrt(sqrtPart)) / (2 * a); + t2 = (-b - Math.sqrt(sqrtPart)) / (2 * a); + } + + if (t1 >= 0 && t1 <= 1) { + s1 = getBezierValueForT(t1, p0, p1, p2, p3); + } + + if (t2 >= 0 && t2 <= 1) { + s2 = getBezierValueForT(t2, p0, p1, p2, p3); + } + + return [s1, s2]; +}; + +const getCubicBezierCurveBound = ( + p0: GlobalPoint, + p1: GlobalPoint, + p2: GlobalPoint, + p3: GlobalPoint, +): Bounds => { + const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]); + const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]); + + let minX = Math.min(p0[0], p3[0]); + let maxX = Math.max(p0[0], p3[0]); + + if (solX) { + const xs = solX.filter((x) => x !== null) as number[]; + minX = Math.min(minX, ...xs); + maxX = Math.max(maxX, ...xs); + } + + let minY = Math.min(p0[1], p3[1]); + let maxY = Math.max(p0[1], p3[1]); + if (solY) { + const ys = solY.filter((y) => y !== null) as number[]; + minY = Math.min(minY, ...ys); + maxY = Math.max(maxY, ...ys); + } + return [minX, minY, maxX, maxY]; +}; + +export const getMinMaxXYFromCurvePathOps = ( + ops: Op[], + transformXY?: (p: GlobalPoint) => GlobalPoint, +): Bounds => { + let currentP: GlobalPoint = pointFrom(0, 0); + + const { minX, minY, maxX, maxY } = ops.reduce( + (limits, { op, data }) => { + // There are only four operation types: + // move, bcurveTo, lineTo, and curveTo + if (op === "move") { + // change starting point + const p: GlobalPoint | undefined = pointFromArray(data); + invariant(p != null, "Op data is not a point"); + currentP = p; + // move operation does not draw anything; so, it always + // returns false + } else if (op === "bcurveTo") { + const _p1 = pointFrom<GlobalPoint>(data[0], data[1]); + const _p2 = pointFrom<GlobalPoint>(data[2], data[3]); + const _p3 = pointFrom<GlobalPoint>(data[4], data[5]); + + const p1 = transformXY ? transformXY(_p1) : _p1; + const p2 = transformXY ? transformXY(_p2) : _p2; + const p3 = transformXY ? transformXY(_p3) : _p3; + + const p0 = transformXY ? transformXY(currentP) : currentP; + currentP = _p3; + + const [minX, minY, maxX, maxY] = getCubicBezierCurveBound( + p0, + p1, + p2, + p3, + ); + + limits.minX = Math.min(limits.minX, minX); + limits.minY = Math.min(limits.minY, minY); + + limits.maxX = Math.max(limits.maxX, maxX); + limits.maxY = Math.max(limits.maxY, maxY); + } else if (op === "lineTo") { + // TODO: Implement this + } else if (op === "qcurveTo") { + // TODO: Implement this + } + return limits; + }, + { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, + ); + return [minX, minY, maxX, maxY]; +}; + +export const getBoundsFromPoints = ( + points: ExcalidrawFreeDrawElement["points"], +): Bounds => { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const [x, y] of points) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + + return [minX, minY, maxX, maxY]; +}; + +const getFreeDrawElementAbsoluteCoords = ( + element: ExcalidrawFreeDrawElement, +): [number, number, number, number, number, number] => { + const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points); + const x1 = minX + element.x; + const y1 = minY + element.y; + const x2 = maxX + element.x; + const y2 = maxY + element.y; + return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2]; +}; + +/** @returns number in pixels */ +export const getArrowheadSize = (arrowhead: Arrowhead): number => { + switch (arrowhead) { + case "arrow": + return 25; + case "diamond": + case "diamond_outline": + return 12; + case "crowfoot_many": + case "crowfoot_one": + case "crowfoot_one_or_many": + return 20; + default: + return 15; + } +}; + +/** @returns number in degrees */ +export const getArrowheadAngle = (arrowhead: Arrowhead): Degrees => { + switch (arrowhead) { + case "bar": + return 90 as Degrees; + case "arrow": + return 20 as Degrees; + default: + return 25 as Degrees; + } +}; + +export const getArrowheadPoints = ( + element: ExcalidrawLinearElement, + shape: Drawable[], + position: "start" | "end", + arrowhead: Arrowhead, +) => { + if (shape.length < 1) { + return null; + } + + const ops = getCurvePathOps(shape[0]); + if (ops.length < 1) { + return null; + } + + // The index of the bCurve operation to examine. + const index = position === "start" ? 1 : ops.length - 1; + + const data = ops[index].data; + + invariant(data.length === 6, "Op data length is not 6"); + + const p3 = pointFrom(data[4], data[5]); + const p2 = pointFrom(data[2], data[3]); + const p1 = pointFrom(data[0], data[1]); + + // We need to find p0 of the bezier curve. + // It is typically the last point of the previous + // curve; it can also be the position of moveTo operation. + const prevOp = ops[index - 1]; + let p0 = pointFrom(0, 0); + if (prevOp.op === "move") { + const p = pointFromArray(prevOp.data); + invariant(p != null, "Op data is not a point"); + p0 = p; + } else if (prevOp.op === "bcurveTo") { + p0 = pointFrom(prevOp.data[4], prevOp.data[5]); + } + + // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 + const equation = (t: number, idx: number) => + Math.pow(1 - t, 3) * p3[idx] + + 3 * t * Math.pow(1 - t, 2) * p2[idx] + + 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + + p0[idx] * Math.pow(t, 3); + + // Ee know the last point of the arrow (or the first, if start arrowhead). + const [x2, y2] = position === "start" ? p0 : p3; + + // By using cubic bezier equation (B(t)) and the given parameters, + // we calculate a point that is closer to the last point. + // The value 0.3 is chosen arbitrarily and it works best for all + // the tested cases. + const [x1, y1] = [equation(0.3, 0), equation(0.3, 1)]; + + // Find the normalized direction vector based on the + // previously calculated points. + const distance = Math.hypot(x2 - x1, y2 - y1); + const nx = (x2 - x1) / distance; + const ny = (y2 - y1) / distance; + + const size = getArrowheadSize(arrowhead); + + let length = 0; + + { + // Length for -> arrows is based on the length of the last section + const [cx, cy] = + position === "end" + ? element.points[element.points.length - 1] + : element.points[0]; + const [px, py] = + element.points.length > 1 + ? position === "end" + ? element.points[element.points.length - 2] + : element.points[1] + : [0, 0]; + + length = Math.hypot(cx - px, cy - py); + } + + // Scale down the arrowhead until we hit a certain size so that it doesn't look weird. + // This value is selected by minimizing a minimum size with the last segment of the arrowhead + const lengthMultiplier = + arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5; + const minSize = Math.min(size, length * lengthMultiplier); + const xs = x2 - nx * minSize; + const ys = y2 - ny * minSize; + + if ( + arrowhead === "dot" || + arrowhead === "circle" || + arrowhead === "circle_outline" + ) { + const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2; + return [x2, y2, diameter]; + } + + const angle = getArrowheadAngle(arrowhead); + + if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") { + // swap (xs, ys) with (x2, y2) + const [x3, y3] = pointRotateRads( + pointFrom(x2, y2), + pointFrom(xs, ys), + degreesToRadians(-angle as Degrees), + ); + const [x4, y4] = pointRotateRads( + pointFrom(x2, y2), + pointFrom(xs, ys), + degreesToRadians(angle), + ); + return [xs, ys, x3, y3, x4, y4]; + } + + // Return points + const [x3, y3] = pointRotateRads( + pointFrom(xs, ys), + pointFrom(x2, y2), + ((-angle * Math.PI) / 180) as Radians, + ); + const [x4, y4] = pointRotateRads( + pointFrom(xs, ys), + pointFrom(x2, y2), + degreesToRadians(angle), + ); + + if (arrowhead === "diamond" || arrowhead === "diamond_outline") { + // point opposite to the arrowhead point + let ox; + let oy; + + if (position === "start") { + const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0]; + + [ox, oy] = pointRotateRads( + pointFrom(x2 + minSize * 2, y2), + pointFrom(x2, y2), + Math.atan2(py - y2, px - x2) as Radians, + ); + } else { + const [px, py] = + element.points.length > 1 + ? element.points[element.points.length - 2] + : [0, 0]; + + [ox, oy] = pointRotateRads( + pointFrom(x2 - minSize * 2, y2), + pointFrom(x2, y2), + Math.atan2(y2 - py, x2 - px) as Radians, + ); + } + + return [x2, y2, x3, y3, ox, oy, x4, y4]; + } + + return [x2, y2, x3, y3, x4, y4]; +}; + +const generateLinearElementShape = ( + element: ExcalidrawLinearElement, +): Drawable => { + const generator = rough.generator(); + const options = generateRoughOptions(element); + + const method = (() => { + if (element.roundness) { + return "curve"; + } + if (options.fill) { + return "polygon"; + } + return "linearPath"; + })(); + + return generator[method]( + element.points as Mutable<LocalPoint>[] as RoughPoint[], + options, + ); +}; + +const getLinearElementRotatedBounds = ( + element: ExcalidrawLinearElement, + cx: number, + cy: number, + elementsMap: ElementsMap, +): Bounds => { + const boundTextElement = getBoundTextElement(element, elementsMap); + + if (element.points.length < 2) { + const [pointX, pointY] = element.points[0]; + const [x, y] = pointRotateRads( + pointFrom(element.x + pointX, element.y + pointY), + pointFrom(cx, cy), + element.angle, + ); + + let coords: Bounds = [x, y, x, y]; + if (boundTextElement) { + const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( + element, + elementsMap, + [x, y, x, y], + boundTextElement, + ); + coords = [ + coordsWithBoundText[0], + coordsWithBoundText[1], + coordsWithBoundText[2], + coordsWithBoundText[3], + ]; + } + return coords; + } + + // first element is always the curve + const cachedShape = ShapeCache.get(element)?.[0]; + const shape = cachedShape ?? generateLinearElementShape(element); + const ops = getCurvePathOps(shape); + const transformXY = ([x, y]: GlobalPoint) => + pointRotateRads<GlobalPoint>( + pointFrom(element.x + x, element.y + y), + pointFrom(cx, cy), + element.angle, + ); + const res = getMinMaxXYFromCurvePathOps(ops, transformXY); + let coords: Bounds = [res[0], res[1], res[2], res[3]]; + if (boundTextElement) { + const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( + element, + elementsMap, + coords, + boundTextElement, + ); + coords = [ + coordsWithBoundText[0], + coordsWithBoundText[1], + coordsWithBoundText[2], + coordsWithBoundText[3], + ]; + } + return coords; +}; + +export const getElementBounds = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, +): Bounds => { + return ElementBounds.getBounds(element, elementsMap); +}; + +export const getCommonBounds = ( + elements: readonly ExcalidrawElement[], + elementsMap?: ElementsMap, +): Bounds => { + if (!elements.length) { + return [0, 0, 0, 0]; + } + + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + + const _elementsMap = elementsMap || arrayToMap(elements); + + elements.forEach((element) => { + const [x1, y1, x2, y2] = getElementBounds(element, _elementsMap); + minX = Math.min(minX, x1); + minY = Math.min(minY, y1); + maxX = Math.max(maxX, x2); + maxY = Math.max(maxY, y2); + }); + + return [minX, minY, maxX, maxY]; +}; + +export const getDraggedElementsBounds = ( + elements: ExcalidrawElement[], + dragOffset: { x: number; y: number }, +) => { + const [minX, minY, maxX, maxY] = getCommonBounds(elements); + return [ + minX + dragOffset.x, + minY + dragOffset.y, + maxX + dragOffset.x, + maxY + dragOffset.y, + ]; +}; + +export const getResizedElementAbsoluteCoords = ( + element: ExcalidrawElement, + nextWidth: number, + nextHeight: number, + normalizePoints: boolean, +): Bounds => { + if (!(isLinearElement(element) || isFreeDrawElement(element))) { + return [ + element.x, + element.y, + element.x + nextWidth, + element.y + nextHeight, + ]; + } + + const points = rescalePoints( + 0, + nextWidth, + rescalePoints(1, nextHeight, element.points, normalizePoints), + normalizePoints, + ); + + let bounds: Bounds; + + if (isFreeDrawElement(element)) { + // Free Draw + bounds = getBoundsFromPoints(points); + } else { + // Line + const gen = rough.generator(); + const curve = !element.roundness + ? gen.linearPath( + points as [number, number][], + generateRoughOptions(element), + ) + : gen.curve(points as [number, number][], generateRoughOptions(element)); + + const ops = getCurvePathOps(curve); + bounds = getMinMaxXYFromCurvePathOps(ops); + } + + const [minX, minY, maxX, maxY] = bounds; + return [ + minX + element.x, + minY + element.y, + maxX + element.x, + maxY + element.y, + ]; +}; + +export const getElementPointsCoords = ( + element: ExcalidrawLinearElement, + points: readonly (readonly [number, number])[], +): Bounds => { + // This might be computationally heavey + const gen = rough.generator(); + const curve = + element.roundness == null + ? gen.linearPath( + points as [number, number][], + generateRoughOptions(element), + ) + : gen.curve(points as [number, number][], generateRoughOptions(element)); + const ops = getCurvePathOps(curve); + const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops); + return [ + minX + element.x, + minY + element.y, + maxX + element.x, + maxY + element.y, + ]; +}; + +export const getClosestElementBounds = ( + elements: readonly ExcalidrawElement[], + from: { x: number; y: number }, +): Bounds => { + if (!elements.length) { + return [0, 0, 0, 0]; + } + + let minDistance = Infinity; + let closestElement = elements[0]; + const elementsMap = arrayToMap(elements); + elements.forEach((element) => { + const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); + const distance = pointDistance( + pointFrom((x1 + x2) / 2, (y1 + y2) / 2), + pointFrom(from.x, from.y), + ); + + if (distance < minDistance) { + minDistance = distance; + closestElement = element; + } + }); + + return getElementBounds(closestElement, elementsMap); +}; + +export interface BoundingBox { + minX: number; + minY: number; + maxX: number; + maxY: number; + midX: number; + midY: number; + width: number; + height: number; +} + +export const getCommonBoundingBox = ( + elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[], +): BoundingBox => { + const [minX, minY, maxX, maxY] = getCommonBounds(elements); + return { + minX, + minY, + maxX, + maxY, + width: maxX - minX, + height: maxY - minY, + midX: (minX + maxX) / 2, + midY: (minY + maxY) / 2, + }; +}; + +/** + * returns scene coords of user's editor viewport (visible canvas area) bounds + */ +export const getVisibleSceneBounds = ({ + scrollX, + scrollY, + width, + height, + zoom, +}: AppState): SceneBounds => { + return [ + -scrollX, + -scrollY, + -scrollX + width / zoom.value, + -scrollY + height / zoom.value, + ]; +}; + +export const getCenterForBounds = (bounds: Bounds): GlobalPoint => + pointFrom( + bounds[0] + (bounds[2] - bounds[0]) / 2, + bounds[1] + (bounds[3] - bounds[1]) / 2, + ); + +export const doBoundsIntersect = ( + bounds1: Bounds | null, + bounds2: Bounds | null, +): boolean => { + if (bounds1 == null || bounds2 == null) { + return false; + } + + const [minX1, minY1, maxX1, maxY1] = bounds1; + const [minX2, minY2, maxX2, maxY2] = bounds2; + + return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2; +}; diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts new file mode 100644 index 0000000..b0a2ce9 --- /dev/null +++ b/packages/excalidraw/element/collision.ts @@ -0,0 +1,312 @@ +import type { + ElementsMap, + ExcalidrawDiamondElement, + ExcalidrawElement, + ExcalidrawEllipseElement, + ExcalidrawRectangleElement, + ExcalidrawRectanguloidElement, +} from "./types"; +import { getElementBounds } from "./bounds"; +import type { FrameNameBounds } from "../types"; +import type { GeometricShape } from "@excalidraw/utils/geometry/shape"; +import { getPolygonShape } from "@excalidraw/utils/geometry/shape"; +import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; +import { isTransparent } from "../utils"; +import { + hasBoundTextElement, + isIframeLikeElement, + isImageElement, + isTextElement, +} from "./typeChecks"; +import { getBoundTextShape, isPathALoop } from "../shapes"; +import type { + GlobalPoint, + LineSegment, + LocalPoint, + Polygon, + Radians, +} from "@excalidraw/math"; +import { + curveIntersectLineSegment, + isPointWithinBounds, + line, + lineSegment, + lineSegmentIntersectionPoints, + pointFrom, + pointRotateRads, + pointsEqual, +} from "@excalidraw/math"; +import { + ellipse, + ellipseLineIntersectionPoints, +} from "@excalidraw/math/ellipse"; +import { + deconstructDiamondElement, + deconstructRectanguloidElement, +} from "./utils"; + +export const shouldTestInside = (element: ExcalidrawElement) => { + if (element.type === "arrow") { + return false; + } + + const isDraggableFromInside = + !isTransparent(element.backgroundColor) || + hasBoundTextElement(element) || + isIframeLikeElement(element) || + isTextElement(element); + + if (element.type === "line") { + return isDraggableFromInside && isPathALoop(element.points); + } + + if (element.type === "freedraw") { + return isDraggableFromInside && isPathALoop(element.points); + } + + return isDraggableFromInside || isImageElement(element); +}; + +export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = { + x: number; + y: number; + element: ExcalidrawElement; + shape: GeometricShape<Point>; + threshold?: number; + frameNameBound?: FrameNameBounds | null; +}; + +export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({ + x, + y, + element, + shape, + threshold = 10, + frameNameBound = null, +}: HitTestArgs<Point>) => { + let hit = shouldTestInside(element) + ? // Since `inShape` tests STRICTLY againt the insides of a shape + // we would need `onShape` as well to include the "borders" + isPointInShape(pointFrom(x, y), shape) || + isPointOnShape(pointFrom(x, y), shape, threshold) + : isPointOnShape(pointFrom(x, y), shape, threshold); + + // hit test against a frame's name + if (!hit && frameNameBound) { + hit = isPointInShape(pointFrom(x, y), { + type: "polygon", + data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) + .data as Polygon<Point>, + }); + } + + return hit; +}; + +export const hitElementBoundingBox = ( + x: number, + y: number, + element: ExcalidrawElement, + elementsMap: ElementsMap, + tolerance = 0, +) => { + let [x1, y1, x2, y2] = getElementBounds(element, elementsMap); + x1 -= tolerance; + y1 -= tolerance; + x2 += tolerance; + y2 += tolerance; + return isPointWithinBounds( + pointFrom(x1, y1), + pointFrom(x, y), + pointFrom(x2, y2), + ); +}; + +export const hitElementBoundingBoxOnly = < + Point extends GlobalPoint | LocalPoint, +>( + hitArgs: HitTestArgs<Point>, + elementsMap: ElementsMap, +) => { + return ( + !hitElementItself(hitArgs) && + // bound text is considered part of the element (even if it's outside the bounding box) + !hitElementBoundText( + hitArgs.x, + hitArgs.y, + getBoundTextShape(hitArgs.element, elementsMap), + ) && + hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap) + ); +}; + +export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>( + x: number, + y: number, + textShape: GeometricShape<Point> | null, +): boolean => { + return !!textShape && isPointInShape(pointFrom(x, y), textShape); +}; + +/** + * Intersect a line with an element for binding test + * + * @param element + * @param line + * @param offset + * @returns + */ +export const intersectElementWithLineSegment = ( + element: ExcalidrawElement, + line: LineSegment<GlobalPoint>, + offset: number = 0, +): GlobalPoint[] => { + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + return intersectRectanguloidWithLineSegment(element, line, offset); + case "diamond": + return intersectDiamondWithLineSegment(element, line, offset); + case "ellipse": + return intersectEllipseWithLineSegment(element, line, offset); + default: + throw new Error(`Unimplemented element type '${element.type}'`); + } +}; + +const intersectRectanguloidWithLineSegment = ( + element: ExcalidrawRectanguloidElement, + l: LineSegment<GlobalPoint>, + offset: number = 0, +): GlobalPoint[] => { + const center = pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height / 2, + ); + // To emulate a rotated rectangle we rotate the point in the inverse angle + // instead. It's all the same distance-wise. + const rotatedA = pointRotateRads<GlobalPoint>( + l[0], + center, + -element.angle as Radians, + ); + const rotatedB = pointRotateRads<GlobalPoint>( + l[1], + center, + -element.angle as Radians, + ); + + // Get the element's building components we can test against + const [sides, corners] = deconstructRectanguloidElement(element, offset); + + return ( + [ + // Test intersection against the sides, keep only the valid + // intersection points and rotate them back to scene space + ...sides + .map((s) => + lineSegmentIntersectionPoints( + lineSegment<GlobalPoint>(rotatedA, rotatedB), + s, + ), + ) + .filter((x) => x != null) + .map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle)), + // Test intersection against the corners which are cubic bezier curves, + // keep only the valid intersection points and rotate them back to scene + // space + ...corners + .flatMap((t) => + curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)), + ) + .filter((i) => i != null) + .map((j) => pointRotateRads(j, center, element.angle)), + ] + // Remove duplicates + .filter( + (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, + ) + ); +}; + +/** + * + * @param element + * @param a + * @param b + * @returns + */ +const intersectDiamondWithLineSegment = ( + element: ExcalidrawDiamondElement, + l: LineSegment<GlobalPoint>, + offset: number = 0, +): GlobalPoint[] => { + const center = pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + // Rotate the point to the inverse direction to simulate the rotated diamond + // points. It's all the same distance-wise. + const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); + const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); + + const [sides, curves] = deconstructDiamondElement(element, offset); + + return ( + [ + ...sides + .map((s) => + lineSegmentIntersectionPoints( + lineSegment<GlobalPoint>(rotatedA, rotatedB), + s, + ), + ) + .filter((p): p is GlobalPoint => p != null) + // Rotate back intersection points + .map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle)), + ...curves + .flatMap((p) => + curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)), + ) + .filter((p) => p != null) + // Rotate back intersection points + .map((p) => pointRotateRads(p, center, element.angle)), + ] + // Remove duplicates + .filter( + (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, + ) + ); +}; + +/** + * + * @param element + * @param a + * @param b + * @returns + */ +const intersectEllipseWithLineSegment = ( + element: ExcalidrawEllipseElement, + l: LineSegment<GlobalPoint>, + offset: number = 0, +): GlobalPoint[] => { + const center = pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); + const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); + + return ellipseLineIntersectionPoints( + ellipse(center, element.width / 2 + offset, element.height / 2 + offset), + line(rotatedA, rotatedB), + ).map((p) => pointRotateRads(p, center, element.angle)); +}; diff --git a/packages/excalidraw/element/containerCache.ts b/packages/excalidraw/element/containerCache.ts new file mode 100644 index 0000000..432cd4e --- /dev/null +++ b/packages/excalidraw/element/containerCache.ts @@ -0,0 +1,33 @@ +import type { ExcalidrawTextContainer } from "./types"; + +export const originalContainerCache: { + [id: ExcalidrawTextContainer["id"]]: + | { + height: ExcalidrawTextContainer["height"]; + } + | undefined; +} = {}; + +export const updateOriginalContainerCache = ( + id: ExcalidrawTextContainer["id"], + height: ExcalidrawTextContainer["height"], +) => { + const data = + originalContainerCache[id] || (originalContainerCache[id] = { height }); + data.height = height; + return data; +}; + +export const resetOriginalContainerCache = ( + id: ExcalidrawTextContainer["id"], +) => { + if (originalContainerCache[id]) { + delete originalContainerCache[id]; + } +}; + +export const getOriginalContainerHeightFromCache = ( + id: ExcalidrawTextContainer["id"], +) => { + return originalContainerCache[id]?.height ?? null; +}; diff --git a/packages/excalidraw/element/cropElement.ts b/packages/excalidraw/element/cropElement.ts new file mode 100644 index 0000000..b109802 --- /dev/null +++ b/packages/excalidraw/element/cropElement.ts @@ -0,0 +1,625 @@ +import { type Point } from "points-on-curve"; +import { + type Radians, + pointFrom, + pointCenter, + pointRotateRads, + vectorFromPoint, + vectorNormalize, + vectorSubtract, + vectorAdd, + vectorScale, + pointFromVector, + clamp, + isCloseTo, +} from "@excalidraw/math"; +import type { TransformHandleType } from "./transformHandles"; +import type { + ElementsMap, + ExcalidrawElement, + ExcalidrawImageElement, + ImageCrop, + NonDeleted, +} from "./types"; +import { + getElementAbsoluteCoords, + getResizedElementAbsoluteCoords, +} from "./bounds"; + +export const MINIMAL_CROP_SIZE = 10; + +export const cropElement = ( + element: ExcalidrawImageElement, + transformHandle: TransformHandleType, + naturalWidth: number, + naturalHeight: number, + pointerX: number, + pointerY: number, + widthAspectRatio?: number, +) => { + const { width: uncroppedWidth, height: uncroppedHeight } = + getUncroppedWidthAndHeight(element); + + const naturalWidthToUncropped = naturalWidth / uncroppedWidth; + const naturalHeightToUncropped = naturalHeight / uncroppedHeight; + + const croppedLeft = (element.crop?.x ?? 0) / naturalWidthToUncropped; + const croppedTop = (element.crop?.y ?? 0) / naturalHeightToUncropped; + + /** + * uncropped width + * *––––––––––––––––––––––––* + * | (x,y) (natural) | + * | *–––––––* | + * | |///////| height | uncropped height + * | *–––––––* | + * | width (natural) | + * *––––––––––––––––––––––––* + */ + + const rotatedPointer = pointRotateRads( + pointFrom(pointerX, pointerY), + pointFrom(element.x + element.width / 2, element.y + element.height / 2), + -element.angle as Radians, + ); + + pointerX = rotatedPointer[0]; + pointerY = rotatedPointer[1]; + + let nextWidth = element.width; + let nextHeight = element.height; + + let crop: ImageCrop | null = element.crop ?? { + x: 0, + y: 0, + width: naturalWidth, + height: naturalHeight, + naturalWidth, + naturalHeight, + }; + + const previousCropHeight = crop.height; + const previousCropWidth = crop.width; + + const isFlippedByX = element.scale[0] === -1; + const isFlippedByY = element.scale[1] === -1; + + let changeInHeight = pointerY - element.y; + let changeInWidth = pointerX - element.x; + + if (transformHandle.includes("n")) { + nextHeight = clamp( + element.height - changeInHeight, + MINIMAL_CROP_SIZE, + isFlippedByY ? uncroppedHeight - croppedTop : element.height + croppedTop, + ); + } + + if (transformHandle.includes("s")) { + changeInHeight = pointerY - element.y - element.height; + nextHeight = clamp( + element.height + changeInHeight, + MINIMAL_CROP_SIZE, + isFlippedByY ? element.height + croppedTop : uncroppedHeight - croppedTop, + ); + } + + if (transformHandle.includes("e")) { + changeInWidth = pointerX - element.x - element.width; + + nextWidth = clamp( + element.width + changeInWidth, + MINIMAL_CROP_SIZE, + isFlippedByX ? element.width + croppedLeft : uncroppedWidth - croppedLeft, + ); + } + + if (transformHandle.includes("w")) { + nextWidth = clamp( + element.width - changeInWidth, + MINIMAL_CROP_SIZE, + isFlippedByX ? uncroppedWidth - croppedLeft : element.width + croppedLeft, + ); + } + + const updateCropWidthAndHeight = (crop: ImageCrop) => { + crop.height = nextHeight * naturalHeightToUncropped; + crop.width = nextWidth * naturalWidthToUncropped; + }; + + updateCropWidthAndHeight(crop); + + const adjustFlipForHandle = ( + handle: TransformHandleType, + crop: ImageCrop, + ) => { + updateCropWidthAndHeight(crop); + if (handle.includes("n")) { + if (!isFlippedByY) { + crop.y += previousCropHeight - crop.height; + } + } + if (handle.includes("s")) { + if (isFlippedByY) { + crop.y += previousCropHeight - crop.height; + } + } + if (handle.includes("e")) { + if (isFlippedByX) { + crop.x += previousCropWidth - crop.width; + } + } + if (handle.includes("w")) { + if (!isFlippedByX) { + crop.x += previousCropWidth - crop.width; + } + } + }; + + switch (transformHandle) { + case "n": { + if (widthAspectRatio) { + const distanceToLeft = croppedLeft + element.width / 2; + const distanceToRight = + uncroppedWidth - croppedLeft - element.width / 2; + + const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2; + + nextWidth = clamp( + nextHeight * widthAspectRatio, + MINIMAL_CROP_SIZE, + MAX_WIDTH, + ); + nextHeight = nextWidth / widthAspectRatio; + } + + adjustFlipForHandle(transformHandle, crop); + + if (widthAspectRatio) { + crop.x += (previousCropWidth - crop.width) / 2; + } + + break; + } + case "s": { + if (widthAspectRatio) { + const distanceToLeft = croppedLeft + element.width / 2; + const distanceToRight = + uncroppedWidth - croppedLeft - element.width / 2; + + const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2; + + nextWidth = clamp( + nextHeight * widthAspectRatio, + MINIMAL_CROP_SIZE, + MAX_WIDTH, + ); + nextHeight = nextWidth / widthAspectRatio; + } + + adjustFlipForHandle(transformHandle, crop); + + if (widthAspectRatio) { + crop.x += (previousCropWidth - crop.width) / 2; + } + + break; + } + case "w": { + if (widthAspectRatio) { + const distanceToTop = croppedTop + element.height / 2; + const distanceToBottom = + uncroppedHeight - croppedTop - element.height / 2; + + const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2; + + nextHeight = clamp( + nextWidth / widthAspectRatio, + MINIMAL_CROP_SIZE, + MAX_HEIGHT, + ); + nextWidth = nextHeight * widthAspectRatio; + } + + adjustFlipForHandle(transformHandle, crop); + + if (widthAspectRatio) { + crop.y += (previousCropHeight - crop.height) / 2; + } + + break; + } + case "e": { + if (widthAspectRatio) { + const distanceToTop = croppedTop + element.height / 2; + const distanceToBottom = + uncroppedHeight - croppedTop - element.height / 2; + + const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2; + + nextHeight = clamp( + nextWidth / widthAspectRatio, + MINIMAL_CROP_SIZE, + MAX_HEIGHT, + ); + nextWidth = nextHeight * widthAspectRatio; + } + + adjustFlipForHandle(transformHandle, crop); + + if (widthAspectRatio) { + crop.y += (previousCropHeight - crop.height) / 2; + } + + break; + } + case "ne": { + if (widthAspectRatio) { + if (changeInWidth > -changeInHeight) { + const MAX_HEIGHT = isFlippedByY + ? uncroppedHeight - croppedTop + : croppedTop + element.height; + + nextHeight = clamp( + nextWidth / widthAspectRatio, + MINIMAL_CROP_SIZE, + MAX_HEIGHT, + ); + nextWidth = nextHeight * widthAspectRatio; + } else { + const MAX_WIDTH = isFlippedByX + ? croppedLeft + element.width + : uncroppedWidth - croppedLeft; + + nextWidth = clamp( + nextHeight * widthAspectRatio, + MINIMAL_CROP_SIZE, + MAX_WIDTH, + ); + nextHeight = nextWidth / widthAspectRatio; + } + } + + adjustFlipForHandle(transformHandle, crop); + break; + } + case "nw": { + if (widthAspectRatio) { + if (changeInWidth < changeInHeight) { + const MAX_HEIGHT = isFlippedByY + ? uncroppedHeight - croppedTop + : croppedTop + element.height; + nextHeight = clamp( + nextWidth / widthAspectRatio, + MINIMAL_CROP_SIZE, + MAX_HEIGHT, + ); + nextWidth = nextHeight * widthAspectRatio; + } else { + const MAX_WIDTH = isFlippedByX + ? uncroppedWidth - croppedLeft + : croppedLeft + element.width; + + nextWidth = clamp( + nextHeight * widthAspectRatio, + MINIMAL_CROP_SIZE, + MAX_WIDTH, + ); + nextHeight = nextWidth / widthAspectRatio; + } + } + + adjustFlipForHandle(transformHandle, crop); + break; + } + case "se": { + if (widthAspectRatio) { + if (changeInWidth > changeInHeight) { + const MAX_HEIGHT = isFlippedByY + ? croppedTop + element.height + : uncroppedHeight - croppedTop; + + nextHeight = clamp( + nextWidth / widthAspectRatio, + MINIMAL_CROP_SIZE, + MAX_HEIGHT, + ); + nextWidth = nextHeight * widthAspectRatio; + } else { + const MAX_WIDTH = isFlippedByX + ? croppedLeft + element.width + : uncroppedWidth - croppedLeft; + + nextWidth = clamp( + nextHeight * widthAspectRatio, + MINIMAL_CROP_SIZE, + MAX_WIDTH, + ); + nextHeight = nextWidth / widthAspectRatio; + } + } + + adjustFlipForHandle(transformHandle, crop); + break; + } + case "sw": { + if (widthAspectRatio) { + if (-changeInWidth > changeInHeight) { + const MAX_HEIGHT = isFlippedByY + ? croppedTop + element.height + : uncroppedHeight - croppedTop; + + nextHeight = clamp( + nextWidth / widthAspectRatio, + MINIMAL_CROP_SIZE, + MAX_HEIGHT, + ); + nextWidth = nextHeight * widthAspectRatio; + } else { + const MAX_WIDTH = isFlippedByX + ? uncroppedWidth - croppedLeft + : croppedLeft + element.width; + + nextWidth = clamp( + nextHeight * widthAspectRatio, + MINIMAL_CROP_SIZE, + MAX_WIDTH, + ); + nextHeight = nextWidth / widthAspectRatio; + } + } + + adjustFlipForHandle(transformHandle, crop); + break; + } + default: + break; + } + + const newOrigin = recomputeOrigin( + element, + transformHandle, + nextWidth, + nextHeight, + !!widthAspectRatio, + ); + + // reset crop to null if we're back to orig size + if ( + isCloseTo(crop.width, crop.naturalWidth) && + isCloseTo(crop.height, crop.naturalHeight) + ) { + crop = null; + } + + return { + x: newOrigin[0], + y: newOrigin[1], + width: nextWidth, + height: nextHeight, + crop, + }; +}; + +const recomputeOrigin = ( + stateAtCropStart: NonDeleted<ExcalidrawElement>, + transformHandle: TransformHandleType, + width: number, + height: number, + shouldMaintainAspectRatio?: boolean, +) => { + const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords( + stateAtCropStart, + stateAtCropStart.width, + stateAtCropStart.height, + true, + ); + const startTopLeft = pointFrom(x1, y1); + const startBottomRight = pointFrom(x2, y2); + const startCenter: any = pointCenter(startTopLeft, startBottomRight); + + const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] = + getResizedElementAbsoluteCoords(stateAtCropStart, width, height, true); + const newBoundsWidth = newBoundsX2 - newBoundsX1; + const newBoundsHeight = newBoundsY2 - newBoundsY1; + + // Calculate new topLeft based on fixed corner during resize + let newTopLeft = [...startTopLeft] as [number, number]; + + if (["n", "w", "nw"].includes(transformHandle)) { + newTopLeft = [ + startBottomRight[0] - Math.abs(newBoundsWidth), + startBottomRight[1] - Math.abs(newBoundsHeight), + ]; + } + if (transformHandle === "ne") { + const bottomLeft = [startTopLeft[0], startBottomRight[1]]; + newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)]; + } + if (transformHandle === "sw") { + const topRight = [startBottomRight[0], startTopLeft[1]]; + newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]]; + } + + if (shouldMaintainAspectRatio) { + if (["s", "n"].includes(transformHandle)) { + newTopLeft[0] = startCenter[0] - newBoundsWidth / 2; + } + if (["e", "w"].includes(transformHandle)) { + newTopLeft[1] = startCenter[1] - newBoundsHeight / 2; + } + } + + // adjust topLeft to new rotation point + const angle = stateAtCropStart.angle; + const rotatedTopLeft = pointRotateRads(newTopLeft, startCenter, angle); + const newCenter: Point = [ + newTopLeft[0] + Math.abs(newBoundsWidth) / 2, + newTopLeft[1] + Math.abs(newBoundsHeight) / 2, + ]; + const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle); + newTopLeft = pointRotateRads( + rotatedTopLeft, + rotatedNewCenter, + -angle as Radians, + ); + + const newOrigin = [...newTopLeft]; + newOrigin[0] += stateAtCropStart.x - newBoundsX1; + newOrigin[1] += stateAtCropStart.y - newBoundsY1; + + return newOrigin; +}; + +// refer to https://link.excalidraw.com/l/6rfy1007QOo/6stx5PmRn0k +export const getUncroppedImageElement = ( + element: ExcalidrawImageElement, + elementsMap: ElementsMap, +) => { + if (element.crop) { + const { width, height } = getUncroppedWidthAndHeight(element); + + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); + + const topLeftVector = vectorFromPoint( + pointRotateRads(pointFrom(x1, y1), pointFrom(cx, cy), element.angle), + ); + const topRightVector = vectorFromPoint( + pointRotateRads(pointFrom(x2, y1), pointFrom(cx, cy), element.angle), + ); + const topEdgeNormalized = vectorNormalize( + vectorSubtract(topRightVector, topLeftVector), + ); + const bottomLeftVector = vectorFromPoint( + pointRotateRads(pointFrom(x1, y2), pointFrom(cx, cy), element.angle), + ); + const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector); + const leftEdgeNormalized = vectorNormalize(leftEdgeVector); + + const { cropX, cropY } = adjustCropPosition(element.crop, element.scale); + + const rotatedTopLeft = vectorAdd( + vectorAdd( + topLeftVector, + vectorScale( + topEdgeNormalized, + (-cropX * width) / element.crop.naturalWidth, + ), + ), + vectorScale( + leftEdgeNormalized, + (-cropY * height) / element.crop.naturalHeight, + ), + ); + + const center = pointFromVector( + vectorAdd( + vectorAdd(rotatedTopLeft, vectorScale(topEdgeNormalized, width / 2)), + vectorScale(leftEdgeNormalized, height / 2), + ), + ); + + const unrotatedTopLeft = pointRotateRads( + pointFromVector(rotatedTopLeft), + center, + -element.angle as Radians, + ); + + const uncroppedElement: ExcalidrawImageElement = { + ...element, + x: unrotatedTopLeft[0], + y: unrotatedTopLeft[1], + width, + height, + crop: null, + }; + + return uncroppedElement; + } + + return element; +}; + +export const getUncroppedWidthAndHeight = (element: ExcalidrawImageElement) => { + if (element.crop) { + const width = + element.width / (element.crop.width / element.crop.naturalWidth); + const height = + element.height / (element.crop.height / element.crop.naturalHeight); + + return { + width, + height, + }; + } + + return { + width: element.width, + height: element.height, + }; +}; + +const adjustCropPosition = ( + crop: ImageCrop, + scale: ExcalidrawImageElement["scale"], +) => { + let cropX = crop.x; + let cropY = crop.y; + + const flipX = scale[0] === -1; + const flipY = scale[1] === -1; + + if (flipX) { + cropX = crop.naturalWidth - Math.abs(cropX) - crop.width; + } + + if (flipY) { + cropY = crop.naturalHeight - Math.abs(cropY) - crop.height; + } + + return { + cropX, + cropY, + }; +}; + +export const getFlipAdjustedCropPosition = ( + element: ExcalidrawImageElement, + natural = false, +) => { + const crop = element.crop; + if (!crop) { + return null; + } + + const isFlippedByX = element.scale[0] === -1; + const isFlippedByY = element.scale[1] === -1; + + let cropX = crop.x; + let cropY = crop.y; + + if (isFlippedByX) { + cropX = crop.naturalWidth - crop.width - crop.x; + } + + if (isFlippedByY) { + cropY = crop.naturalHeight - crop.height - crop.y; + } + + if (natural) { + return { + x: cropX, + y: cropY, + }; + } + + const { width, height } = getUncroppedWidthAndHeight(element); + + return { + x: cropX / (crop.naturalWidth / width), + y: cropY / (crop.naturalHeight / height), + }; +}; diff --git a/packages/excalidraw/element/distance.ts b/packages/excalidraw/element/distance.ts new file mode 100644 index 0000000..0010ab9 --- /dev/null +++ b/packages/excalidraw/element/distance.ts @@ -0,0 +1,123 @@ +import type { GlobalPoint, Radians } from "@excalidraw/math"; +import { + curvePointDistance, + distanceToLineSegment, + pointFrom, + pointRotateRads, +} from "@excalidraw/math"; +import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse"; +import type { + ExcalidrawBindableElement, + ExcalidrawDiamondElement, + ExcalidrawEllipseElement, + ExcalidrawRectanguloidElement, +} from "./types"; +import { + deconstructDiamondElement, + deconstructRectanguloidElement, +} from "./utils"; + +export const distanceToBindableElement = ( + element: ExcalidrawBindableElement, + p: GlobalPoint, +): number => { + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + return distanceToRectanguloidElement(element, p); + case "diamond": + return distanceToDiamondElement(element, p); + case "ellipse": + return distanceToEllipseElement(element, p); + } +}; + +/** + * Returns the distance of a point and the provided rectangular-shaped element, + * accounting for roundness and rotation + * + * @param element The rectanguloid element + * @param p The point to consider + * @returns The eucledian distance to the outline of the rectanguloid element + */ +const distanceToRectanguloidElement = ( + element: ExcalidrawRectanguloidElement, + p: GlobalPoint, +) => { + const center = pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height / 2, + ); + // To emulate a rotated rectangle we rotate the point in the inverse angle + // instead. It's all the same distance-wise. + const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); + + // Get the element's building components we can test against + const [sides, corners] = deconstructRectanguloidElement(element); + + return Math.min( + ...sides.map((s) => distanceToLineSegment(rotatedPoint, s)), + ...corners + .map((a) => curvePointDistance(a, rotatedPoint)) + .filter((d): d is number => d !== null), + ); +}; + +/** + * Returns the distance of a point and the provided diamond element, accounting + * for roundness and rotation + * + * @param element The diamond element + * @param p The point to consider + * @returns The eucledian distance to the outline of the diamond + */ +const distanceToDiamondElement = ( + element: ExcalidrawDiamondElement, + p: GlobalPoint, +): number => { + const center = pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + // Rotate the point to the inverse direction to simulate the rotated diamond + // points. It's all the same distance-wise. + const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); + + const [sides, curves] = deconstructDiamondElement(element); + + return Math.min( + ...sides.map((s) => distanceToLineSegment(rotatedPoint, s)), + ...curves + .map((a) => curvePointDistance(a, rotatedPoint)) + .filter((d): d is number => d !== null), + ); +}; + +/** + * Returns the distance of a point and the provided ellipse element, accounting + * for roundness and rotation + * + * @param element The ellipse element + * @param p The point to consider + * @returns The eucledian distance to the outline of the ellipse + */ +const distanceToEllipseElement = ( + element: ExcalidrawEllipseElement, + p: GlobalPoint, +): number => { + const center = pointFrom( + element.x + element.width / 2, + element.y + element.height / 2, + ); + return ellipseDistanceFromPoint( + // Instead of rotating the ellipse, rotate the point to the inverse angle + pointRotateRads(p, center, -element.angle as Radians), + ellipse(center, element.width / 2, element.height / 2), + ); +}; diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts new file mode 100644 index 0000000..bb8fd23 --- /dev/null +++ b/packages/excalidraw/element/dragElements.ts @@ -0,0 +1,286 @@ +import { updateBoundElements } from "./binding"; +import type { Bounds } from "./bounds"; +import { getCommonBounds } from "./bounds"; +import { mutateElement } from "./mutateElement"; +import { getPerfectElementSize } from "./sizeHelpers"; +import type { NonDeletedExcalidrawElement } from "./types"; +import type { + AppState, + NormalizedZoomValue, + NullableGridSize, + PointerDownState, +} from "../types"; +import { getBoundTextElement } from "./textElement"; +import type Scene from "../scene/Scene"; +import { + isArrowElement, + isElbowArrow, + isFrameLikeElement, + isImageElement, + isTextElement, +} from "./typeChecks"; +import { getFontString } from "../utils"; +import { TEXT_AUTOWRAP_THRESHOLD } from "../constants"; +import { getGridPoint } from "../snapping"; +import { getMinTextElementWidth } from "./textMeasurements"; + +export const dragSelectedElements = ( + pointerDownState: PointerDownState, + _selectedElements: NonDeletedExcalidrawElement[], + offset: { x: number; y: number }, + scene: Scene, + snapOffset: { + x: number; + y: number; + }, + gridSize: NullableGridSize, +) => { + if ( + _selectedElements.length === 1 && + isElbowArrow(_selectedElements[0]) && + (_selectedElements[0].startBinding || _selectedElements[0].endBinding) + ) { + return; + } + + const selectedElements = _selectedElements.filter((element) => { + if (isElbowArrow(element) && element.startBinding && element.endBinding) { + const startElement = _selectedElements.find( + (el) => el.id === element.startBinding?.elementId, + ); + const endElement = _selectedElements.find( + (el) => el.id === element.endBinding?.elementId, + ); + + return startElement && endElement; + } + + return true; + }); + + // we do not want a frame and its elements to be selected at the same time + // but when it happens (due to some bug), we want to avoid updating element + // in the frame twice, hence the use of set + const elementsToUpdate = new Set<NonDeletedExcalidrawElement>( + selectedElements, + ); + const frames = selectedElements + .filter((e) => isFrameLikeElement(e)) + .map((f) => f.id); + + if (frames.length > 0) { + for (const element of scene.getNonDeletedElements()) { + if (element.frameId !== null && frames.includes(element.frameId)) { + elementsToUpdate.add(element); + } + } + } + + const commonBounds = getCommonBounds( + Array.from(elementsToUpdate).map( + (el) => pointerDownState.originalElements.get(el.id) ?? el, + ), + ); + const adjustedOffset = calculateOffset( + commonBounds, + offset, + snapOffset, + gridSize, + ); + + elementsToUpdate.forEach((element) => { + updateElementCoords(pointerDownState, element, adjustedOffset); + if (!isArrowElement(element)) { + // skip arrow labels since we calculate its position during render + const textElement = getBoundTextElement( + element, + scene.getNonDeletedElementsMap(), + ); + if (textElement) { + updateElementCoords(pointerDownState, textElement, adjustedOffset); + } + updateBoundElements(element, scene.getElementsMapIncludingDeleted(), { + simultaneouslyUpdated: Array.from(elementsToUpdate), + }); + } + }); +}; + +const calculateOffset = ( + commonBounds: Bounds, + dragOffset: { x: number; y: number }, + snapOffset: { x: number; y: number }, + gridSize: NullableGridSize, +): { x: number; y: number } => { + const [x, y] = commonBounds; + let nextX = x + dragOffset.x + snapOffset.x; + let nextY = y + dragOffset.y + snapOffset.y; + + if (snapOffset.x === 0 || snapOffset.y === 0) { + const [nextGridX, nextGridY] = getGridPoint( + x + dragOffset.x, + y + dragOffset.y, + gridSize, + ); + + if (snapOffset.x === 0) { + nextX = nextGridX; + } + + if (snapOffset.y === 0) { + nextY = nextGridY; + } + } + return { + x: nextX - x, + y: nextY - y, + }; +}; + +const updateElementCoords = ( + pointerDownState: PointerDownState, + element: NonDeletedExcalidrawElement, + dragOffset: { x: number; y: number }, +) => { + const originalElement = + pointerDownState.originalElements.get(element.id) ?? element; + + const nextX = originalElement.x + dragOffset.x; + const nextY = originalElement.y + dragOffset.y; + + mutateElement(element, { + x: nextX, + y: nextY, + }); +}; + +export const getDragOffsetXY = ( + selectedElements: NonDeletedExcalidrawElement[], + x: number, + y: number, +): [number, number] => { + const [x1, y1] = getCommonBounds(selectedElements); + return [x - x1, y - y1]; +}; + +export const dragNewElement = ({ + newElement, + elementType, + originX, + originY, + x, + y, + width, + height, + shouldMaintainAspectRatio, + shouldResizeFromCenter, + zoom, + widthAspectRatio = null, + originOffset = null, + informMutation = true, +}: { + newElement: NonDeletedExcalidrawElement; + elementType: AppState["activeTool"]["type"]; + originX: number; + originY: number; + x: number; + y: number; + width: number; + height: number; + shouldMaintainAspectRatio: boolean; + shouldResizeFromCenter: boolean; + zoom: NormalizedZoomValue; + /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is + true */ + widthAspectRatio?: number | null; + originOffset?: { + x: number; + y: number; + } | null; + informMutation?: boolean; +}) => { + if (shouldMaintainAspectRatio && newElement.type !== "selection") { + if (widthAspectRatio) { + height = width / widthAspectRatio; + } else { + // Depending on where the cursor is at (x, y) relative to where the starting point is + // (originX, originY), we use ONLY width or height to control size increase. + // This allows the cursor to always "stick" to one of the sides of the bounding box. + if (Math.abs(y - originY) > Math.abs(x - originX)) { + ({ width, height } = getPerfectElementSize( + elementType, + height, + x < originX ? -width : width, + )); + } else { + ({ width, height } = getPerfectElementSize( + elementType, + width, + y < originY ? -height : height, + )); + } + + if (height < 0) { + height = -height; + } + } + } + + let newX = x < originX ? originX - width : originX; + let newY = y < originY ? originY - height : originY; + + if (shouldResizeFromCenter) { + width += width; + height += height; + newX = originX - width / 2; + newY = originY - height / 2; + } + + let textAutoResize = null; + + if (isTextElement(newElement)) { + height = newElement.height; + const minWidth = getMinTextElementWidth( + getFontString({ + fontSize: newElement.fontSize, + fontFamily: newElement.fontFamily, + }), + newElement.lineHeight, + ); + width = Math.max(width, minWidth); + + if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) { + textAutoResize = { + autoResize: false, + }; + } + + newY = originY; + if (shouldResizeFromCenter) { + newX = originX - width / 2; + } + } + + if (width !== 0 && height !== 0) { + let imageInitialDimension = null; + if (isImageElement(newElement)) { + imageInitialDimension = { + initialWidth: width, + initialHeight: height, + }; + } + + mutateElement( + newElement, + { + x: newX + (originOffset?.x ?? 0), + y: newY + (originOffset?.y ?? 0), + width, + height, + ...textAutoResize, + ...imageInitialDimension, + }, + informMutation, + ); + } +}; diff --git a/packages/excalidraw/element/elbowArrow.test.tsx b/packages/excalidraw/element/elbowArrow.test.tsx new file mode 100644 index 0000000..c00eae9 --- /dev/null +++ b/packages/excalidraw/element/elbowArrow.test.tsx @@ -0,0 +1,408 @@ +import React from "react"; +import Scene from "../scene/Scene"; +import { API } from "../tests/helpers/api"; +import { Pointer, UI } from "../tests/helpers/ui"; +import { + act, + fireEvent, + GlobalTestState, + queryByTestId, + render, +} from "../tests/test-utils"; +import { bindLinearElement } from "./binding"; +import { Excalidraw, mutateElement } from "../index"; +import type { + ExcalidrawArrowElement, + ExcalidrawBindableElement, + ExcalidrawElbowArrowElement, +} from "./types"; +import { ARROW_TYPE } from "../constants"; +import "../../utils/test-utils"; +import type { LocalPoint } from "@excalidraw/math"; +import { pointFrom } from "@excalidraw/math"; +import { actionDuplicateSelection } from "../actions/actionDuplicateSelection"; +import { actionSelectAll } from "../actions"; + +const { h } = window; + +const mouse = new Pointer("mouse"); + +describe("elbow arrow segment move", () => { + beforeEach(async () => { + localStorage.clear(); + await render(<Excalidraw handleKeyboardGlobally={true} />); + }); + + it("can move the second segment of a fully connected elbow arrow", () => { + UI.createElement("rectangle", { + x: -100, + y: -50, + width: 100, + height: 100, + }); + UI.createElement("rectangle", { + x: 200, + y: 150, + width: 100, + height: 100, + }); + + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + + mouse.reset(); + mouse.moveTo(0, 0); + mouse.click(); + mouse.moveTo(200, 200); + mouse.click(); + + mouse.reset(); + mouse.moveTo(100, 100); + mouse.down(); + mouse.moveTo(115, 100); + mouse.up(); + + const arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawElbowArrowElement; + + expect(h.state.selectedElementIds).toEqual({ [arrow.id]: true }); + expect(arrow.fixedSegments?.length).toBe(1); + + expect(arrow.points).toCloselyEqualPoints([ + [0, 0], + [110, 0], + [110, 200], + [190, 200], + ]); + + mouse.reset(); + mouse.moveTo(105, 74.275); + mouse.doubleClick(); + + expect(arrow.points).toCloselyEqualPoints([ + [0, 0], + [110, 0], + [110, 200], + [190, 200], + ]); + }); + + it("can move the second segment of an unconnected elbow arrow", () => { + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + + mouse.reset(); + mouse.moveTo(0, 0); + mouse.click(); + mouse.moveTo(250, 200); + mouse.click(); + + mouse.reset(); + mouse.moveTo(125, 100); + mouse.down(); + mouse.moveTo(130, 100); + mouse.up(); + + const arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawArrowElement; + + expect(arrow.points).toCloselyEqualPoints([ + [0, 0], + [130, 0], + [130, 200], + [250, 200], + ]); + + mouse.reset(); + mouse.moveTo(130, 100); + mouse.doubleClick(); + + expect(arrow.points).toCloselyEqualPoints([ + [0, 0], + [125, 0], + [125, 200], + [250, 200], + ]); + }); +}); + +describe("elbow arrow routing", () => { + it("can properly generate orthogonal arrow points", () => { + const scene = new Scene(); + const arrow = API.createElement({ + type: "arrow", + elbowed: true, + }) as ExcalidrawElbowArrowElement; + scene.insertElement(arrow); + mutateElement(arrow, { + points: [ + pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y), + pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y), + ], + }); + expect(arrow.points).toEqual([ + [0, 0], + [0, 100], + [90, 100], + [90, 200], + ]); + expect(arrow.x).toEqual(-45); + expect(arrow.y).toEqual(-100.1); + expect(arrow.width).toEqual(90); + expect(arrow.height).toEqual(200); + }); + it("can generate proper points for bound elbow arrow", () => { + const scene = new Scene(); + const rectangle1 = API.createElement({ + type: "rectangle", + x: -150, + y: -150, + width: 100, + height: 100, + }) as ExcalidrawBindableElement; + const rectangle2 = API.createElement({ + type: "rectangle", + x: 50, + y: 50, + width: 100, + height: 100, + }) as ExcalidrawBindableElement; + const arrow = API.createElement({ + type: "arrow", + elbowed: true, + x: -45, + y: -100.1, + width: 90, + height: 200, + points: [pointFrom(0, 0), pointFrom(90, 200)], + }) as ExcalidrawElbowArrowElement; + scene.insertElement(rectangle1); + scene.insertElement(rectangle2); + scene.insertElement(arrow); + const elementsMap = scene.getNonDeletedElementsMap(); + bindLinearElement(arrow, rectangle1, "start", elementsMap); + bindLinearElement(arrow, rectangle2, "end", elementsMap); + + expect(arrow.startBinding).not.toBe(null); + expect(arrow.endBinding).not.toBe(null); + + mutateElement(arrow, { + points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)], + }); + + expect(arrow.points).toEqual([ + [0, 0], + [45, 0], + [45, 200], + [90, 200], + ]); + }); +}); + +describe("elbow arrow ui", () => { + beforeEach(async () => { + localStorage.clear(); + await render(<Excalidraw handleKeyboardGlobally={true} />); + + fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { + button: 2, + clientX: 1, + clientY: 1, + }); + const contextMenu = UI.queryContextMenu(); + fireEvent.click(queryByTestId(contextMenu!, "stats")!); + }); + + it("can follow bound shapes", async () => { + UI.createElement("rectangle", { + x: -150, + y: -150, + width: 100, + height: 100, + }); + UI.createElement("rectangle", { + x: 50, + y: 50, + width: 100, + height: 100, + }); + + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + + expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow); + + mouse.reset(); + mouse.moveTo(-43, -99); + mouse.click(); + mouse.moveTo(43, 99); + mouse.click(); + + const arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawArrowElement; + + expect(arrow.type).toBe("arrow"); + expect(arrow.elbowed).toBe(true); + expect(arrow.points).toEqual([ + [0, 0], + [45, 0], + [45, 200], + [90, 200], + ]); + }); + + it("can follow bound rotated shapes", async () => { + UI.createElement("rectangle", { + x: -150, + y: -150, + width: 100, + height: 100, + }); + UI.createElement("rectangle", { + x: 50, + y: 50, + width: 100, + height: 100, + }); + + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + + mouse.reset(); + mouse.moveTo(-43, -99); + mouse.click(); + mouse.moveTo(43, 99); + mouse.click(); + + const arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawArrowElement; + + mouse.click(51, 51); + + const inputAngle = UI.queryStatsProperty("A")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + UI.updateInput(inputAngle, String("40")); + + expect(arrow.points.map((point) => point.map(Math.round))).toEqual([ + [0, 0], + [35, 0], + [35, 165], + [103, 165], + ]); + }); + + it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => { + UI.createElement("rectangle", { + x: -150, + y: -150, + width: 100, + height: 100, + }); + UI.createElement("rectangle", { + x: 50, + y: 50, + width: 100, + height: 100, + }); + + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + + mouse.reset(); + mouse.moveTo(-43, -99); + mouse.click(); + mouse.moveTo(43, 99); + mouse.click(); + + const arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawArrowElement; + const originalArrowId = arrow.id; + + expect(arrow.startBinding).not.toBe(null); + expect(arrow.endBinding).not.toBe(null); + + act(() => { + h.app.actionManager.executeAction(actionSelectAll); + }); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + expect(h.elements.length).toEqual(6); + + const duplicatedArrow = h.scene.getSelectedElements( + h.state, + )[2] as ExcalidrawArrowElement; + + expect(duplicatedArrow.id).not.toBe(originalArrowId); + expect(duplicatedArrow.type).toBe("arrow"); + expect(duplicatedArrow.elbowed).toBe(true); + expect(duplicatedArrow.points).toEqual([ + [0, 0], + [45, 0], + [45, 200], + [90, 200], + ]); + expect(arrow.startBinding).not.toBe(null); + expect(arrow.endBinding).not.toBe(null); + }); + + it("keeps arrow shape when only the bound arrow is duplicated", async () => { + UI.createElement("rectangle", { + x: -150, + y: -150, + width: 100, + height: 100, + }); + UI.createElement("rectangle", { + x: 50, + y: 50, + width: 100, + height: 100, + }); + + UI.clickTool("arrow"); + UI.clickOnTestId("elbow-arrow"); + + mouse.reset(); + mouse.moveTo(-43, -99); + mouse.click(); + mouse.moveTo(43, 99); + mouse.click(); + + const arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawArrowElement; + const originalArrowId = arrow.id; + + expect(arrow.startBinding).not.toBe(null); + expect(arrow.endBinding).not.toBe(null); + + act(() => { + h.app.actionManager.executeAction(actionDuplicateSelection); + }); + + expect(h.elements.length).toEqual(4); + + const duplicatedArrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawArrowElement; + + expect(duplicatedArrow.id).not.toBe(originalArrowId); + expect(duplicatedArrow.type).toBe("arrow"); + expect(duplicatedArrow.elbowed).toBe(true); + expect(duplicatedArrow.points).toEqual([ + [0, 0], + [45, 0], + [45, 200], + [90, 200], + ]); + }); +}); diff --git a/packages/excalidraw/element/elbowArrow.ts b/packages/excalidraw/element/elbowArrow.ts new file mode 100644 index 0000000..ce8c3d5 --- /dev/null +++ b/packages/excalidraw/element/elbowArrow.ts @@ -0,0 +1,2280 @@ +import { + clamp, + pointDistance, + pointFrom, + pointScaleFromOrigin, + pointsEqual, + pointTranslate, + vector, + vectorCross, + vectorFromPoint, + vectorScale, + type GlobalPoint, + type LocalPoint, +} from "@excalidraw/math"; +import BinaryHeap from "../binaryheap"; +import { getSizeFromPoints } from "../points"; +import { aabbForElement, pointInsideBounds } from "../shapes"; +import { invariant, isAnyTrue, tupleToCoors } from "../utils"; +import type { AppState } from "../types"; +import { + bindPointToSnapToElementOutline, + FIXED_BINDING_DISTANCE, + getHeadingForElbowArrowSnap, + getGlobalFixedPointForBindableElement, + snapToMid, + getHoveredElementForBinding, +} from "./binding"; +import type { Bounds } from "./bounds"; +import type { Heading } from "./heading"; +import { + compareHeading, + flipHeading, + HEADING_DOWN, + HEADING_LEFT, + HEADING_RIGHT, + HEADING_UP, + headingForPointIsHorizontal, + headingIsHorizontal, + vectorToHeading, + headingForPoint, +} from "./heading"; +import { type ElementUpdate } from "./mutateElement"; +import { isBindableElement } from "./typeChecks"; +import { + type ExcalidrawElbowArrowElement, + type NonDeletedSceneElementsMap, + type SceneElementsMap, +} from "./types"; +import type { + Arrowhead, + ElementsMap, + ExcalidrawBindableElement, + FixedPointBinding, + FixedSegment, + NonDeletedExcalidrawElement, +} from "./types"; +import { distanceToBindableElement } from "./distance"; + +type GridAddress = [number, number] & { _brand: "gridaddress" }; + +type Node = { + f: number; + g: number; + h: number; + closed: boolean; + visited: boolean; + parent: Node | null; + pos: GlobalPoint; + addr: GridAddress; +}; + +type Grid = { + row: number; + col: number; + data: (Node | null)[]; +}; + +type ElbowArrowState = { + x: number; + y: number; + startBinding: FixedPointBinding | null; + endBinding: FixedPointBinding | null; + startArrowhead: Arrowhead | null; + endArrowhead: Arrowhead | null; +}; + +type ElbowArrowData = { + dynamicAABBs: Bounds[]; + startDonglePosition: GlobalPoint | null; + startGlobalPoint: GlobalPoint; + startHeading: Heading; + endDonglePosition: GlobalPoint | null; + endGlobalPoint: GlobalPoint; + endHeading: Heading; + commonBounds: Bounds; + hoveredStartElement: ExcalidrawBindableElement | null; + hoveredEndElement: ExcalidrawBindableElement | null; +}; + +const DEDUP_TRESHOLD = 1; +export const BASE_PADDING = 40; + +const handleSegmentRenormalization = ( + arrow: ExcalidrawElbowArrowElement, + elementsMap: NonDeletedSceneElementsMap, +) => { + const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments + ? arrow.fixedSegments.slice() + : null; + + if (nextFixedSegments) { + const _nextPoints: GlobalPoint[] = []; + + arrow.points + .map((p) => pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1])) + .forEach((p, i, points) => { + if (i < 2) { + return _nextPoints.push(p); + } + + const currentSegmentIsHorizontal = headingForPoint(p, points[i - 1]); + const prevSegmentIsHorizontal = headingForPoint( + points[i - 1], + points[i - 2], + ); + + if ( + // Check if previous two points are on the same line + compareHeading(currentSegmentIsHorizontal, prevSegmentIsHorizontal) + ) { + const prevSegmentIdx = + nextFixedSegments?.findIndex( + (segment) => segment.index === i - 1, + ) ?? -1; + const segmentIdx = + nextFixedSegments?.findIndex((segment) => segment.index === i) ?? + -1; + + // If the current segment is a fixed segment, update its start point + if (segmentIdx !== -1) { + nextFixedSegments[segmentIdx].start = pointFrom<LocalPoint>( + points[i - 2][0] - arrow.x, + points[i - 2][1] - arrow.y, + ); + } + + // Remove the fixed segment status from the previous segment if it is + // a fixed segment, because we are going to unify that segment with + // the current one + if (prevSegmentIdx !== -1) { + nextFixedSegments.splice(prevSegmentIdx, 1); + } + + // Remove the duplicate point + _nextPoints.splice(-1, 1); + + // Update fixed point indices + nextFixedSegments.forEach((segment) => { + if (segment.index > i - 1) { + segment.index -= 1; + } + }); + } + + return _nextPoints.push(p); + }); + + const nextPoints: GlobalPoint[] = []; + + _nextPoints.forEach((p, i, points) => { + if (i < 3) { + return nextPoints.push(p); + } + + if ( + // Remove segments that are too short + pointDistance(points[i - 2], points[i - 1]) < DEDUP_TRESHOLD + ) { + const prevPrevSegmentIdx = + nextFixedSegments?.findIndex((segment) => segment.index === i - 2) ?? + -1; + const prevSegmentIdx = + nextFixedSegments?.findIndex((segment) => segment.index === i - 1) ?? + -1; + + // Remove the previous fixed segment if it exists (i.e. the segment + // which will be removed due to being parallel or too short) + if (prevSegmentIdx !== -1) { + nextFixedSegments.splice(prevSegmentIdx, 1); + } + + // Remove the fixed segment status from the segment 2 steps back + // if it is a fixed segment, because we are going to unify that + // segment with the current one + if (prevPrevSegmentIdx !== -1) { + nextFixedSegments.splice(prevPrevSegmentIdx, 1); + } + + nextPoints.splice(-2, 2); + + // Since we have to remove two segments, update any fixed segment + nextFixedSegments.forEach((segment) => { + if (segment.index > i - 2) { + segment.index -= 2; + } + }); + + // Remove aligned segment points + const isHorizontal = headingForPointIsHorizontal(p, points[i - 1]); + + return nextPoints.push( + pointFrom<GlobalPoint>( + !isHorizontal ? points[i - 2][0] : p[0], + isHorizontal ? points[i - 2][1] : p[1], + ), + ); + } + + nextPoints.push(p); + }); + + const filteredNextFixedSegments = nextFixedSegments.filter( + (segment) => + segment.index !== 1 && segment.index !== nextPoints.length - 1, + ); + if (filteredNextFixedSegments.length === 0) { + return normalizeArrowElementUpdate( + getElbowArrowCornerPoints( + removeElbowArrowShortSegments( + routeElbowArrow( + arrow, + getElbowArrowData( + arrow, + elementsMap, + nextPoints.map((p) => + pointFrom<LocalPoint>(p[0] - arrow.x, p[1] - arrow.y), + ), + arrow.startBinding && + getBindableElementForId( + arrow.startBinding.elementId, + elementsMap, + ), + arrow.endBinding && + getBindableElementForId( + arrow.endBinding.elementId, + elementsMap, + ), + ), + ) ?? [], + ), + ), + filteredNextFixedSegments, + null, + null, + ); + } + + import.meta.env.DEV && + invariant( + validateElbowPoints(nextPoints), + "Invalid elbow points with fixed segments", + ); + + return normalizeArrowElementUpdate( + nextPoints, + filteredNextFixedSegments, + arrow.startIsSpecial, + arrow.endIsSpecial, + ); + } + + return { + x: arrow.x, + y: arrow.y, + points: arrow.points, + fixedSegments: arrow.fixedSegments, + startIsSpecial: arrow.startIsSpecial, + endIsSpecial: arrow.endIsSpecial, + }; +}; + +const handleSegmentRelease = ( + arrow: ExcalidrawElbowArrowElement, + fixedSegments: readonly FixedSegment[], + elementsMap: NonDeletedSceneElementsMap, +) => { + const newFixedSegmentIndices = fixedSegments.map((segment) => segment.index); + const oldFixedSegmentIndices = + arrow.fixedSegments?.map((segment) => segment.index) ?? []; + const deletedSegmentIdx = oldFixedSegmentIndices.findIndex( + (idx) => !newFixedSegmentIndices.includes(idx), + ); + + if (deletedSegmentIdx === -1 || !arrow.fixedSegments?.[deletedSegmentIdx]) { + return { + points: arrow.points, + }; + } + + const deletedIdx = arrow.fixedSegments[deletedSegmentIdx].index; + + // Find prev and next fixed segments + const prevSegment = arrow.fixedSegments[deletedSegmentIdx - 1]; + const nextSegment = arrow.fixedSegments[deletedSegmentIdx + 1]; + + // We need to render a sub-arrow path to restore deleted segments + const x = arrow.x + (prevSegment ? prevSegment.end[0] : 0); + const y = arrow.y + (prevSegment ? prevSegment.end[1] : 0); + const startBinding = prevSegment ? null : arrow.startBinding; + const endBinding = nextSegment ? null : arrow.endBinding; + const { + startHeading, + endHeading, + startGlobalPoint, + endGlobalPoint, + hoveredStartElement, + hoveredEndElement, + ...rest + } = getElbowArrowData( + { + x, + y, + startBinding, + endBinding, + startArrowhead: null, + endArrowhead: null, + points: arrow.points, + }, + elementsMap, + [ + pointFrom<LocalPoint>(0, 0), + pointFrom<LocalPoint>( + arrow.x + + (nextSegment?.start[0] ?? arrow.points[arrow.points.length - 1][0]) - + x, + arrow.y + + (nextSegment?.start[1] ?? arrow.points[arrow.points.length - 1][1]) - + y, + ), + ], + startBinding && + getBindableElementForId(startBinding.elementId, elementsMap), + endBinding && getBindableElementForId(endBinding.elementId, elementsMap), + { isDragging: false }, + ); + + const { points: restoredPoints } = normalizeArrowElementUpdate( + getElbowArrowCornerPoints( + removeElbowArrowShortSegments( + routeElbowArrow(arrow, { + startHeading, + endHeading, + startGlobalPoint, + endGlobalPoint, + hoveredStartElement, + hoveredEndElement, + ...rest, + }) ?? [], + ), + ), + fixedSegments, + null, + null, + ); + + const nextPoints: GlobalPoint[] = []; + + // First part of the arrow are the old points + if (prevSegment) { + for (let i = 0; i < prevSegment.index; i++) { + nextPoints.push( + pointFrom<GlobalPoint>( + arrow.x + arrow.points[i][0], + arrow.y + arrow.points[i][1], + ), + ); + } + } + + restoredPoints.forEach((p) => { + nextPoints.push( + pointFrom<GlobalPoint>( + arrow.x + (prevSegment ? prevSegment.end[0] : 0) + p[0], + arrow.y + (prevSegment ? prevSegment.end[1] : 0) + p[1], + ), + ); + }); + + // Last part of the arrow are the old points too + if (nextSegment) { + for (let i = nextSegment.index; i < arrow.points.length; i++) { + nextPoints.push( + pointFrom<GlobalPoint>( + arrow.x + arrow.points[i][0], + arrow.y + arrow.points[i][1], + ), + ); + } + } + + // Update nextFixedSegments + const originalSegmentCountDiff = + (nextSegment?.index ?? arrow.points.length) - (prevSegment?.index ?? 0) - 1; + + const nextFixedSegments = fixedSegments.map((segment) => { + if (segment.index > deletedIdx) { + return { + ...segment, + index: + segment.index - + originalSegmentCountDiff + + (restoredPoints.length - 1), + }; + } + + return segment; + }); + + const simplifiedPoints = nextPoints.flatMap((p, i) => { + const prev = nextPoints[i - 1]; + const next = nextPoints[i + 1]; + + if (prev && next) { + const prevHeading = headingForPoint(p, prev); + const nextHeading = headingForPoint(next, p); + + if (compareHeading(prevHeading, nextHeading)) { + // Update subsequent fixed segment indices + nextFixedSegments.forEach((segment) => { + if (segment.index > i) { + segment.index -= 1; + } + }); + + return []; + } else if (compareHeading(prevHeading, flipHeading(nextHeading))) { + // Update subsequent fixed segment indices + nextFixedSegments.forEach((segment) => { + if (segment.index > i) { + segment.index += 1; + } + }); + + return [p, p]; + } + } + + return [p]; + }); + + return normalizeArrowElementUpdate( + simplifiedPoints, + nextFixedSegments, + false, + false, + ); +}; + +/** + * + */ +const handleSegmentMove = ( + arrow: ExcalidrawElbowArrowElement, + fixedSegments: readonly FixedSegment[], + startHeading: Heading, + endHeading: Heading, + hoveredStartElement: ExcalidrawBindableElement | null, + hoveredEndElement: ExcalidrawBindableElement | null, +): ElementUpdate<ExcalidrawElbowArrowElement> => { + const activelyModifiedSegmentIdx = fixedSegments + .map((segment, i) => { + if ( + arrow.fixedSegments == null || + arrow.fixedSegments[i] === undefined || + arrow.fixedSegments[i].index !== segment.index + ) { + return i; + } + + return (segment.start[0] !== arrow.fixedSegments![i].start[0] && + segment.end[0] !== arrow.fixedSegments![i].end[0]) !== + (segment.start[1] !== arrow.fixedSegments![i].start[1] && + segment.end[1] !== arrow.fixedSegments![i].end[1]) + ? i + : null; + }) + .filter((idx) => idx !== null) + .shift(); + + if (activelyModifiedSegmentIdx == null) { + return { points: arrow.points }; + } + + const firstSegmentIdx = + arrow.fixedSegments?.findIndex((segment) => segment.index === 1) ?? -1; + const lastSegmentIdx = + arrow.fixedSegments?.findIndex( + (segment) => segment.index === arrow.points.length - 1, + ) ?? -1; + + // Handle special case for first segment move + const segmentLength = pointDistance( + fixedSegments[activelyModifiedSegmentIdx].start, + fixedSegments[activelyModifiedSegmentIdx].end, + ); + const segmentIsTooShort = segmentLength < BASE_PADDING + 5; + if ( + firstSegmentIdx === -1 && + fixedSegments[activelyModifiedSegmentIdx].index === 1 && + hoveredStartElement + ) { + const startIsHorizontal = headingIsHorizontal(startHeading); + const startIsPositive = startIsHorizontal + ? compareHeading(startHeading, HEADING_RIGHT) + : compareHeading(startHeading, HEADING_DOWN); + const padding = startIsPositive + ? segmentIsTooShort + ? segmentLength / 2 + : BASE_PADDING + : segmentIsTooShort + ? -segmentLength / 2 + : -BASE_PADDING; + fixedSegments[activelyModifiedSegmentIdx].start = pointFrom<LocalPoint>( + fixedSegments[activelyModifiedSegmentIdx].start[0] + + (startIsHorizontal ? padding : 0), + fixedSegments[activelyModifiedSegmentIdx].start[1] + + (!startIsHorizontal ? padding : 0), + ); + } + + // Handle special case for last segment move + if ( + lastSegmentIdx === -1 && + fixedSegments[activelyModifiedSegmentIdx].index === + arrow.points.length - 1 && + hoveredEndElement + ) { + const endIsHorizontal = headingIsHorizontal(endHeading); + const endIsPositive = endIsHorizontal + ? compareHeading(endHeading, HEADING_RIGHT) + : compareHeading(endHeading, HEADING_DOWN); + const padding = endIsPositive + ? segmentIsTooShort + ? segmentLength / 2 + : BASE_PADDING + : segmentIsTooShort + ? -segmentLength / 2 + : -BASE_PADDING; + fixedSegments[activelyModifiedSegmentIdx].end = pointFrom<LocalPoint>( + fixedSegments[activelyModifiedSegmentIdx].end[0] + + (endIsHorizontal ? padding : 0), + fixedSegments[activelyModifiedSegmentIdx].end[1] + + (!endIsHorizontal ? padding : 0), + ); + } + + // Translate all fixed segments to global coordinates + const nextFixedSegments = fixedSegments.map((segment) => ({ + ...segment, + start: pointFrom<GlobalPoint>( + arrow.x + segment.start[0], + arrow.y + segment.start[1], + ), + end: pointFrom<GlobalPoint>( + arrow.x + segment.end[0], + arrow.y + segment.end[1], + ), + })); + + // For start, clone old arrow points + const newPoints: GlobalPoint[] = arrow.points.map((p, i) => + pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]), + ); + + const startIdx = nextFixedSegments[activelyModifiedSegmentIdx].index - 1; + const endIdx = nextFixedSegments[activelyModifiedSegmentIdx].index; + const start = nextFixedSegments[activelyModifiedSegmentIdx].start; + const end = nextFixedSegments[activelyModifiedSegmentIdx].end; + const prevSegmentIsHorizontal = + newPoints[startIdx - 1] && + !pointsEqual(newPoints[startIdx], newPoints[startIdx - 1]) + ? headingForPointIsHorizontal( + newPoints[startIdx - 1], + newPoints[startIdx], + ) + : undefined; + const nextSegmentIsHorizontal = + newPoints[endIdx + 1] && + !pointsEqual(newPoints[endIdx], newPoints[endIdx + 1]) + ? headingForPointIsHorizontal(newPoints[endIdx + 1], newPoints[endIdx]) + : undefined; + + // Override the segment points with the actively moved fixed segment + if (prevSegmentIsHorizontal !== undefined) { + const dir = prevSegmentIsHorizontal ? 1 : 0; + newPoints[startIdx - 1][dir] = start[dir]; + } + newPoints[startIdx] = start; + newPoints[endIdx] = end; + if (nextSegmentIsHorizontal !== undefined) { + const dir = nextSegmentIsHorizontal ? 1 : 0; + newPoints[endIdx + 1][dir] = end[dir]; + } + + // Override neighboring fixedSegment start/end points, if any + const prevSegmentIdx = nextFixedSegments.findIndex( + (segment) => segment.index === startIdx, + ); + if (prevSegmentIdx !== -1) { + // Align the next segment points with the moved segment + const dir = headingForPointIsHorizontal( + nextFixedSegments[prevSegmentIdx].end, + nextFixedSegments[prevSegmentIdx].start, + ) + ? 1 + : 0; + nextFixedSegments[prevSegmentIdx].start[dir] = start[dir]; + nextFixedSegments[prevSegmentIdx].end = start; + } + + const nextSegmentIdx = nextFixedSegments.findIndex( + (segment) => segment.index === endIdx + 1, + ); + if (nextSegmentIdx !== -1) { + // Align the next segment points with the moved segment + const dir = headingForPointIsHorizontal( + nextFixedSegments[nextSegmentIdx].end, + nextFixedSegments[nextSegmentIdx].start, + ) + ? 1 + : 0; + nextFixedSegments[nextSegmentIdx].end[dir] = end[dir]; + nextFixedSegments[nextSegmentIdx].start = end; + } + + // First segment move needs an additional segment + if (firstSegmentIdx === -1 && startIdx === 0) { + const startIsHorizontal = hoveredStartElement + ? headingIsHorizontal(startHeading) + : headingForPointIsHorizontal(newPoints[1], newPoints[0]); + newPoints.unshift( + pointFrom<GlobalPoint>( + startIsHorizontal ? start[0] : arrow.x + arrow.points[0][0], + !startIsHorizontal ? start[1] : arrow.y + arrow.points[0][1], + ), + ); + + if (hoveredStartElement) { + newPoints.unshift( + pointFrom<GlobalPoint>( + arrow.x + arrow.points[0][0], + arrow.y + arrow.points[0][1], + ), + ); + } + + for (const segment of nextFixedSegments) { + segment.index += hoveredStartElement ? 2 : 1; + } + } + + // Last segment move needs an additional segment + if (lastSegmentIdx === -1 && endIdx === arrow.points.length - 1) { + const endIsHorizontal = headingIsHorizontal(endHeading); + newPoints.push( + pointFrom<GlobalPoint>( + endIsHorizontal + ? end[0] + : arrow.x + arrow.points[arrow.points.length - 1][0], + !endIsHorizontal + ? end[1] + : arrow.y + arrow.points[arrow.points.length - 1][1], + ), + ); + if (hoveredEndElement) { + newPoints.push( + pointFrom<GlobalPoint>( + arrow.x + arrow.points[arrow.points.length - 1][0], + arrow.y + arrow.points[arrow.points.length - 1][1], + ), + ); + } + } + + return normalizeArrowElementUpdate( + newPoints, + nextFixedSegments.map((segment) => ({ + ...segment, + start: pointFrom<LocalPoint>( + segment.start[0] - arrow.x, + segment.start[1] - arrow.y, + ), + end: pointFrom<LocalPoint>( + segment.end[0] - arrow.x, + segment.end[1] - arrow.y, + ), + })), + false, // If you move a segment, there is no special point anymore + false, // If you move a segment, there is no special point anymore + ); +}; + +const handleEndpointDrag = ( + arrow: ExcalidrawElbowArrowElement, + updatedPoints: readonly LocalPoint[], + fixedSegments: readonly FixedSegment[], + startHeading: Heading, + endHeading: Heading, + startGlobalPoint: GlobalPoint, + endGlobalPoint: GlobalPoint, + hoveredStartElement: ExcalidrawBindableElement | null, + hoveredEndElement: ExcalidrawBindableElement | null, +) => { + let startIsSpecial = arrow.startIsSpecial ?? null; + let endIsSpecial = arrow.endIsSpecial ?? null; + const globalUpdatedPoints = updatedPoints.map((p, i) => + i === 0 + ? pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]) + : i === updatedPoints.length - 1 + ? pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]) + : pointFrom<GlobalPoint>( + arrow.x + arrow.points[i][0], + arrow.y + arrow.points[i][1], + ), + ); + const nextFixedSegments = fixedSegments.map((segment) => ({ + ...segment, + start: pointFrom<GlobalPoint>( + arrow.x + (segment.start[0] - updatedPoints[0][0]), + arrow.y + (segment.start[1] - updatedPoints[0][1]), + ), + end: pointFrom<GlobalPoint>( + arrow.x + (segment.end[0] - updatedPoints[0][0]), + arrow.y + (segment.end[1] - updatedPoints[0][1]), + ), + })); + const newPoints: GlobalPoint[] = []; + + // Add the inside points + const offset = 2 + (startIsSpecial ? 1 : 0); + const endOffset = 2 + (endIsSpecial ? 1 : 0); + while (newPoints.length + offset < globalUpdatedPoints.length - endOffset) { + newPoints.push(globalUpdatedPoints[newPoints.length + offset]); + } + + // Calculate the moving second point connection and add the start point + { + const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1]; + const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2]; + const startIsHorizontal = headingIsHorizontal(startHeading); + const secondIsHorizontal = headingIsHorizontal( + vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)), + ); + + if (hoveredStartElement && startIsHorizontal === secondIsHorizontal) { + const positive = startIsHorizontal + ? compareHeading(startHeading, HEADING_RIGHT) + : compareHeading(startHeading, HEADING_DOWN); + newPoints.unshift( + pointFrom<GlobalPoint>( + !secondIsHorizontal + ? thirdPoint[0] + : startGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING), + secondIsHorizontal + ? thirdPoint[1] + : startGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING), + ), + ); + newPoints.unshift( + pointFrom<GlobalPoint>( + startIsHorizontal + ? startGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING) + : startGlobalPoint[0], + !startIsHorizontal + ? startGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING) + : startGlobalPoint[1], + ), + ); + if (!startIsSpecial) { + startIsSpecial = true; + for (const segment of nextFixedSegments) { + if (segment.index > 1) { + segment.index += 1; + } + } + } + } else { + newPoints.unshift( + pointFrom<GlobalPoint>( + !secondIsHorizontal ? secondPoint[0] : startGlobalPoint[0], + secondIsHorizontal ? secondPoint[1] : startGlobalPoint[1], + ), + ); + if (startIsSpecial) { + startIsSpecial = false; + for (const segment of nextFixedSegments) { + if (segment.index > 1) { + segment.index -= 1; + } + } + } + } + newPoints.unshift(startGlobalPoint); + } + + // Calculate the moving second to last point connection + { + const secondToLastPoint = + globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)]; + const thirdToLastPoint = + globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)]; + const endIsHorizontal = headingIsHorizontal(endHeading); + const secondIsHorizontal = headingForPointIsHorizontal( + thirdToLastPoint, + secondToLastPoint, + ); + if (hoveredEndElement && endIsHorizontal === secondIsHorizontal) { + const positive = endIsHorizontal + ? compareHeading(endHeading, HEADING_RIGHT) + : compareHeading(endHeading, HEADING_DOWN); + newPoints.push( + pointFrom<GlobalPoint>( + !secondIsHorizontal + ? thirdToLastPoint[0] + : endGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING), + secondIsHorizontal + ? thirdToLastPoint[1] + : endGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING), + ), + ); + newPoints.push( + pointFrom<GlobalPoint>( + endIsHorizontal + ? endGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING) + : endGlobalPoint[0], + !endIsHorizontal + ? endGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING) + : endGlobalPoint[1], + ), + ); + if (!endIsSpecial) { + endIsSpecial = true; + } + } else { + newPoints.push( + pointFrom<GlobalPoint>( + !secondIsHorizontal ? secondToLastPoint[0] : endGlobalPoint[0], + secondIsHorizontal ? secondToLastPoint[1] : endGlobalPoint[1], + ), + ); + if (endIsSpecial) { + endIsSpecial = false; + } + } + } + + newPoints.push(endGlobalPoint); + + return normalizeArrowElementUpdate( + newPoints, + nextFixedSegments + .map(({ index }) => ({ + index, + start: newPoints[index - 1], + end: newPoints[index], + })) + .map((segment) => ({ + ...segment, + start: pointFrom<LocalPoint>( + segment.start[0] - startGlobalPoint[0], + segment.start[1] - startGlobalPoint[1], + ), + end: pointFrom<LocalPoint>( + segment.end[0] - startGlobalPoint[0], + segment.end[1] - startGlobalPoint[1], + ), + })), + startIsSpecial, + endIsSpecial, + ); +}; + +const MAX_POS = 1e6; + +/** + * + */ +export const updateElbowArrowPoints = ( + arrow: Readonly<ExcalidrawElbowArrowElement>, + elementsMap: NonDeletedSceneElementsMap, + updates: { + points?: readonly LocalPoint[]; + fixedSegments?: FixedSegment[] | null; + startBinding?: FixedPointBinding | null; + endBinding?: FixedPointBinding | null; + }, + options?: { + isDragging?: boolean; + }, +): ElementUpdate<ExcalidrawElbowArrowElement> => { + if (arrow.points.length < 2) { + return { points: updates.points ?? arrow.points }; + } + + // NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow + // arrow size is valid. This check will be removed once the issue is identified + if ( + arrow.x < -MAX_POS || + arrow.x > MAX_POS || + arrow.y < -MAX_POS || + arrow.y > MAX_POS || + arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) < + -MAX_POS || + arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) > + MAX_POS || + arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) < + -MAX_POS || + arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) > + MAX_POS || + arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) < + -MAX_POS || + arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) > + MAX_POS || + arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) < + -MAX_POS || + arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS + ) { + console.error( + "Elbow arrow (or update) is outside reasonable bounds (> 1e6)", + { + arrow, + updates, + }, + ); + } + // @ts-ignore See above note + arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS); + // @ts-ignore See above note + arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS); + if (updates.points) { + updates.points = updates.points.map(([x, y]) => + pointFrom<LocalPoint>( + clamp(x, -MAX_POS, MAX_POS), + clamp(y, -MAX_POS, MAX_POS), + ), + ); + } + + if (!import.meta.env.PROD) { + invariant( + !updates.points || updates.points.length >= 2, + "Updated point array length must match the arrow point length, contain " + + "exactly the new start and end points or not be specified at all (i.e. " + + "you can't add new points between start and end manually to elbow arrows)", + ); + + invariant( + !arrow.fixedSegments || + arrow.fixedSegments + .map((s) => s.start[0] === s.end[0] || s.start[1] === s.end[1]) + .every(Boolean), + "Fixed segments must be either horizontal or vertical", + ); + + invariant( + !updates.fixedSegments || + updates.fixedSegments + .map((s) => s.start[0] === s.end[0] || s.start[1] === s.end[1]) + .every(Boolean), + "Updates to fixed segments must be either horizontal or vertical", + ); + + invariant( + arrow.points + .slice(1) + .map( + (p, i) => p[0] === arrow.points[i][0] || p[1] === arrow.points[i][1], + ), + "Elbow arrow segments must be either horizontal or vertical", + ); + } + + const updatedPoints: readonly LocalPoint[] = updates.points + ? updates.points && updates.points.length === 2 + ? arrow.points.map((p, idx) => + idx === 0 + ? updates.points![0] + : idx === arrow.points.length - 1 + ? updates.points![1] + : p, + ) + : updates.points.slice() + : arrow.points.slice(); + + // 0. During all element replacement in the scene, we just need to renormalize + // the arrow + // TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed + const startBinding = + typeof updates.startBinding !== "undefined" + ? updates.startBinding + : arrow.startBinding; + const endBinding = + typeof updates.endBinding !== "undefined" + ? updates.endBinding + : arrow.endBinding; + const startElement = + startBinding && + getBindableElementForId(startBinding.elementId, elementsMap); + const endElement = + endBinding && getBindableElementForId(endBinding.elementId, elementsMap); + if ( + (elementsMap.size === 0 && validateElbowPoints(updatedPoints)) || + startElement?.id !== startBinding?.elementId || + endElement?.id !== endBinding?.elementId + ) { + return normalizeArrowElementUpdate( + updatedPoints.map((p) => + pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]), + ), + arrow.fixedSegments, + arrow.startIsSpecial, + arrow.endIsSpecial, + ); + } + + const { + startHeading, + endHeading, + startGlobalPoint, + endGlobalPoint, + hoveredStartElement, + hoveredEndElement, + ...rest + } = getElbowArrowData( + { + x: arrow.x, + y: arrow.y, + startBinding, + endBinding, + startArrowhead: arrow.startArrowhead, + endArrowhead: arrow.endArrowhead, + points: arrow.points, + }, + elementsMap, + updatedPoints, + startElement, + endElement, + options, + ); + + const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? []; + + //// + // 1. Renormalize the arrow + //// + if ( + !updates.points && + !updates.fixedSegments && + !updates.startBinding && + !updates.endBinding + ) { + return handleSegmentRenormalization(arrow, elementsMap); + } + + // Short circuit on no-op to avoid huge performance hit + if ( + updates.startBinding === arrow.startBinding && + updates.endBinding === arrow.endBinding && + (updates.points ?? []).every((p, i) => + pointsEqual( + p, + arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity), + ), + ) + ) { + return {}; + } + + //// + // 2. Just normal elbow arrow things + //// + if (fixedSegments.length === 0) { + return normalizeArrowElementUpdate( + getElbowArrowCornerPoints( + removeElbowArrowShortSegments( + routeElbowArrow(arrow, { + startHeading, + endHeading, + startGlobalPoint, + endGlobalPoint, + hoveredStartElement, + hoveredEndElement, + ...rest, + }) ?? [], + ), + ), + fixedSegments, + null, + null, + ); + } + + //// + // 3. Handle releasing a fixed segment + if ((arrow.fixedSegments?.length ?? 0) > fixedSegments.length) { + return handleSegmentRelease(arrow, fixedSegments, elementsMap); + } + + //// + // 4. Handle manual segment move + //// + if (!updates.points) { + return handleSegmentMove( + arrow, + fixedSegments, + startHeading, + endHeading, + hoveredStartElement, + hoveredEndElement, + ); + } + + //// + // 5. Handle resize + //// + if (updates.points && updates.fixedSegments) { + return updates; + } + + //// + // 6. One or more segments are fixed and endpoints are moved + // + // The key insights are: + // - When segments are fixed, the arrow will keep the exact amount of segments + // - Fixed segments are "replacements" for exactly one segment in the old arrow + //// + return handleEndpointDrag( + arrow, + updatedPoints, + fixedSegments, + startHeading, + endHeading, + startGlobalPoint, + endGlobalPoint, + hoveredStartElement, + hoveredEndElement, + ); +}; + +/** + * Retrieves data necessary for calculating the elbow arrow path. + * + * @param arrow - The arrow object containing its properties. + * @param elementsMap - A map of elements in the scene. + * @param nextPoints - The next set of points for the arrow. + * @param options - Optional parameters for the calculation. + * @param options.isDragging - Indicates if the arrow is being dragged. + * @param options.startIsMidPoint - Indicates if the start point is a midpoint. + * @param options.endIsMidPoint - Indicates if the end point is a midpoint. + * + * @returns An object containing various properties needed for elbow arrow calculations: + * - dynamicAABBs: Dynamically generated axis-aligned bounding boxes. + * - startDonglePosition: The position of the start dongle. + * - startGlobalPoint: The global coordinates of the start point. + * - startHeading: The heading direction from the start point. + * - endDonglePosition: The position of the end dongle. + * - endGlobalPoint: The global coordinates of the end point. + * - endHeading: The heading direction from the end point. + * - commonBounds: The common bounding box that encompasses both start and end points. + * - hoveredStartElement: The element being hovered over at the start point. + * - hoveredEndElement: The element being hovered over at the end point. + */ +const getElbowArrowData = ( + arrow: { + x: number; + y: number; + startBinding: FixedPointBinding | null; + endBinding: FixedPointBinding | null; + startArrowhead: Arrowhead | null; + endArrowhead: Arrowhead | null; + points: readonly LocalPoint[]; + }, + elementsMap: NonDeletedSceneElementsMap, + nextPoints: readonly LocalPoint[], + startElement: ExcalidrawBindableElement | null, + endElement: ExcalidrawBindableElement | null, + options?: { + isDragging?: boolean; + zoom?: AppState["zoom"]; + }, +) => { + const origStartGlobalPoint: GlobalPoint = pointTranslate< + LocalPoint, + GlobalPoint + >(nextPoints[0], vector(arrow.x, arrow.y)); + const origEndGlobalPoint: GlobalPoint = pointTranslate< + LocalPoint, + GlobalPoint + >(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y)); + + let hoveredStartElement = startElement; + let hoveredEndElement = endElement; + if (options?.isDragging) { + const elements = Array.from(elementsMap.values()); + hoveredStartElement = + getHoveredElement( + origStartGlobalPoint, + elementsMap, + elements, + options?.zoom, + ) || startElement; + hoveredEndElement = + getHoveredElement( + origEndGlobalPoint, + elementsMap, + elements, + options?.zoom, + ) || endElement; + } + + const startGlobalPoint = getGlobalPoint( + { + ...arrow, + elbowed: true, + points: nextPoints, + } as ExcalidrawElbowArrowElement, + "start", + arrow.startBinding?.fixedPoint, + origStartGlobalPoint, + startElement, + hoveredStartElement, + options?.isDragging, + ); + const endGlobalPoint = getGlobalPoint( + { + ...arrow, + elbowed: true, + points: nextPoints, + } as ExcalidrawElbowArrowElement, + "end", + arrow.endBinding?.fixedPoint, + origEndGlobalPoint, + endElement, + hoveredEndElement, + options?.isDragging, + ); + const startHeading = getBindPointHeading( + startGlobalPoint, + endGlobalPoint, + elementsMap, + hoveredStartElement, + origStartGlobalPoint, + ); + const endHeading = getBindPointHeading( + endGlobalPoint, + startGlobalPoint, + elementsMap, + hoveredEndElement, + origEndGlobalPoint, + ); + const startPointBounds = [ + startGlobalPoint[0] - 2, + startGlobalPoint[1] - 2, + startGlobalPoint[0] + 2, + startGlobalPoint[1] + 2, + ] as Bounds; + const endPointBounds = [ + endGlobalPoint[0] - 2, + endGlobalPoint[1] - 2, + endGlobalPoint[0] + 2, + endGlobalPoint[1] + 2, + ] as Bounds; + const startElementBounds = hoveredStartElement + ? aabbForElement( + hoveredStartElement, + offsetFromHeading( + startHeading, + arrow.startArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2, + 1, + ), + ) + : startPointBounds; + const endElementBounds = hoveredEndElement + ? aabbForElement( + hoveredEndElement, + offsetFromHeading( + endHeading, + arrow.endArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2, + 1, + ), + ) + : endPointBounds; + const boundsOverlap = + pointInsideBounds( + startGlobalPoint, + hoveredEndElement + ? aabbForElement( + hoveredEndElement, + offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING), + ) + : endPointBounds, + ) || + pointInsideBounds( + endGlobalPoint, + hoveredStartElement + ? aabbForElement( + hoveredStartElement, + offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING), + ) + : startPointBounds, + ); + const commonBounds = commonAABB( + boundsOverlap + ? [startPointBounds, endPointBounds] + : [startElementBounds, endElementBounds], + ); + const dynamicAABBs = generateDynamicAABBs( + boundsOverlap ? startPointBounds : startElementBounds, + boundsOverlap ? endPointBounds : endElementBounds, + commonBounds, + boundsOverlap + ? offsetFromHeading( + startHeading, + !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, + 0, + ) + : offsetFromHeading( + startHeading, + !hoveredStartElement && !hoveredEndElement + ? 0 + : BASE_PADDING - + (arrow.startArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2), + BASE_PADDING, + ), + boundsOverlap + ? offsetFromHeading( + endHeading, + !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, + 0, + ) + : offsetFromHeading( + endHeading, + !hoveredStartElement && !hoveredEndElement + ? 0 + : BASE_PADDING - + (arrow.endArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2), + BASE_PADDING, + ), + boundsOverlap, + hoveredStartElement && aabbForElement(hoveredStartElement), + hoveredEndElement && aabbForElement(hoveredEndElement), + ); + const startDonglePosition = getDonglePosition( + dynamicAABBs[0], + startHeading, + startGlobalPoint, + ); + const endDonglePosition = getDonglePosition( + dynamicAABBs[1], + endHeading, + endGlobalPoint, + ); + + return { + dynamicAABBs, + startDonglePosition, + startGlobalPoint, + startHeading, + endDonglePosition, + endGlobalPoint, + endHeading, + commonBounds, + hoveredStartElement, + hoveredEndElement, + boundsOverlap, + startElementBounds, + endElementBounds, + }; +}; + +/** + * Generate the elbow arrow segments + * + * @param arrow + * @param elementsMap + * @param nextPoints + * @param options + * @returns + */ +const routeElbowArrow = ( + arrow: ElbowArrowState, + elbowArrowData: ElbowArrowData, +): GlobalPoint[] | null => { + const { + dynamicAABBs, + startDonglePosition, + startGlobalPoint, + startHeading, + endDonglePosition, + endGlobalPoint, + endHeading, + commonBounds, + hoveredEndElement, + } = elbowArrowData; + + // Canculate Grid positions + const grid = calculateGrid( + dynamicAABBs, + startDonglePosition ? startDonglePosition : startGlobalPoint, + startHeading, + endDonglePosition ? endDonglePosition : endGlobalPoint, + endHeading, + commonBounds, + ); + + const startDongle = + startDonglePosition && pointToGridNode(startDonglePosition, grid); + const endDongle = + endDonglePosition && pointToGridNode(endDonglePosition, grid); + + // Do not allow stepping on the true end or true start points + const endNode = pointToGridNode(endGlobalPoint, grid); + if (endNode && hoveredEndElement) { + endNode.closed = true; + } + const startNode = pointToGridNode(startGlobalPoint, grid); + if (startNode && arrow.startBinding) { + startNode.closed = true; + } + const dongleOverlap = + startDongle && + endDongle && + (pointInsideBounds(startDongle.pos, dynamicAABBs[1]) || + pointInsideBounds(endDongle.pos, dynamicAABBs[0])); + + // Create path to end dongle from start dongle + const path = astar( + startDongle ? startDongle : startNode!, + endDongle ? endDongle : endNode!, + grid, + startHeading ? startHeading : HEADING_RIGHT, + endHeading ? endHeading : HEADING_RIGHT, + dongleOverlap ? [] : dynamicAABBs, + ); + + if (path) { + const points = path.map((node) => [ + node.pos[0], + node.pos[1], + ]) as GlobalPoint[]; + startDongle && points.unshift(startGlobalPoint); + endDongle && points.push(endGlobalPoint); + + return points; + } + + return null; +}; + +const offsetFromHeading = ( + heading: Heading, + head: number, + side: number, +): [number, number, number, number] => { + switch (heading) { + case HEADING_UP: + return [head, side, side, side]; + case HEADING_RIGHT: + return [side, head, side, side]; + case HEADING_DOWN: + return [side, side, head, side]; + } + + return [side, side, side, head]; +}; + +/** + * Routing algorithm based on the A* path search algorithm. + * @see https://www.geeksforgeeks.org/a-search-algorithm/ + * + * Binary heap is used to optimize node lookup. + * See {@link calculateGrid} for the grid calculation details. + * + * Additional modifications added due to aesthetic route reasons: + * 1) Arrow segment direction change is penalized by specific linear constant (bendMultiplier) + * 2) Arrow segments are not allowed to go "backwards", overlapping with the previous segment + */ +const astar = ( + start: Node, + end: Node, + grid: Grid, + startHeading: Heading, + endHeading: Heading, + aabbs: Bounds[], +) => { + const bendMultiplier = m_dist(start.pos, end.pos); + const open = new BinaryHeap<Node>((node) => node.f); + + open.push(start); + + while (open.size() > 0) { + // Grab the lowest f(x) to process next. Heap keeps this sorted for us. + const current = open.pop(); + + if (!current || current.closed) { + // Current is not passable, continue with next element + continue; + } + + // End case -- result has been found, return the traced path. + if (current === end) { + return pathTo(start, current); + } + + // Normal case -- move current from open to closed, process each of its neighbors. + current.closed = true; + + // Find all neighbors for the current node. + const neighbors = getNeighbors(current.addr, grid); + + for (let i = 0; i < 4; i++) { + const neighbor = neighbors[i]; + + if (!neighbor || neighbor.closed) { + // Not a valid node to process, skip to next neighbor. + continue; + } + + // Intersect + const neighborHalfPoint = pointScaleFromOrigin( + neighbor.pos, + current.pos, + 0.5, + ); + if ( + isAnyTrue( + ...aabbs.map((aabb) => pointInsideBounds(neighborHalfPoint, aabb)), + ) + ) { + continue; + } + + // The g score is the shortest distance from start to current node. + // We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet. + const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3); + const previousDirection = current.parent + ? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos)) + : startHeading; + + // Do not allow going in reverse + const reverseHeading = flipHeading(previousDirection); + const neighborIsReverseRoute = + compareHeading(reverseHeading, neighborHeading) || + (gridAddressesEqual(start.addr, neighbor.addr) && + compareHeading(neighborHeading, startHeading)) || + (gridAddressesEqual(end.addr, neighbor.addr) && + compareHeading(neighborHeading, endHeading)); + if (neighborIsReverseRoute) { + continue; + } + + const directionChange = previousDirection !== neighborHeading; + const gScore = + current.g + + m_dist(neighbor.pos, current.pos) + + (directionChange ? Math.pow(bendMultiplier, 3) : 0); + + const beenVisited = neighbor.visited; + + if (!beenVisited || gScore < neighbor.g) { + const estBendCount = estimateSegmentCount( + neighbor, + end, + neighborHeading, + endHeading, + ); + // Found an optimal (so far) path to this node. Take score for node to see how good it is. + neighbor.visited = true; + neighbor.parent = current; + neighbor.h = + m_dist(end.pos, neighbor.pos) + + estBendCount * Math.pow(bendMultiplier, 2); + neighbor.g = gScore; + neighbor.f = neighbor.g + neighbor.h; + if (!beenVisited) { + // Pushing to heap will put it in proper place based on the 'f' value. + open.push(neighbor); + } else { + // Already seen the node, but since it has been rescored we need to reorder it in the heap + open.rescoreElement(neighbor); + } + } + } + } + + return null; +}; + +const pathTo = (start: Node, node: Node) => { + let curr = node; + const path = []; + while (curr.parent) { + path.unshift(curr); + curr = curr.parent; + } + path.unshift(start); + + return path; +}; + +const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) => + Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]); + +/** + * Create dynamically resizing, always touching + * bounding boxes having a minimum extent represented + * by the given static bounds. + */ +const generateDynamicAABBs = ( + a: Bounds, + b: Bounds, + common: Bounds, + startDifference?: [number, number, number, number], + endDifference?: [number, number, number, number], + disableSideHack?: boolean, + startElementBounds?: Bounds | null, + endElementBounds?: Bounds | null, +): Bounds[] => { + const startEl = startElementBounds ?? a; + const endEl = endElementBounds ?? b; + const [startUp, startRight, startDown, startLeft] = startDifference ?? [ + 0, 0, 0, 0, + ]; + const [endUp, endRight, endDown, endLeft] = endDifference ?? [0, 0, 0, 0]; + + const first = [ + a[0] > b[2] + ? a[1] > b[3] || a[3] < b[1] + ? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft) + : (startEl[0] + endEl[2]) / 2 + : a[0] > b[0] + ? a[0] - startLeft + : common[0] - startLeft, + a[1] > b[3] + ? a[0] > b[2] || a[2] < b[0] + ? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp) + : (startEl[1] + endEl[3]) / 2 + : a[1] > b[1] + ? a[1] - startUp + : common[1] - startUp, + a[2] < b[0] + ? a[1] > b[3] || a[3] < b[1] + ? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight) + : (startEl[2] + endEl[0]) / 2 + : a[2] < b[2] + ? a[2] + startRight + : common[2] + startRight, + a[3] < b[1] + ? a[0] > b[2] || a[2] < b[0] + ? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown) + : (startEl[3] + endEl[1]) / 2 + : a[3] < b[3] + ? a[3] + startDown + : common[3] + startDown, + ] as Bounds; + const second = [ + b[0] > a[2] + ? b[1] > a[3] || b[3] < a[1] + ? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft) + : (endEl[0] + startEl[2]) / 2 + : b[0] > a[0] + ? b[0] - endLeft + : common[0] - endLeft, + b[1] > a[3] + ? b[0] > a[2] || b[2] < a[0] + ? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp) + : (endEl[1] + startEl[3]) / 2 + : b[1] > a[1] + ? b[1] - endUp + : common[1] - endUp, + b[2] < a[0] + ? b[1] > a[3] || b[3] < a[1] + ? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight) + : (endEl[2] + startEl[0]) / 2 + : b[2] < a[2] + ? b[2] + endRight + : common[2] + endRight, + b[3] < a[1] + ? b[0] > a[2] || b[2] < a[0] + ? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown) + : (endEl[3] + startEl[1]) / 2 + : b[3] < a[3] + ? b[3] + endDown + : common[3] + endDown, + ] as Bounds; + + const c = commonAABB([first, second]); + if ( + !disableSideHack && + first[2] - first[0] + second[2] - second[0] > c[2] - c[0] + 0.00000000001 && + first[3] - first[1] + second[3] - second[1] > c[3] - c[1] + 0.00000000001 + ) { + const [endCenterX, endCenterY] = [ + (second[0] + second[2]) / 2, + (second[1] + second[3]) / 2, + ]; + if (b[0] > a[2] && a[1] > b[3]) { + // BOTTOM LEFT + const cX = first[2] + (second[0] - first[2]) / 2; + const cY = second[3] + (first[1] - second[3]) / 2; + + if ( + vectorCross( + vector(a[2] - endCenterX, a[1] - endCenterY), + vector(a[0] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { + return [ + [first[0], first[1], cX, first[3]], + [cX, second[1], second[2], second[3]], + ]; + } + + return [ + [first[0], cY, first[2], first[3]], + [second[0], second[1], second[2], cY], + ]; + } else if (a[2] < b[0] && a[3] < b[1]) { + // TOP LEFT + const cX = first[2] + (second[0] - first[2]) / 2; + const cY = first[3] + (second[1] - first[3]) / 2; + + if ( + vectorCross( + vector(a[0] - endCenterX, a[1] - endCenterY), + vector(a[2] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { + return [ + [first[0], first[1], first[2], cY], + [second[0], cY, second[2], second[3]], + ]; + } + + return [ + [first[0], first[1], cX, first[3]], + [cX, second[1], second[2], second[3]], + ]; + } else if (a[0] > b[2] && a[3] < b[1]) { + // TOP RIGHT + const cX = second[2] + (first[0] - second[2]) / 2; + const cY = first[3] + (second[1] - first[3]) / 2; + + if ( + vectorCross( + vector(a[2] - endCenterX, a[1] - endCenterY), + vector(a[0] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { + return [ + [cX, first[1], first[2], first[3]], + [second[0], second[1], cX, second[3]], + ]; + } + + return [ + [first[0], first[1], first[2], cY], + [second[0], cY, second[2], second[3]], + ]; + } else if (a[0] > b[2] && a[1] > b[3]) { + // BOTTOM RIGHT + const cX = second[2] + (first[0] - second[2]) / 2; + const cY = second[3] + (first[1] - second[3]) / 2; + + if ( + vectorCross( + vector(a[0] - endCenterX, a[1] - endCenterY), + vector(a[2] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { + return [ + [cX, first[1], first[2], first[3]], + [second[0], second[1], cX, second[3]], + ]; + } + + return [ + [first[0], cY, first[2], first[3]], + [second[0], second[1], second[2], cY], + ]; + } + } + + return [first, second]; +}; + +/** + * Calculates the grid which is used as nodes at + * the grid line intersections by the A* algorithm. + * + * NOTE: This is not a uniform grid. It is built at + * various intersections of bounding boxes. + */ +const calculateGrid = ( + aabbs: Bounds[], + start: GlobalPoint, + startHeading: Heading, + end: GlobalPoint, + endHeading: Heading, + common: Bounds, +): Grid => { + const horizontal = new Set<number>(); + const vertical = new Set<number>(); + + if (startHeading === HEADING_LEFT || startHeading === HEADING_RIGHT) { + vertical.add(start[1]); + } else { + horizontal.add(start[0]); + } + if (endHeading === HEADING_LEFT || endHeading === HEADING_RIGHT) { + vertical.add(end[1]); + } else { + horizontal.add(end[0]); + } + + aabbs.forEach((aabb) => { + horizontal.add(aabb[0]); + horizontal.add(aabb[2]); + vertical.add(aabb[1]); + vertical.add(aabb[3]); + }); + + horizontal.add(common[0]); + horizontal.add(common[2]); + vertical.add(common[1]); + vertical.add(common[3]); + + const _vertical = Array.from(vertical).sort((a, b) => a - b); + const _horizontal = Array.from(horizontal).sort((a, b) => a - b); + + return { + row: _vertical.length, + col: _horizontal.length, + data: _vertical.flatMap((y, row) => + _horizontal.map( + (x, col): Node => ({ + f: 0, + g: 0, + h: 0, + closed: false, + visited: false, + parent: null, + addr: [col, row] as GridAddress, + pos: [x, y] as GlobalPoint, + }), + ), + ), + }; +}; + +const getDonglePosition = ( + bounds: Bounds, + heading: Heading, + p: GlobalPoint, +): GlobalPoint => { + switch (heading) { + case HEADING_UP: + return pointFrom(p[0], bounds[1]); + case HEADING_RIGHT: + return pointFrom(bounds[2], p[1]); + case HEADING_DOWN: + return pointFrom(p[0], bounds[3]); + } + return pointFrom(bounds[0], p[1]); +}; + +const estimateSegmentCount = ( + start: Node, + end: Node, + startHeading: Heading, + endHeading: Heading, +) => { + if (endHeading === HEADING_RIGHT) { + switch (startHeading) { + case HEADING_RIGHT: { + if (start.pos[0] >= end.pos[0]) { + return 4; + } + if (start.pos[1] === end.pos[1]) { + return 0; + } + return 2; + } + case HEADING_UP: + if (start.pos[1] > end.pos[1] && start.pos[0] < end.pos[0]) { + return 1; + } + return 3; + case HEADING_DOWN: + if (start.pos[1] < end.pos[1] && start.pos[0] < end.pos[0]) { + return 1; + } + return 3; + case HEADING_LEFT: + if (start.pos[1] === end.pos[1]) { + return 4; + } + return 2; + } + } else if (endHeading === HEADING_LEFT) { + switch (startHeading) { + case HEADING_RIGHT: + if (start.pos[1] === end.pos[1]) { + return 4; + } + return 2; + case HEADING_UP: + if (start.pos[1] > end.pos[1] && start.pos[0] > end.pos[0]) { + return 1; + } + return 3; + case HEADING_DOWN: + if (start.pos[1] < end.pos[1] && start.pos[0] > end.pos[0]) { + return 1; + } + return 3; + case HEADING_LEFT: + if (start.pos[0] <= end.pos[0]) { + return 4; + } + if (start.pos[1] === end.pos[1]) { + return 0; + } + return 2; + } + } else if (endHeading === HEADING_UP) { + switch (startHeading) { + case HEADING_RIGHT: + if (start.pos[1] > end.pos[1] && start.pos[0] < end.pos[0]) { + return 1; + } + return 3; + case HEADING_UP: + if (start.pos[1] >= end.pos[1]) { + return 4; + } + if (start.pos[0] === end.pos[0]) { + return 0; + } + return 2; + case HEADING_DOWN: + if (start.pos[0] === end.pos[0]) { + return 4; + } + return 2; + case HEADING_LEFT: + if (start.pos[1] > end.pos[1] && start.pos[0] > end.pos[0]) { + return 1; + } + return 3; + } + } else if (endHeading === HEADING_DOWN) { + switch (startHeading) { + case HEADING_RIGHT: + if (start.pos[1] < end.pos[1] && start.pos[0] < end.pos[0]) { + return 1; + } + return 3; + case HEADING_UP: + if (start.pos[0] === end.pos[0]) { + return 4; + } + return 2; + case HEADING_DOWN: + if (start.pos[1] <= end.pos[1]) { + return 4; + } + if (start.pos[0] === end.pos[0]) { + return 0; + } + return 2; + case HEADING_LEFT: + if (start.pos[1] < end.pos[1] && start.pos[0] > end.pos[0]) { + return 1; + } + return 3; + } + } + return 0; +}; + +/** + * Get neighboring points for a gived grid address + */ +const getNeighbors = ([col, row]: [number, number], grid: Grid) => + [ + gridNodeFromAddr([col, row - 1], grid), + gridNodeFromAddr([col + 1, row], grid), + gridNodeFromAddr([col, row + 1], grid), + gridNodeFromAddr([col - 1, row], grid), + ] as [Node | null, Node | null, Node | null, Node | null]; + +const gridNodeFromAddr = ( + [col, row]: [col: number, row: number], + grid: Grid, +): Node | null => { + if (col < 0 || col >= grid.col || row < 0 || row >= grid.row) { + return null; + } + + return grid.data[row * grid.col + col] ?? null; +}; + +/** + * Get node for global point on canvas (if exists) + */ +const pointToGridNode = (point: GlobalPoint, grid: Grid): Node | null => { + for (let col = 0; col < grid.col; col++) { + for (let row = 0; row < grid.row; row++) { + const candidate = gridNodeFromAddr([col, row], grid); + if ( + candidate && + point[0] === candidate.pos[0] && + point[1] === candidate.pos[1] + ) { + return candidate; + } + } + } + + return null; +}; + +const commonAABB = (aabbs: Bounds[]): Bounds => [ + Math.min(...aabbs.map((aabb) => aabb[0])), + Math.min(...aabbs.map((aabb) => aabb[1])), + Math.max(...aabbs.map((aabb) => aabb[2])), + Math.max(...aabbs.map((aabb) => aabb[3])), +]; + +/// #region Utils + +const getBindableElementForId = ( + id: string, + elementsMap: ElementsMap, +): ExcalidrawBindableElement | null => { + const element = elementsMap.get(id); + if (element && isBindableElement(element)) { + return element; + } + + return null; +}; + +const normalizeArrowElementUpdate = ( + global: GlobalPoint[], + nextFixedSegments: readonly FixedSegment[] | null, + startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], + endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], +): { + points: LocalPoint[]; + x: number; + y: number; + width: number; + height: number; + fixedSegments: readonly FixedSegment[] | null; + startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"]; + endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"]; +} => { + const offsetX = global[0][0]; + const offsetY = global[0][1]; + let points = global.map((p) => + pointTranslate<GlobalPoint, LocalPoint>( + p, + vectorScale(vectorFromPoint(global[0]), -1), + ), + ); + + // NOTE (mtolmacs): This is a temporary check to see if the normalization + // creates an overly large arrow. This should be removed once we have an answer. + if ( + offsetX < -MAX_POS || + offsetX > MAX_POS || + offsetY < -MAX_POS || + offsetY > MAX_POS || + offsetX + points[points.length - 1][0] < -MAX_POS || + offsetY + points[points.length - 1][0] > MAX_POS || + offsetX + points[points.length - 1][1] < -MAX_POS || + offsetY + points[points.length - 1][1] > MAX_POS + ) { + console.error( + "Elbow arrow normalization is outside reasonable bounds (> 1e6)", + { + x: offsetX, + y: offsetY, + points, + ...getSizeFromPoints(points), + }, + ); + } + + points = points.map(([x, y]) => + pointFrom<LocalPoint>(clamp(x, -1e6, 1e6), clamp(y, -1e6, 1e6)), + ); + + return { + points, + x: clamp(offsetX, -1e6, 1e6), + y: clamp(offsetY, -1e6, 1e6), + fixedSegments: + (nextFixedSegments?.length ?? 0) > 0 ? nextFixedSegments : null, + ...getSizeFromPoints(points), + startIsSpecial, + endIsSpecial, + }; +}; + +const getElbowArrowCornerPoints = (points: GlobalPoint[]): GlobalPoint[] => { + if (points.length > 1) { + let previousHorizontal = + Math.abs(points[0][1] - points[1][1]) < + Math.abs(points[0][0] - points[1][0]); + + return points.filter((p, idx) => { + // The very first and last points are always kept + if (idx === 0 || idx === points.length - 1) { + return true; + } + + const next = points[idx + 1]; + const nextHorizontal = + Math.abs(p[1] - next[1]) < Math.abs(p[0] - next[0]); + if (previousHorizontal === nextHorizontal) { + previousHorizontal = nextHorizontal; + return false; + } + + previousHorizontal = nextHorizontal; + return true; + }); + } + + return points; +}; + +const removeElbowArrowShortSegments = ( + points: GlobalPoint[], +): GlobalPoint[] => { + if (points.length >= 4) { + return points.filter((p, idx) => { + if (idx === 0 || idx === points.length - 1) { + return true; + } + + const prev = points[idx - 1]; + const prevDist = pointDistance(prev, p); + return prevDist > DEDUP_TRESHOLD; + }); + } + + return points; +}; + +const neighborIndexToHeading = (idx: number): Heading => { + switch (idx) { + case 0: + return HEADING_UP; + case 1: + return HEADING_RIGHT; + case 2: + return HEADING_DOWN; + } + return HEADING_LEFT; +}; + +const getGlobalPoint = ( + arrow: ExcalidrawElbowArrowElement, + startOrEnd: "start" | "end", + fixedPointRatio: [number, number] | undefined | null, + initialPoint: GlobalPoint, + boundElement?: ExcalidrawBindableElement | null, + hoveredElement?: ExcalidrawBindableElement | null, + isDragging?: boolean, +): GlobalPoint => { + if (isDragging) { + if (hoveredElement) { + const snapPoint = bindPointToSnapToElementOutline( + arrow, + hoveredElement, + startOrEnd, + ); + + return snapToMid(hoveredElement, snapPoint); + } + + return initialPoint; + } + + if (boundElement) { + const fixedGlobalPoint = getGlobalFixedPointForBindableElement( + fixedPointRatio || [0, 0], + boundElement, + ); + + // NOTE: Resize scales the binding position point too, so we need to update it + return Math.abs( + distanceToBindableElement(boundElement, fixedGlobalPoint) - + FIXED_BINDING_DISTANCE, + ) > 0.01 + ? bindPointToSnapToElementOutline(arrow, boundElement, startOrEnd) + : fixedGlobalPoint; + } + + return initialPoint; +}; + +const getBindPointHeading = ( + p: GlobalPoint, + otherPoint: GlobalPoint, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + hoveredElement: ExcalidrawBindableElement | null | undefined, + origPoint: GlobalPoint, +): Heading => + getHeadingForElbowArrowSnap( + p, + otherPoint, + hoveredElement, + hoveredElement && + aabbForElement( + hoveredElement, + Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [ + number, + number, + number, + number, + ], + ), + elementsMap, + origPoint, + ); + +const getHoveredElement = ( + origPoint: GlobalPoint, + elementsMap: NonDeletedSceneElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + zoom?: AppState["zoom"], +) => { + return getHoveredElementForBinding( + tupleToCoors(origPoint), + elements, + elementsMap, + zoom, + true, + true, + ); +}; + +const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean => + a[0] === b[0] && a[1] === b[1]; + +export const validateElbowPoints = <P extends GlobalPoint | LocalPoint>( + points: readonly P[], + tolerance: number = DEDUP_TRESHOLD, +) => + points + .slice(1) + .map( + (p, i) => + Math.abs(p[0] - points[i][0]) < tolerance || + Math.abs(p[1] - points[i][1]) < tolerance, + ) + .every(Boolean); diff --git a/packages/excalidraw/element/elementLink.ts b/packages/excalidraw/element/elementLink.ts new file mode 100644 index 0000000..991f9ca --- /dev/null +++ b/packages/excalidraw/element/elementLink.ts @@ -0,0 +1,102 @@ +/** + * Create and link between shapes. + */ + +import { ELEMENT_LINK_KEY } from "../constants"; +import { normalizeLink } from "../data/url"; +import { elementsAreInSameGroup } from "../groups"; +import type { AppProps, AppState } from "../types"; +import type { ExcalidrawElement } from "./types"; + +export const defaultGetElementLinkFromSelection: Exclude< + AppProps["generateLinkForSelection"], + undefined +> = (id, type) => { + const url = window.location.href; + + try { + const link = new URL(url); + link.searchParams.set(ELEMENT_LINK_KEY, id); + + return normalizeLink(link.toString()); + } catch (error) { + console.error(error); + } + + return normalizeLink(url); +}; + +export const getLinkIdAndTypeFromSelection = ( + selectedElements: ExcalidrawElement[], + appState: AppState, +): { + id: string; + type: "element" | "group"; +} | null => { + if ( + selectedElements.length > 0 && + canCreateLinkFromElements(selectedElements) + ) { + if (selectedElements.length === 1) { + return { + id: selectedElements[0].id, + type: "element", + }; + } + + if (selectedElements.length > 1) { + const selectedGroupId = Object.keys(appState.selectedGroupIds)[0]; + + if (selectedGroupId) { + return { + id: selectedGroupId, + type: "group", + }; + } + return { + id: selectedElements[0].groupIds[0], + type: "group", + }; + } + } + + return null; +}; + +export const canCreateLinkFromElements = ( + selectedElements: ExcalidrawElement[], +) => { + if (selectedElements.length === 1) { + return true; + } + + if (selectedElements.length > 1 && elementsAreInSameGroup(selectedElements)) { + return true; + } + + return false; +}; + +export const isElementLink = (url: string) => { + try { + const _url = new URL(url); + return ( + _url.searchParams.has(ELEMENT_LINK_KEY) && + _url.host === window.location.host + ); + } catch (error) { + return false; + } +}; + +export const parseElementLinkFromURL = (url: string) => { + try { + const { searchParams } = new URL(url); + if (searchParams.has(ELEMENT_LINK_KEY)) { + const id = searchParams.get(ELEMENT_LINK_KEY); + return id; + } + } catch {} + + return null; +}; diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts new file mode 100644 index 0000000..8265a0b --- /dev/null +++ b/packages/excalidraw/element/embeddable.ts @@ -0,0 +1,444 @@ +import { register } from "../actions/register"; +import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants"; +import type { ExcalidrawProps } from "../types"; +import { escapeDoubleQuotes, getFontString, updateActiveTool } from "../utils"; +import { setCursorForShape } from "../cursor"; +import { newTextElement } from "./newElement"; +import { wrapText } from "./textWrapping"; +import { isIframeElement } from "./typeChecks"; +import type { + ExcalidrawElement, + ExcalidrawIframeLikeElement, + IframeData, +} from "./types"; +import type { MarkRequired } from "../utility-types"; +import { CaptureUpdateAction } from "../store"; + +type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">; + +const embeddedLinkCache = new Map<string, IframeDataWithSandbox>(); + +const RE_YOUTUBE = + /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/; + +const RE_VIMEO = + /^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/; +const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/; + +const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/; +const RE_GH_GIST_EMBED = + /^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i; + +// not anchored to start to allow <blockquote> twitter embeds +const RE_TWITTER = + /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/; +const RE_TWITTER_EMBED = + /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i; + +const RE_VALTOWN = + /^https:\/\/(?:www\.)?val\.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/; + +const RE_GENERIC_EMBED = + /^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i; + +const RE_GIPHY = + /giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/; + +const RE_REDDIT = + /^(?:http(?:s)?:\/\/)?(?:www\.)?reddit\.com\/r\/([a-zA-Z0-9_]+)\/comments\/([a-zA-Z0-9_]+)\/([a-zA-Z0-9_]+)\/?(?:\?[^#\s]*)?(?:#[^\s]*)?$/; + +const RE_REDDIT_EMBED = + /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i; + +const ALLOWED_DOMAINS = new Set([ + "youtube.com", + "youtu.be", + "vimeo.com", + "player.vimeo.com", + "figma.com", + "link.excalidraw.com", + "gist.github.com", + "twitter.com", + "x.com", + "*.simplepdf.eu", + "stackblitz.com", + "val.town", + "giphy.com", + "reddit.com", +]); + +const ALLOW_SAME_ORIGIN = new Set([ + "youtube.com", + "youtu.be", + "vimeo.com", + "player.vimeo.com", + "figma.com", + "twitter.com", + "x.com", + "*.simplepdf.eu", + "stackblitz.com", + "reddit.com", +]); + +export const createSrcDoc = (body: string) => { + return `<html><body>${body}</body></html>`; +}; + +export const getEmbedLink = ( + link: string | null | undefined, +): IframeDataWithSandbox | null => { + if (!link) { + return null; + } + + if (embeddedLinkCache.has(link)) { + return embeddedLinkCache.get(link)!; + } + + const originalLink = link; + + const allowSameOrigin = ALLOW_SAME_ORIGIN.has( + matchHostname(link, ALLOW_SAME_ORIGIN) || "", + ); + + let type: "video" | "generic" = "generic"; + let aspectRatio = { w: 560, h: 840 }; + const ytLink = link.match(RE_YOUTUBE); + if (ytLink?.[2]) { + const time = ytLink[3] ? `&start=${ytLink[3]}` : ``; + const isPortrait = link.includes("shorts"); + type = "video"; + switch (ytLink[1]) { + case "embed/": + case "watch?v=": + case "shorts/": + link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`; + break; + case "playlist?list=": + case "embed/videoseries?list=": + link = `https://www.youtube.com/embed/videoseries?list=${ytLink[2]}&enablejsapi=1${time}`; + break; + default: + link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`; + break; + } + aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 }; + embeddedLinkCache.set(originalLink, { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }); + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; + } + + const vimeoLink = link.match(RE_VIMEO); + if (vimeoLink?.[1]) { + const target = vimeoLink?.[1]; + const error = !/^\d+$/.test(target) + ? new URIError("Invalid embed link format") + : undefined; + type = "video"; + link = `https://player.vimeo.com/video/${target}?api=1`; + aspectRatio = { w: 560, h: 315 }; + //warning deliberately ommited so it is displayed only once per link + //same link next time will be served from cache + embeddedLinkCache.set(originalLink, { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }); + return { + link, + intrinsicSize: aspectRatio, + type, + error, + sandbox: { allowSameOrigin }, + }; + } + + const figmaLink = link.match(RE_FIGMA); + if (figmaLink) { + type = "generic"; + link = `https://www.figma.com/embed?embed_host=share&url=${encodeURIComponent( + link, + )}`; + aspectRatio = { w: 550, h: 550 }; + embeddedLinkCache.set(originalLink, { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }); + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; + } + + const valLink = link.match(RE_VALTOWN); + if (valLink) { + link = + valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed"); + embeddedLinkCache.set(originalLink, { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }); + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; + } + + if (RE_TWITTER.test(link)) { + const postId = link.match(RE_TWITTER)![1]; + // the embed srcdoc still supports twitter.com domain only. + // Note that we don't attempt to parse the username as it can consist of + // non-latin1 characters, and the username in the url can be set to anything + // without affecting the embed. + const safeURL = escapeDoubleQuotes( + `https://twitter.com/x/status/${postId}`, + ); + + const ret: IframeDataWithSandbox = { + type: "document", + srcdoc: (theme: string) => + createSrcDoc( + `<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${safeURL}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`, + ), + intrinsicSize: { w: 480, h: 480 }, + sandbox: { allowSameOrigin }, + }; + embeddedLinkCache.set(originalLink, ret); + return ret; + } + + if (RE_REDDIT.test(link)) { + const [, page, postId, title] = link.match(RE_REDDIT)!; + const safeURL = escapeDoubleQuotes( + `https://reddit.com/r/${page}/comments/${postId}/${title}`, + ); + const ret: IframeDataWithSandbox = { + type: "document", + srcdoc: (theme: string) => + createSrcDoc( + `<blockquote class="reddit-embed-bq" data-embed-theme="${theme}"><a href="${safeURL}"></a><br></blockquote><script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>`, + ), + intrinsicSize: { w: 480, h: 480 }, + sandbox: { allowSameOrigin }, + }; + embeddedLinkCache.set(originalLink, ret); + return ret; + } + + if (RE_GH_GIST.test(link)) { + const [, user, gistId] = link.match(RE_GH_GIST)!; + const safeURL = escapeDoubleQuotes( + `https://gist.github.com/${user}/${gistId}`, + ); + const ret: IframeDataWithSandbox = { + type: "document", + srcdoc: () => + createSrcDoc(` + <script src="${safeURL}.js"></script> + <style type="text/css"> + * { margin: 0px; } + table, .gist { height: 100%; } + .gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; } + </style> + `), + intrinsicSize: { w: 550, h: 720 }, + sandbox: { allowSameOrigin }, + }; + embeddedLinkCache.set(link, ret); + return ret; + } + + embeddedLinkCache.set(link, { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }); + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; +}; + +export const createPlaceholderEmbeddableLabel = ( + element: ExcalidrawIframeLikeElement, +): ExcalidrawElement => { + let text: string; + if (isIframeElement(element)) { + text = "IFrame element"; + } else { + text = + !element.link || element?.link === "" ? "Empty Web-Embed" : element.link; + } + + const fontSize = Math.max( + Math.min(element.width / 2, element.width / text.length), + element.width / 30, + ); + const fontFamily = FONT_FAMILY.Helvetica; + + const fontString = getFontString({ + fontSize, + fontFamily, + }); + + return newTextElement({ + x: element.x + element.width / 2, + y: element.y + element.height / 2, + strokeColor: + element.strokeColor !== "transparent" ? element.strokeColor : "black", + backgroundColor: "transparent", + fontFamily, + fontSize, + text: wrapText(text, fontString, element.width - 20), + textAlign: "center", + verticalAlign: VERTICAL_ALIGN.MIDDLE, + angle: element.angle ?? 0, + }); +}; + +export const actionSetEmbeddableAsActiveTool = register({ + name: "setEmbeddableAsActiveTool", + trackEvent: { category: "toolbar" }, + target: "Tool", + label: "toolBar.embeddable", + perform: (elements, appState, _, app) => { + const nextActiveTool = updateActiveTool(appState, { + type: "embeddable", + }); + + setCursorForShape(app.canvas, { + ...appState, + activeTool: nextActiveTool, + }); + + return { + elements, + appState: { + ...appState, + activeTool: updateActiveTool(appState, { + type: "embeddable", + }), + }, + captureUpdate: CaptureUpdateAction.EVENTUALLY, + }; + }, +}); + +const matchHostname = ( + url: string, + /** using a Set assumes it already contains normalized bare domains */ + allowedHostnames: Set<string> | string, +): string | null => { + try { + const { hostname } = new URL(url); + + const bareDomain = hostname.replace(/^www\./, ""); + + if (allowedHostnames instanceof Set) { + if (ALLOWED_DOMAINS.has(bareDomain)) { + return bareDomain; + } + + const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace( + /^([^.]+)/, + "*", + ); + if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) { + return bareDomainWithFirstSubdomainWildcarded; + } + return null; + } + + const bareAllowedHostname = allowedHostnames.replace(/^www\./, ""); + if (bareDomain === bareAllowedHostname) { + return bareAllowedHostname; + } + } catch (error) { + // ignore + } + return null; +}; + +export const maybeParseEmbedSrc = (str: string): string => { + const twitterMatch = str.match(RE_TWITTER_EMBED); + if (twitterMatch && twitterMatch.length === 2) { + return twitterMatch[1]; + } + + const redditMatch = str.match(RE_REDDIT_EMBED); + if (redditMatch && redditMatch.length === 2) { + return redditMatch[1]; + } + + const gistMatch = str.match(RE_GH_GIST_EMBED); + if (gistMatch && gistMatch.length === 2) { + return gistMatch[1]; + } + + if (RE_GIPHY.test(str)) { + return `https://giphy.com/embed/${RE_GIPHY.exec(str)![1]}`; + } + + const match = str.match(RE_GENERIC_EMBED); + if (match && match.length === 2) { + return match[1]; + } + + return str; +}; + +export const embeddableURLValidator = ( + url: string | null | undefined, + validateEmbeddable: ExcalidrawProps["validateEmbeddable"], +): boolean => { + if (!url) { + return false; + } + if (validateEmbeddable != null) { + if (typeof validateEmbeddable === "function") { + const ret = validateEmbeddable(url); + // if return value is undefined, leave validation to default + if (typeof ret === "boolean") { + return ret; + } + } else if (typeof validateEmbeddable === "boolean") { + return validateEmbeddable; + } else if (validateEmbeddable instanceof RegExp) { + return validateEmbeddable.test(url); + } else if (Array.isArray(validateEmbeddable)) { + for (const domain of validateEmbeddable) { + if (domain instanceof RegExp) { + if (url.match(domain)) { + return true; + } + } else if (matchHostname(url, domain)) { + return true; + } + } + return false; + } + } + + return !!matchHostname(url, ALLOWED_DOMAINS); +}; diff --git a/packages/excalidraw/element/flowchart.test.tsx b/packages/excalidraw/element/flowchart.test.tsx new file mode 100644 index 0000000..d47c850 --- /dev/null +++ b/packages/excalidraw/element/flowchart.test.tsx @@ -0,0 +1,403 @@ +import { render, unmountComponent } from "../tests/test-utils"; +import { reseed } from "../random"; +import { UI, Keyboard, Pointer } from "../tests/helpers/ui"; +import { Excalidraw } from "../index"; +import { API } from "../tests/helpers/api"; +import { KEYS } from "../keys"; + +unmountComponent(); + +const { h } = window; +const mouse = new Pointer("mouse"); + +beforeEach(async () => { + localStorage.clear(); + reseed(7); + mouse.reset(); + + await render(<Excalidraw handleKeyboardGlobally={true} />); + h.state.width = 1000; + h.state.height = 1000; + + // The bounds of hand-drawn linear elements may change after flipping, so + // removing this style for testing + UI.clickTool("arrow"); + UI.clickByTitle("Architect"); + UI.clickTool("selection"); +}); + +describe("flow chart creation", () => { + beforeEach(() => { + API.clearSelection(); + const rectangle = API.createElement({ + type: "rectangle", + width: 200, + height: 100, + }); + + API.setElements([rectangle]); + API.setSelectedElements([rectangle]); + }); + + // multiple at once + it("create multiple successor nodes at once", () => { + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + expect(h.elements.length).toBe(5); + expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3); + expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2); + }); + + it("when directions are changed, only the last same directions will apply", () => { + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + Keyboard.keyPress(KEYS.ARROW_RIGHT); + Keyboard.keyPress(KEYS.ARROW_RIGHT); + + Keyboard.keyPress(KEYS.ARROW_LEFT); + + Keyboard.keyPress(KEYS.ARROW_UP); + Keyboard.keyPress(KEYS.ARROW_UP); + Keyboard.keyPress(KEYS.ARROW_UP); + }); + + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + expect(h.elements.length).toBe(7); + expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4); + expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3); + }); + + it("when escaped, no nodes will be created", () => { + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_UP); + Keyboard.keyPress(KEYS.ARROW_DOWN); + }); + + Keyboard.keyPress(KEYS.ESCAPE); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + expect(h.elements.length).toBe(1); + }); + + it("create nodes one at a time", () => { + const initialNode = h.elements[0]; + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + expect(h.elements.length).toBe(3); + expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(2); + expect(h.elements.filter((el) => el.type === "arrow").length).toBe(1); + + const firstChildNode = h.elements.filter( + (el) => el.type === "rectangle" && el.id !== initialNode.id, + )[0]; + expect(firstChildNode).not.toBe(null); + expect(firstChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]); + + API.setSelectedElements([initialNode]); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + expect(h.elements.length).toBe(5); + expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3); + expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2); + + const secondChildNode = h.elements.filter( + (el) => + el.type === "rectangle" && + el.id !== initialNode.id && + el.id !== firstChildNode.id, + )[0]; + expect(secondChildNode).not.toBe(null); + expect(secondChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]); + + API.setSelectedElements([initialNode]); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + expect(h.elements.length).toBe(7); + expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4); + expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3); + + const thirdChildNode = h.elements.filter( + (el) => + el.type === "rectangle" && + el.id !== initialNode.id && + el.id !== firstChildNode.id && + el.id !== secondChildNode.id, + )[0]; + + expect(thirdChildNode).not.toBe(null); + expect(thirdChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]); + + expect(firstChildNode.x).toBe(secondChildNode.x); + expect(secondChildNode.x).toBe(thirdChildNode.x); + }); +}); + +describe("flow chart navigation", () => { + it("single node at each level", () => { + /** + * ▨ -> ▨ -> ▨ -> ▨ -> ▨ + */ + + API.clearSelection(); + const rectangle = API.createElement({ + type: "rectangle", + width: 200, + height: 100, + }); + + API.setElements([rectangle]); + API.setSelectedElements([rectangle]); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(5); + expect(h.elements.filter((el) => el.type === "arrow").length).toBe(4); + + // all the way to the left, gets us to the first node + Keyboard.withModifierKeys({ alt: true }, () => { + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + }); + Keyboard.keyUp(KEYS.ALT); + expect(h.state.selectedElementIds[rectangle.id]).toBe(true); + + // all the way to the right, gets us to the last node + const rightMostNode = h.elements[h.elements.length - 2]; + expect(rightMostNode); + expect(rightMostNode.type).toBe("rectangle"); + Keyboard.withModifierKeys({ alt: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + Keyboard.keyPress(KEYS.ARROW_RIGHT); + Keyboard.keyPress(KEYS.ARROW_RIGHT); + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.ALT); + expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true); + }); + + it("multiple nodes at each level", () => { + /** + * from the perspective of the first node, there're four layers, and + * there are four nodes at the second layer + * + * -> ▨ + * ▨ -> ▨ -> ▨ -> ▨ -> ▨ + * -> ▨ + * -> ▨ + */ + + API.clearSelection(); + const rectangle = API.createElement({ + type: "rectangle", + width: 200, + height: 100, + }); + + API.setElements([rectangle]); + API.setSelectedElements([rectangle]); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + const secondNode = h.elements[1]; + const rightMostNode = h.elements[h.elements.length - 2]; + + API.setSelectedElements([rectangle]); + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + API.setSelectedElements([rectangle]); + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + API.setSelectedElements([rectangle]); + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + API.setSelectedElements([rectangle]); + + // because of same level cycling, + // going right five times should take us back to the second node again + Keyboard.withModifierKeys({ alt: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + Keyboard.keyPress(KEYS.ARROW_RIGHT); + Keyboard.keyPress(KEYS.ARROW_RIGHT); + Keyboard.keyPress(KEYS.ARROW_RIGHT); + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.ALT); + expect(h.state.selectedElementIds[secondNode.id]).toBe(true); + + // from the second node, going right three times should take us to the rightmost node + Keyboard.withModifierKeys({ alt: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + Keyboard.keyPress(KEYS.ARROW_RIGHT); + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.ALT); + expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true); + + Keyboard.withModifierKeys({ alt: true }, () => { + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + }); + Keyboard.keyUp(KEYS.ALT); + expect(h.state.selectedElementIds[rectangle.id]).toBe(true); + }); + + it("take the most obvious link when possible", () => { + /** + * ▨ → ▨ ▨ → ▨ + * ↓ ↑ + * ▨ → ▨ + */ + + API.clearSelection(); + const rectangle = API.createElement({ + type: "rectangle", + width: 200, + height: 100, + }); + + API.setElements([rectangle]); + API.setSelectedElements([rectangle]); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_DOWN); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_UP); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.CTRL_OR_CMD); + + // last node should be the one that's selected + const rightMostNode = h.elements[h.elements.length - 2]; + expect(rightMostNode.type).toBe("rectangle"); + expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true); + + Keyboard.withModifierKeys({ alt: true }, () => { + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + Keyboard.keyPress(KEYS.ARROW_LEFT); + }); + Keyboard.keyUp(KEYS.ALT); + + expect(h.state.selectedElementIds[rectangle.id]).toBe(true); + + // going any direction takes us to the predecessor as well + const predecessorToRightMostNode = h.elements[h.elements.length - 4]; + expect(predecessorToRightMostNode.type).toBe("rectangle"); + + API.setSelectedElements([rightMostNode]); + Keyboard.withModifierKeys({ alt: true }, () => { + Keyboard.keyPress(KEYS.ARROW_RIGHT); + }); + Keyboard.keyUp(KEYS.ALT); + expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true); + expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe( + true, + ); + API.setSelectedElements([rightMostNode]); + Keyboard.withModifierKeys({ alt: true }, () => { + Keyboard.keyPress(KEYS.ARROW_UP); + }); + Keyboard.keyUp(KEYS.ALT); + expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true); + expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe( + true, + ); + API.setSelectedElements([rightMostNode]); + Keyboard.withModifierKeys({ alt: true }, () => { + Keyboard.keyPress(KEYS.ARROW_DOWN); + }); + Keyboard.keyUp(KEYS.ALT); + expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true); + expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe( + true, + ); + }); +}); diff --git a/packages/excalidraw/element/flowchart.ts b/packages/excalidraw/element/flowchart.ts new file mode 100644 index 0000000..09f006d --- /dev/null +++ b/packages/excalidraw/element/flowchart.ts @@ -0,0 +1,715 @@ +import { + HEADING_DOWN, + HEADING_LEFT, + HEADING_RIGHT, + HEADING_UP, + compareHeading, + headingForPointFromElement, + type Heading, +} from "./heading"; +import { bindLinearElement } from "./binding"; +import { LinearElementEditor } from "./linearElementEditor"; +import { newArrowElement, newElement } from "./newElement"; +import { + type ElementsMap, + type ExcalidrawBindableElement, + type ExcalidrawElement, + type ExcalidrawFlowchartNodeElement, + type NonDeletedSceneElementsMap, + type Ordered, + type OrderedExcalidrawElement, +} from "./types"; +import { KEYS } from "../keys"; +import type { AppState, PendingExcalidrawElements } from "../types"; +import { mutateElement } from "./mutateElement"; +import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame"; +import { + isBindableElement, + isElbowArrow, + isFrameElement, + isFlowchartNodeElement, +} from "./typeChecks"; +import { invariant, toBrandedType } from "../utils"; +import { pointFrom, type LocalPoint } from "@excalidraw/math"; +import { aabbForElement } from "../shapes"; +import { updateElbowArrowPoints } from "./elbowArrow"; + +type LinkDirection = "up" | "right" | "down" | "left"; + +const VERTICAL_OFFSET = 100; +const HORIZONTAL_OFFSET = 100; + +export const getLinkDirectionFromKey = (key: string): LinkDirection => { + switch (key) { + case KEYS.ARROW_UP: + return "up"; + case KEYS.ARROW_DOWN: + return "down"; + case KEYS.ARROW_RIGHT: + return "right"; + case KEYS.ARROW_LEFT: + return "left"; + default: + return "right"; + } +}; + +const getNodeRelatives = ( + type: "predecessors" | "successors", + node: ExcalidrawBindableElement, + elementsMap: ElementsMap, + direction: LinkDirection, +) => { + const items = [...elementsMap.values()].reduce( + (acc: { relative: ExcalidrawBindableElement; heading: Heading }[], el) => { + let oppositeBinding; + if ( + isElbowArrow(el) && + // we want check existence of the opposite binding, in the direction + // we're interested in + (oppositeBinding = + el[type === "predecessors" ? "startBinding" : "endBinding"]) && + // similarly, we need to filter only arrows bound to target node + el[type === "predecessors" ? "endBinding" : "startBinding"] + ?.elementId === node.id + ) { + const relative = elementsMap.get(oppositeBinding.elementId); + + if (!relative) { + return acc; + } + + invariant( + isBindableElement(relative), + "not an ExcalidrawBindableElement", + ); + + const edgePoint = ( + type === "predecessors" ? el.points[el.points.length - 1] : [0, 0] + ) as Readonly<LocalPoint>; + + const heading = headingForPointFromElement(node, aabbForElement(node), [ + edgePoint[0] + el.x, + edgePoint[1] + el.y, + ] as Readonly<LocalPoint>); + + acc.push({ + relative, + heading, + }); + } + return acc; + }, + [], + ); + + switch (direction) { + case "up": + return items + .filter((item) => compareHeading(item.heading, HEADING_UP)) + .map((item) => item.relative); + case "down": + return items + .filter((item) => compareHeading(item.heading, HEADING_DOWN)) + .map((item) => item.relative); + case "right": + return items + .filter((item) => compareHeading(item.heading, HEADING_RIGHT)) + .map((item) => item.relative); + case "left": + return items + .filter((item) => compareHeading(item.heading, HEADING_LEFT)) + .map((item) => item.relative); + } +}; + +const getSuccessors = ( + node: ExcalidrawBindableElement, + elementsMap: ElementsMap, + direction: LinkDirection, +) => { + return getNodeRelatives("successors", node, elementsMap, direction); +}; + +export const getPredecessors = ( + node: ExcalidrawBindableElement, + elementsMap: ElementsMap, + direction: LinkDirection, +) => { + return getNodeRelatives("predecessors", node, elementsMap, direction); +}; + +const getOffsets = ( + element: ExcalidrawFlowchartNodeElement, + linkedNodes: ExcalidrawElement[], + direction: LinkDirection, +) => { + const _HORIZONTAL_OFFSET = HORIZONTAL_OFFSET + element.width; + + // check if vertical space or horizontal space is available first + if (direction === "up" || direction === "down") { + const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height; + // check vertical space + const minX = element.x; + const maxX = element.x + element.width; + + // vertical space is available + if ( + linkedNodes.every( + (linkedNode) => + linkedNode.x + linkedNode.width < minX || linkedNode.x > maxX, + ) + ) { + return { + x: 0, + y: _VERTICAL_OFFSET * (direction === "up" ? -1 : 1), + }; + } + } else if (direction === "right" || direction === "left") { + const minY = element.y; + const maxY = element.y + element.height; + + if ( + linkedNodes.every( + (linkedNode) => + linkedNode.y + linkedNode.height < minY || linkedNode.y > maxY, + ) + ) { + return { + x: + (HORIZONTAL_OFFSET + element.width) * (direction === "left" ? -1 : 1), + y: 0, + }; + } + } + + if (direction === "up" || direction === "down") { + const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height; + const y = linkedNodes.length === 0 ? _VERTICAL_OFFSET : _VERTICAL_OFFSET; + const x = + linkedNodes.length === 0 + ? 0 + : (linkedNodes.length + 1) % 2 === 0 + ? ((linkedNodes.length + 1) / 2) * _HORIZONTAL_OFFSET + : (linkedNodes.length / 2) * _HORIZONTAL_OFFSET * -1; + + if (direction === "up") { + return { + x, + y: y * -1, + }; + } + + return { + x, + y, + }; + } + + const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height; + const x = + (linkedNodes.length === 0 ? HORIZONTAL_OFFSET : HORIZONTAL_OFFSET) + + element.width; + const y = + linkedNodes.length === 0 + ? 0 + : (linkedNodes.length + 1) % 2 === 0 + ? ((linkedNodes.length + 1) / 2) * _VERTICAL_OFFSET + : (linkedNodes.length / 2) * _VERTICAL_OFFSET * -1; + + if (direction === "left") { + return { + x: x * -1, + y, + }; + } + return { + x, + y, + }; +}; + +const addNewNode = ( + element: ExcalidrawFlowchartNodeElement, + elementsMap: ElementsMap, + appState: AppState, + direction: LinkDirection, +) => { + const successors = getSuccessors(element, elementsMap, direction); + const predeccessors = getPredecessors(element, elementsMap, direction); + + const offsets = getOffsets( + element, + [...successors, ...predeccessors], + direction, + ); + + const nextNode = newElement({ + type: element.type, + x: element.x + offsets.x, + y: element.y + offsets.y, + // TODO: extract this to a util + width: element.width, + height: element.height, + roundness: element.roundness, + roughness: element.roughness, + backgroundColor: element.backgroundColor, + strokeColor: element.strokeColor, + strokeWidth: element.strokeWidth, + opacity: element.opacity, + fillStyle: element.fillStyle, + strokeStyle: element.strokeStyle, + }); + + invariant( + isFlowchartNodeElement(nextNode), + "not an ExcalidrawFlowchartNodeElement", + ); + + const bindingArrow = createBindingArrow( + element, + nextNode, + elementsMap, + direction, + appState, + ); + + return { + nextNode, + bindingArrow, + }; +}; + +export const addNewNodes = ( + startNode: ExcalidrawFlowchartNodeElement, + elementsMap: ElementsMap, + appState: AppState, + direction: LinkDirection, + numberOfNodes: number, +) => { + // always start from 0 and distribute evenly + const newNodes: ExcalidrawElement[] = []; + + for (let i = 0; i < numberOfNodes; i++) { + let nextX: number; + let nextY: number; + if (direction === "left" || direction === "right") { + const totalHeight = + VERTICAL_OFFSET * (numberOfNodes - 1) + + numberOfNodes * startNode.height; + + const startY = startNode.y + startNode.height / 2 - totalHeight / 2; + + let offsetX = HORIZONTAL_OFFSET + startNode.width; + if (direction === "left") { + offsetX *= -1; + } + nextX = startNode.x + offsetX; + const offsetY = (VERTICAL_OFFSET + startNode.height) * i; + nextY = startY + offsetY; + } else { + const totalWidth = + HORIZONTAL_OFFSET * (numberOfNodes - 1) + + numberOfNodes * startNode.width; + const startX = startNode.x + startNode.width / 2 - totalWidth / 2; + let offsetY = VERTICAL_OFFSET + startNode.height; + + if (direction === "up") { + offsetY *= -1; + } + nextY = startNode.y + offsetY; + const offsetX = (HORIZONTAL_OFFSET + startNode.width) * i; + nextX = startX + offsetX; + } + + const nextNode = newElement({ + type: startNode.type, + x: nextX, + y: nextY, + // TODO: extract this to a util + width: startNode.width, + height: startNode.height, + roundness: startNode.roundness, + roughness: startNode.roughness, + backgroundColor: startNode.backgroundColor, + strokeColor: startNode.strokeColor, + strokeWidth: startNode.strokeWidth, + opacity: startNode.opacity, + fillStyle: startNode.fillStyle, + strokeStyle: startNode.strokeStyle, + }); + + invariant( + isFlowchartNodeElement(nextNode), + "not an ExcalidrawFlowchartNodeElement", + ); + + const bindingArrow = createBindingArrow( + startNode, + nextNode, + elementsMap, + direction, + appState, + ); + + newNodes.push(nextNode); + newNodes.push(bindingArrow); + } + + return newNodes; +}; + +const createBindingArrow = ( + startBindingElement: ExcalidrawFlowchartNodeElement, + endBindingElement: ExcalidrawFlowchartNodeElement, + elementsMap: ElementsMap, + direction: LinkDirection, + appState: AppState, +) => { + let startX: number; + let startY: number; + + const PADDING = 6; + + switch (direction) { + case "up": { + startX = startBindingElement.x + startBindingElement.width / 2; + startY = startBindingElement.y - PADDING; + break; + } + case "down": { + startX = startBindingElement.x + startBindingElement.width / 2; + startY = startBindingElement.y + startBindingElement.height + PADDING; + break; + } + case "right": { + startX = startBindingElement.x + startBindingElement.width + PADDING; + startY = startBindingElement.y + startBindingElement.height / 2; + break; + } + case "left": { + startX = startBindingElement.x - PADDING; + startY = startBindingElement.y + startBindingElement.height / 2; + break; + } + } + + let endX: number; + let endY: number; + + switch (direction) { + case "up": { + endX = endBindingElement.x + endBindingElement.width / 2 - startX; + endY = endBindingElement.y + endBindingElement.height - startY + PADDING; + break; + } + case "down": { + endX = endBindingElement.x + endBindingElement.width / 2 - startX; + endY = endBindingElement.y - startY - PADDING; + break; + } + case "right": { + endX = endBindingElement.x - startX - PADDING; + endY = endBindingElement.y - startY + endBindingElement.height / 2; + break; + } + case "left": { + endX = endBindingElement.x + endBindingElement.width - startX + PADDING; + endY = endBindingElement.y - startY + endBindingElement.height / 2; + break; + } + } + + const bindingArrow = newArrowElement({ + type: "arrow", + x: startX, + y: startY, + startArrowhead: null, + endArrowhead: appState.currentItemEndArrowhead, + strokeColor: startBindingElement.strokeColor, + strokeStyle: startBindingElement.strokeStyle, + strokeWidth: startBindingElement.strokeWidth, + opacity: startBindingElement.opacity, + roughness: startBindingElement.roughness, + points: [pointFrom(0, 0), pointFrom(endX, endY)], + elbowed: true, + }); + + bindLinearElement( + bindingArrow, + startBindingElement, + "start", + elementsMap as NonDeletedSceneElementsMap, + ); + bindLinearElement( + bindingArrow, + endBindingElement, + "end", + elementsMap as NonDeletedSceneElementsMap, + ); + + const changedElements = new Map<string, OrderedExcalidrawElement>(); + changedElements.set( + startBindingElement.id, + startBindingElement as OrderedExcalidrawElement, + ); + changedElements.set( + endBindingElement.id, + endBindingElement as OrderedExcalidrawElement, + ); + changedElements.set( + bindingArrow.id, + bindingArrow as OrderedExcalidrawElement, + ); + + LinearElementEditor.movePoints(bindingArrow, [ + { + index: 1, + point: bindingArrow.points[1], + }, + ]); + + const update = updateElbowArrowPoints( + bindingArrow, + toBrandedType<NonDeletedSceneElementsMap>( + new Map([ + ...elementsMap.entries(), + [startBindingElement.id, startBindingElement], + [endBindingElement.id, endBindingElement], + [bindingArrow.id, bindingArrow], + ] as [string, Ordered<ExcalidrawElement>][]), + ), + { points: bindingArrow.points }, + ); + + return { + ...bindingArrow, + ...update, + }; +}; + +export class FlowChartNavigator { + isExploring: boolean = false; + // nodes that are ONE link away (successor and predecessor both included) + private sameLevelNodes: ExcalidrawElement[] = []; + private sameLevelIndex: number = 0; + // set it to the opposite of the defalut creation direction + private direction: LinkDirection | null = null; + // for speedier navigation + private visitedNodes: Set<ExcalidrawElement["id"]> = new Set(); + + clear() { + this.isExploring = false; + this.sameLevelNodes = []; + this.sameLevelIndex = 0; + this.direction = null; + this.visitedNodes.clear(); + } + + exploreByDirection( + element: ExcalidrawElement, + elementsMap: ElementsMap, + direction: LinkDirection, + ): ExcalidrawElement["id"] | null { + if (!isBindableElement(element)) { + return null; + } + + // clear if going at a different direction + if (direction !== this.direction) { + this.clear(); + } + + // add the current node to the visited + if (!this.visitedNodes.has(element.id)) { + this.visitedNodes.add(element.id); + } + + /** + * CASE: + * - already started exploring, AND + * - there are multiple nodes at the same level, AND + * - still going at the same direction, AND + * + * RESULT: + * - loop through nodes at the same level + * + * WHY: + * - provides user the capability to loop through nodes at the same level + */ + if ( + this.isExploring && + direction === this.direction && + this.sameLevelNodes.length > 1 + ) { + this.sameLevelIndex = + (this.sameLevelIndex + 1) % this.sameLevelNodes.length; + + return this.sameLevelNodes[this.sameLevelIndex].id; + } + + const nodes = [ + ...getSuccessors(element, elementsMap, direction), + ...getPredecessors(element, elementsMap, direction), + ]; + + /** + * CASE: + * - just started exploring at the given direction + * + * RESULT: + * - go to the first node in the given direction + */ + if (nodes.length > 0) { + this.sameLevelIndex = 0; + this.isExploring = true; + this.sameLevelNodes = nodes; + this.direction = direction; + this.visitedNodes.add(nodes[0].id); + + return nodes[0].id; + } + + /** + * CASE: + * - (just started exploring or still going at the same direction) OR + * - there're no nodes at the given direction + * + * RESULT: + * - go to some other unvisited linked node + * + * WHY: + * - provide a speedier navigation from a given node to some predecessor + * without the user having to change arrow key + */ + if (direction === this.direction || !this.isExploring) { + if (!this.isExploring) { + // just started and no other nodes at the given direction + // so the current node is technically the first visited node + // (this is needed so that we don't get stuck between looping through ) + this.visitedNodes.add(element.id); + } + + const otherDirections: LinkDirection[] = [ + "up", + "right", + "down", + "left", + ].filter((dir): dir is LinkDirection => dir !== direction); + + const otherLinkedNodes = otherDirections + .map((dir) => [ + ...getSuccessors(element, elementsMap, dir), + ...getPredecessors(element, elementsMap, dir), + ]) + .flat() + .filter((linkedNode) => !this.visitedNodes.has(linkedNode.id)); + + for (const linkedNode of otherLinkedNodes) { + if (!this.visitedNodes.has(linkedNode.id)) { + this.visitedNodes.add(linkedNode.id); + this.isExploring = true; + this.direction = direction; + return linkedNode.id; + } + } + } + + return null; + } +} + +export class FlowChartCreator { + isCreatingChart: boolean = false; + private numberOfNodes: number = 0; + private direction: LinkDirection | null = "right"; + pendingNodes: PendingExcalidrawElements | null = null; + + createNodes( + startNode: ExcalidrawFlowchartNodeElement, + elementsMap: ElementsMap, + appState: AppState, + direction: LinkDirection, + ) { + if (direction !== this.direction) { + const { nextNode, bindingArrow } = addNewNode( + startNode, + elementsMap, + appState, + direction, + ); + + this.numberOfNodes = 1; + this.isCreatingChart = true; + this.direction = direction; + this.pendingNodes = [nextNode, bindingArrow]; + } else { + this.numberOfNodes += 1; + const newNodes = addNewNodes( + startNode, + elementsMap, + appState, + direction, + this.numberOfNodes, + ); + + this.isCreatingChart = true; + this.direction = direction; + this.pendingNodes = newNodes; + } + + // add pending nodes to the same frame as the start node + // if every pending node is at least intersecting with the frame + if (startNode.frameId) { + const frame = elementsMap.get(startNode.frameId); + + invariant( + frame && isFrameElement(frame), + "not an ExcalidrawFrameElement", + ); + + if ( + frame && + this.pendingNodes.every( + (node) => + elementsAreInFrameBounds([node], frame, elementsMap) || + elementOverlapsWithFrame(node, frame, elementsMap), + ) + ) { + this.pendingNodes = this.pendingNodes.map((node) => + mutateElement( + node, + { + frameId: startNode.frameId, + }, + false, + ), + ); + } + } + } + + clear() { + this.isCreatingChart = false; + this.pendingNodes = null; + this.direction = null; + this.numberOfNodes = 0; + } +} + +export const isNodeInFlowchart = ( + element: ExcalidrawFlowchartNodeElement, + elementsMap: ElementsMap, +) => { + for (const [, el] of elementsMap) { + if ( + el.type === "arrow" && + (el.startBinding?.elementId === element.id || + el.endBinding?.elementId === element.id) + ) { + return true; + } + } + + return false; +}; diff --git a/packages/excalidraw/element/heading.ts b/packages/excalidraw/element/heading.ts new file mode 100644 index 0000000..94e8633 --- /dev/null +++ b/packages/excalidraw/element/heading.ts @@ -0,0 +1,202 @@ +import type { + LocalPoint, + GlobalPoint, + Triangle, + Vector, + Radians, +} from "@excalidraw/math"; +import { + pointFrom, + pointRotateRads, + pointScaleFromOrigin, + radiansToDegrees, + triangleIncludesPoint, + vectorFromPoint, +} from "@excalidraw/math"; +import { getCenterForBounds, type Bounds } from "./bounds"; +import type { ExcalidrawBindableElement } from "./types"; + +export const HEADING_RIGHT = [1, 0] as Heading; +export const HEADING_DOWN = [0, 1] as Heading; +export const HEADING_LEFT = [-1, 0] as Heading; +export const HEADING_UP = [0, -1] as Heading; +export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1]; + +export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>( + a: Point, + b: Point, +) => { + const angle = radiansToDegrees( + Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians, + ); + if (angle >= 315 || angle < 45) { + return HEADING_UP; + } else if (angle >= 45 && angle < 135) { + return HEADING_RIGHT; + } else if (angle >= 135 && angle < 225) { + return HEADING_DOWN; + } + return HEADING_LEFT; +}; + +export const vectorToHeading = (vec: Vector): Heading => { + const [x, y] = vec; + const absX = Math.abs(x); + const absY = Math.abs(y); + if (x > absY) { + return HEADING_RIGHT; + } else if (x <= -absY) { + return HEADING_LEFT; + } else if (y > absX) { + return HEADING_DOWN; + } + return HEADING_UP; +}; + +export const headingForPoint = <P extends GlobalPoint | LocalPoint>( + p: P, + o: P, +) => vectorToHeading(vectorFromPoint<P>(p, o)); + +export const headingForPointIsHorizontal = <P extends GlobalPoint | LocalPoint>( + p: P, + o: P, +) => headingIsHorizontal(headingForPoint<P>(p, o)); + +export const compareHeading = (a: Heading, b: Heading) => + a[0] === b[0] && a[1] === b[1]; + +export const headingIsHorizontal = (a: Heading) => + compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT); + +export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a); + +// Gets the heading for the point by creating a bounding box around the rotated +// close fitting bounding box, then creating 4 search cones around the center of +// the external bbox. +export const headingForPointFromElement = < + Point extends GlobalPoint | LocalPoint, +>( + element: Readonly<ExcalidrawBindableElement>, + aabb: Readonly<Bounds>, + p: Readonly<Point>, +): Heading => { + const SEARCH_CONE_MULTIPLIER = 2; + + const midPoint = getCenterForBounds(aabb); + + if (element.type === "diamond") { + if (p[0] < element.x) { + return HEADING_LEFT; + } else if (p[1] < element.y) { + return HEADING_UP; + } else if (p[0] > element.x + element.width) { + return HEADING_RIGHT; + } else if (p[1] > element.y + element.height) { + return HEADING_DOWN; + } + + const top = pointRotateRads( + pointScaleFromOrigin( + pointFrom(element.x + element.width / 2, element.y), + midPoint, + SEARCH_CONE_MULTIPLIER, + ), + midPoint, + element.angle, + ); + const right = pointRotateRads( + pointScaleFromOrigin( + pointFrom(element.x + element.width, element.y + element.height / 2), + midPoint, + SEARCH_CONE_MULTIPLIER, + ), + midPoint, + element.angle, + ); + const bottom = pointRotateRads( + pointScaleFromOrigin( + pointFrom(element.x + element.width / 2, element.y + element.height), + midPoint, + SEARCH_CONE_MULTIPLIER, + ), + midPoint, + element.angle, + ); + const left = pointRotateRads( + pointScaleFromOrigin( + pointFrom(element.x, element.y + element.height / 2), + midPoint, + SEARCH_CONE_MULTIPLIER, + ), + midPoint, + element.angle, + ); + + if ( + triangleIncludesPoint<Point>([top, right, midPoint] as Triangle<Point>, p) + ) { + return headingForDiamond(top, right); + } else if ( + triangleIncludesPoint<Point>( + [right, bottom, midPoint] as Triangle<Point>, + p, + ) + ) { + return headingForDiamond(right, bottom); + } else if ( + triangleIncludesPoint<Point>( + [bottom, left, midPoint] as Triangle<Point>, + p, + ) + ) { + return headingForDiamond(bottom, left); + } + + return headingForDiamond(left, top); + } + + const topLeft = pointScaleFromOrigin( + pointFrom(aabb[0], aabb[1]), + midPoint, + SEARCH_CONE_MULTIPLIER, + ) as Point; + const topRight = pointScaleFromOrigin( + pointFrom(aabb[2], aabb[1]), + midPoint, + SEARCH_CONE_MULTIPLIER, + ) as Point; + const bottomLeft = pointScaleFromOrigin( + pointFrom(aabb[0], aabb[3]), + midPoint, + SEARCH_CONE_MULTIPLIER, + ) as Point; + const bottomRight = pointScaleFromOrigin( + pointFrom(aabb[2], aabb[3]), + midPoint, + SEARCH_CONE_MULTIPLIER, + ) as Point; + + return triangleIncludesPoint<Point>( + [topLeft, topRight, midPoint] as Triangle<Point>, + p, + ) + ? HEADING_UP + : triangleIncludesPoint<Point>( + [topRight, bottomRight, midPoint] as Triangle<Point>, + p, + ) + ? HEADING_RIGHT + : triangleIncludesPoint<Point>( + [bottomRight, bottomLeft, midPoint] as Triangle<Point>, + p, + ) + ? HEADING_DOWN + : HEADING_LEFT; +}; + +export const flipHeading = (h: Heading): Heading => + [ + h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1, + h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1, + ] as Heading; diff --git a/packages/excalidraw/element/image.ts b/packages/excalidraw/element/image.ts new file mode 100644 index 0000000..32644b6 --- /dev/null +++ b/packages/excalidraw/element/image.ts @@ -0,0 +1,146 @@ +// ----------------------------------------------------------------------------- +// ExcalidrawImageElement & related helpers +// ----------------------------------------------------------------------------- + +import { MIME_TYPES, SVG_NS } from "../constants"; +import type { AppClassProperties, DataURL, BinaryFiles } from "../types"; +import { isInitializedImageElement } from "./typeChecks"; +import type { + ExcalidrawElement, + FileId, + InitializedExcalidrawImageElement, +} from "./types"; + +export const loadHTMLImageElement = (dataURL: DataURL) => { + return new Promise<HTMLImageElement>((resolve, reject) => { + const image = new Image(); + image.onload = () => { + resolve(image); + }; + image.onerror = (error) => { + reject(error); + }; + image.src = dataURL; + }); +}; + +/** NOTE: updates cache even if already populated with given image. Thus, + * you should filter out the images upstream if you want to optimize this. */ +export const updateImageCache = async ({ + fileIds, + files, + imageCache, +}: { + fileIds: FileId[]; + files: BinaryFiles; + imageCache: AppClassProperties["imageCache"]; +}) => { + const updatedFiles = new Map<FileId, true>(); + const erroredFiles = new Map<FileId, true>(); + + await Promise.all( + fileIds.reduce((promises, fileId) => { + const fileData = files[fileId as string]; + if (fileData && !updatedFiles.has(fileId)) { + updatedFiles.set(fileId, true); + return promises.concat( + (async () => { + try { + if (fileData.mimeType === MIME_TYPES.binary) { + throw new Error("Only images can be added to ImageCache"); + } + + const imagePromise = loadHTMLImageElement(fileData.dataURL); + const data = { + image: imagePromise, + mimeType: fileData.mimeType, + } as const; + // store the promise immediately to indicate there's an in-progress + // initialization + imageCache.set(fileId, data); + + const image = await imagePromise; + + imageCache.set(fileId, { ...data, image }); + } catch (error: any) { + erroredFiles.set(fileId, true); + } + })(), + ); + } + return promises; + }, [] as Promise<any>[]), + ); + + return { + imageCache, + /** includes errored files because they cache was updated nonetheless */ + updatedFiles, + /** files that failed when creating HTMLImageElement */ + erroredFiles, + }; +}; + +export const getInitializedImageElements = ( + elements: readonly ExcalidrawElement[], +) => + elements.filter((element) => + isInitializedImageElement(element), + ) as InitializedExcalidrawImageElement[]; + +export const isHTMLSVGElement = (node: Node | null): node is SVGElement => { + // lower-casing due to XML/HTML convention differences + // https://johnresig.com/blog/nodename-case-sensitivity + return node?.nodeName.toLowerCase() === "svg"; +}; + +export const normalizeSVG = (SVGString: string) => { + const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg); + const svg = doc.querySelector("svg"); + const errorNode = doc.querySelector("parsererror"); + if (errorNode || !isHTMLSVGElement(svg)) { + throw new Error("Invalid SVG"); + } else { + if (!svg.hasAttribute("xmlns")) { + svg.setAttribute("xmlns", SVG_NS); + } + + let width = svg.getAttribute("width"); + let height = svg.getAttribute("height"); + + // Do not use % or auto values for width/height + // to avoid scaling issues when rendering at different sizes/zoom levels + if (width?.includes("%") || width === "auto") { + width = null; + } + if (height?.includes("%") || height === "auto") { + height = null; + } + + const viewBox = svg.getAttribute("viewBox"); + + if (!width || !height) { + width = width || "50"; + height = height || "50"; + + if (viewBox) { + const match = viewBox.match( + /\d+ +\d+ +(\d+(?:\.\d+)?) +(\d+(?:\.\d+)?)/, + ); + if (match) { + [, width, height] = match; + } + } + + svg.setAttribute("width", width); + svg.setAttribute("height", height); + } + + // Make sure viewBox is set + if (!viewBox) { + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + } + + return svg.outerHTML; + } +}; diff --git a/packages/excalidraw/element/index.ts b/packages/excalidraw/element/index.ts new file mode 100644 index 0000000..a9b7476 --- /dev/null +++ b/packages/excalidraw/element/index.ts @@ -0,0 +1,122 @@ +import type { + ExcalidrawElement, + NonDeletedExcalidrawElement, + NonDeleted, +} from "./types"; +import { isInvisiblySmallElement } from "./sizeHelpers"; +import { isLinearElementType } from "./typeChecks"; + +export { + newElement, + newTextElement, + refreshTextDimensions, + newLinearElement, + newArrowElement, + newImageElement, + duplicateElement, +} from "./newElement"; +export { + getElementAbsoluteCoords, + getElementBounds, + getCommonBounds, + getDiamondPoints, + getArrowheadPoints, + getClosestElementBounds, +} from "./bounds"; + +export { + OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, + getTransformHandlesFromCoords, + getTransformHandles, +} from "./transformHandles"; +export { + resizeTest, + getCursorForResizingElement, + getElementWithTransformHandleType, + getTransformHandleTypeFromCoords, +} from "./resizeTest"; +export { + transformElements, + getResizeOffsetXY, + getResizeArrowDirection, +} from "./resizeElements"; +export { + dragSelectedElements, + getDragOffsetXY, + dragNewElement, +} from "./dragElements"; +export { isTextElement, isExcalidrawElement } from "./typeChecks"; +export { redrawTextBoundingBox, getTextFromElements } from "./textElement"; +export { + getPerfectElementSize, + getLockedLinearCursorAlignSize, + isInvisiblySmallElement, + resizePerfectLineForNWHandler, + getNormalizedDimensions, +} from "./sizeHelpers"; +export { showSelectedShapeActions } from "./showSelectedShapeActions"; + +/** + * @deprecated unsafe, use hashElementsVersion instead + */ +export const getSceneVersion = (elements: readonly ExcalidrawElement[]) => + elements.reduce((acc, el) => acc + el.version, 0); + +/** + * Hashes elements' versionNonce (using djb2 algo). Order of elements matters. + */ +export const hashElementsVersion = ( + elements: readonly ExcalidrawElement[], +): number => { + let hash = 5381; + for (let i = 0; i < elements.length; i++) { + hash = (hash << 5) + hash + elements[i].versionNonce; + } + return hash >>> 0; // Ensure unsigned 32-bit integer +}; + +// string hash function (using djb2). Not cryptographically secure, use only +// for versioning and such. +export const hashString = (s: string): number => { + let hash: number = 5381; + for (let i = 0; i < s.length; i++) { + const char: number = s.charCodeAt(i); + hash = (hash << 5) + hash + char; + } + return hash >>> 0; // Ensure unsigned 32-bit integer +}; + +export const getVisibleElements = (elements: readonly ExcalidrawElement[]) => + elements.filter( + (el) => !el.isDeleted && !isInvisiblySmallElement(el), + ) as readonly NonDeletedExcalidrawElement[]; + +export const getNonDeletedElements = <T extends ExcalidrawElement>( + elements: readonly T[], +) => + elements.filter((element) => !element.isDeleted) as readonly NonDeleted<T>[]; + +export const isNonDeletedElement = <T extends ExcalidrawElement>( + element: T, +): element is NonDeleted<T> => !element.isDeleted; + +const _clearElements = ( + elements: readonly ExcalidrawElement[], +): ExcalidrawElement[] => + getNonDeletedElements(elements).map((element) => + isLinearElementType(element.type) + ? { ...element, lastCommittedPoint: null } + : element, + ); + +export const clearElementsForDatabase = ( + elements: readonly ExcalidrawElement[], +) => _clearElements(elements); + +export const clearElementsForExport = ( + elements: readonly ExcalidrawElement[], +) => _clearElements(elements); + +export const clearElementsForLocalStorage = ( + elements: readonly ExcalidrawElement[], +) => _clearElements(elements); diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts new file mode 100644 index 0000000..b616268 --- /dev/null +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -0,0 +1,1824 @@ +import type { + NonDeleted, + ExcalidrawLinearElement, + ExcalidrawElement, + PointBinding, + ExcalidrawBindableElement, + ExcalidrawTextElementWithContainer, + ElementsMap, + NonDeletedSceneElementsMap, + FixedPointBinding, + SceneElementsMap, + FixedSegment, + ExcalidrawElbowArrowElement, +} from "./types"; +import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; +import type { Bounds } from "./bounds"; +import { getElementPointsCoords, getMinMaxXYFromCurvePathOps } from "./bounds"; +import type { + AppState, + PointerCoords, + InteractiveCanvasAppState, + AppClassProperties, + NullableGridSize, + Zoom, +} from "../types"; +import { mutateElement } from "./mutateElement"; + +import { + bindOrUnbindLinearElement, + getHoveredElementForBinding, + isBindingEnabled, +} from "./binding"; +import { invariant, tupleToCoors } from "../utils"; +import { + isBindingElement, + isElbowArrow, + isFixedPointBinding, +} from "./typeChecks"; +import { KEYS, shouldRotateWithDiscreteAngle } from "../keys"; +import { getBoundTextElement, handleBindTextResize } from "./textElement"; +import { DRAGGING_THRESHOLD } from "../constants"; +import type { Mutable } from "../utility-types"; +import { ShapeCache } from "../scene/ShapeCache"; +import type { Store } from "../store"; +import type Scene from "../scene/Scene"; +import type { Radians } from "@excalidraw/math"; +import { + pointCenter, + pointFrom, + pointRotateRads, + pointsEqual, + type GlobalPoint, + type LocalPoint, + pointDistance, + vectorFromPoint, +} from "@excalidraw/math"; +import { + getBezierCurveLength, + getBezierXY, + getControlPointsForBezierCurve, + isPathALoop, + mapIntervalToBezierT, +} from "../shapes"; +import { getGridPoint } from "../snapping"; +import { headingIsHorizontal, vectorToHeading } from "./heading"; +import { getCurvePathOps } from "@excalidraw/utils/geometry/shape"; + +const editorMidPointsCache: { + version: number | null; + points: (GlobalPoint | null)[]; + zoom: number | null; +} = { version: null, points: [], zoom: null }; +export class LinearElementEditor { + public readonly elementId: ExcalidrawElement["id"] & { + _brand: "excalidrawLinearElementId"; + }; + /** indices */ + public readonly selectedPointsIndices: readonly number[] | null; + + public readonly pointerDownState: Readonly<{ + prevSelectedPointsIndices: readonly number[] | null; + /** index */ + lastClickedPoint: number; + lastClickedIsEndPoint: boolean; + origin: Readonly<{ x: number; y: number }> | null; + segmentMidpoint: { + value: GlobalPoint | null; + index: number | null; + added: boolean; + }; + }>; + + /** whether you're dragging a point */ + public readonly isDragging: boolean; + public readonly lastUncommittedPoint: LocalPoint | null; + public readonly pointerOffset: Readonly<{ x: number; y: number }>; + public readonly startBindingElement: + | ExcalidrawBindableElement + | null + | "keep"; + public readonly endBindingElement: ExcalidrawBindableElement | null | "keep"; + public readonly hoverPointIndex: number; + public readonly segmentMidPointHoveredCoords: GlobalPoint | null; + public readonly elbowed: boolean; + + constructor(element: NonDeleted<ExcalidrawLinearElement>) { + this.elementId = element.id as string & { + _brand: "excalidrawLinearElementId"; + }; + if (!pointsEqual(element.points[0], pointFrom(0, 0))) { + console.error("Linear element is not normalized", Error().stack); + } + + this.selectedPointsIndices = null; + this.lastUncommittedPoint = null; + this.isDragging = false; + this.pointerOffset = { x: 0, y: 0 }; + this.startBindingElement = "keep"; + this.endBindingElement = "keep"; + this.pointerDownState = { + prevSelectedPointsIndices: null, + lastClickedPoint: -1, + lastClickedIsEndPoint: false, + origin: null, + + segmentMidpoint: { + value: null, + index: null, + added: false, + }, + }; + this.hoverPointIndex = -1; + this.segmentMidPointHoveredCoords = null; + this.elbowed = isElbowArrow(element) && element.elbowed; + } + + // --------------------------------------------------------------------------- + // static methods + // --------------------------------------------------------------------------- + + static POINT_HANDLE_SIZE = 10; + /** + * @param id the `elementId` from the instance of this class (so that we can + * statically guarantee this method returns an ExcalidrawLinearElement) + */ + static getElement<T extends ExcalidrawLinearElement>( + id: InstanceType<typeof LinearElementEditor>["elementId"], + elementsMap: ElementsMap, + ): T | null { + const element = elementsMap.get(id); + if (element) { + return element as NonDeleted<T>; + } + return null; + } + + static handleBoxSelection( + event: PointerEvent, + appState: AppState, + setState: React.Component<any, AppState>["setState"], + elementsMap: NonDeletedSceneElementsMap, + ) { + if (!appState.editingLinearElement || !appState.selectionElement) { + return false; + } + const { editingLinearElement } = appState; + const { selectedPointsIndices, elementId } = editingLinearElement; + + const element = LinearElementEditor.getElement(elementId, elementsMap); + if (!element) { + return false; + } + + const [selectionX1, selectionY1, selectionX2, selectionY2] = + getElementAbsoluteCoords(appState.selectionElement, elementsMap); + + const pointsSceneCoords = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); + + const nextSelectedPoints = pointsSceneCoords + .reduce((acc: number[], point, index) => { + if ( + (point[0] >= selectionX1 && + point[0] <= selectionX2 && + point[1] >= selectionY1 && + point[1] <= selectionY2) || + (event.shiftKey && selectedPointsIndices?.includes(index)) + ) { + acc.push(index); + } + + return acc; + }, []) + .filter((index) => { + if ( + isElbowArrow(element) && + index !== 0 && + index !== element.points.length - 1 + ) { + return false; + } + return true; + }); + + setState({ + editingLinearElement: { + ...editingLinearElement, + selectedPointsIndices: nextSelectedPoints.length + ? nextSelectedPoints + : null, + }, + }); + } + + /** + * @returns whether point was dragged + */ + static handlePointDragging( + event: PointerEvent, + app: AppClassProperties, + scenePointerX: number, + scenePointerY: number, + maybeSuggestBinding: ( + element: NonDeleted<ExcalidrawLinearElement>, + pointSceneCoords: { x: number; y: number }[], + ) => void, + linearElementEditor: LinearElementEditor, + scene: Scene, + ): boolean { + if (!linearElementEditor) { + return false; + } + const { elementId } = linearElementEditor; + const elementsMap = scene.getNonDeletedElementsMap(); + const element = LinearElementEditor.getElement(elementId, elementsMap); + if (!element) { + return false; + } + + if ( + isElbowArrow(element) && + !linearElementEditor.pointerDownState.lastClickedIsEndPoint && + linearElementEditor.pointerDownState.lastClickedPoint !== 0 + ) { + return false; + } + + const selectedPointsIndices = isElbowArrow(element) + ? linearElementEditor.selectedPointsIndices + ?.reduce( + (startEnd, index) => + (index === 0 + ? [0, startEnd[1]] + : [startEnd[0], element.points.length - 1]) as [ + boolean | number, + boolean | number, + ], + [false, false] as [number | boolean, number | boolean], + ) + .filter( + (idx: number | boolean): idx is number => typeof idx === "number", + ) + : linearElementEditor.selectedPointsIndices; + const lastClickedPoint = isElbowArrow(element) + ? linearElementEditor.pointerDownState.lastClickedPoint > 0 + ? element.points.length - 1 + : 0 + : linearElementEditor.pointerDownState.lastClickedPoint; + + // point that's being dragged (out of all selected points) + const draggingPoint = element.points[lastClickedPoint] as + | [number, number] + | undefined; + + if (selectedPointsIndices && draggingPoint) { + if ( + shouldRotateWithDiscreteAngle(event) && + selectedPointsIndices.length === 1 && + element.points.length > 1 + ) { + const selectedIndex = selectedPointsIndices[0]; + const referencePoint = + element.points[selectedIndex === 0 ? 1 : selectedIndex - 1]; + + const [width, height] = LinearElementEditor._getShiftLockedDelta( + element, + elementsMap, + referencePoint, + pointFrom(scenePointerX, scenePointerY), + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ); + + LinearElementEditor.movePoints(element, [ + { + index: selectedIndex, + point: pointFrom( + width + referencePoint[0], + height + referencePoint[1], + ), + isDragging: selectedIndex === lastClickedPoint, + }, + ]); + } else { + const newDraggingPointPosition = LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ); + + const deltaX = newDraggingPointPosition[0] - draggingPoint[0]; + const deltaY = newDraggingPointPosition[1] - draggingPoint[1]; + + LinearElementEditor.movePoints( + element, + selectedPointsIndices.map((pointIndex) => { + const newPointPosition: LocalPoint = + pointIndex === lastClickedPoint + ? LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ) + : pointFrom( + element.points[pointIndex][0] + deltaX, + element.points[pointIndex][1] + deltaY, + ); + return { + index: pointIndex, + point: newPointPosition, + isDragging: pointIndex === lastClickedPoint, + }; + }), + ); + } + + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + handleBindTextResize(element, elementsMap, false); + } + + // suggest bindings for first and last point if selected + if (isBindingElement(element, false)) { + const coords: { x: number; y: number }[] = []; + + const firstSelectedIndex = selectedPointsIndices[0]; + if (firstSelectedIndex === 0) { + coords.push( + tupleToCoors( + LinearElementEditor.getPointGlobalCoordinates( + element, + element.points[0], + elementsMap, + ), + ), + ); + } + + const lastSelectedIndex = + selectedPointsIndices[selectedPointsIndices.length - 1]; + if (lastSelectedIndex === element.points.length - 1) { + coords.push( + tupleToCoors( + LinearElementEditor.getPointGlobalCoordinates( + element, + element.points[lastSelectedIndex], + elementsMap, + ), + ), + ); + } + + if (coords.length) { + maybeSuggestBinding(element, coords); + } + } + + return true; + } + + return false; + } + + static handlePointerUp( + event: PointerEvent, + editingLinearElement: LinearElementEditor, + appState: AppState, + scene: Scene, + ): LinearElementEditor { + const elementsMap = scene.getNonDeletedElementsMap(); + const elements = scene.getNonDeletedElements(); + + const { elementId, selectedPointsIndices, isDragging, pointerDownState } = + editingLinearElement; + const element = LinearElementEditor.getElement(elementId, elementsMap); + if (!element) { + return editingLinearElement; + } + + const bindings: Mutable< + Partial< + Pick< + InstanceType<typeof LinearElementEditor>, + "startBindingElement" | "endBindingElement" + > + > + > = {}; + + if (isDragging && selectedPointsIndices) { + for (const selectedPoint of selectedPointsIndices) { + if ( + selectedPoint === 0 || + selectedPoint === element.points.length - 1 + ) { + if (isPathALoop(element.points, appState.zoom.value)) { + LinearElementEditor.movePoints(element, [ + { + index: selectedPoint, + point: + selectedPoint === 0 + ? element.points[element.points.length - 1] + : element.points[0], + }, + ]); + } + + const bindingElement = isBindingEnabled(appState) + ? getHoveredElementForBinding( + tupleToCoors( + LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + selectedPoint!, + elementsMap, + ), + ), + elements, + elementsMap, + appState.zoom, + isElbowArrow(element), + isElbowArrow(element), + ) + : null; + + bindings[ + selectedPoint === 0 ? "startBindingElement" : "endBindingElement" + ] = bindingElement; + } + } + } + + return { + ...editingLinearElement, + ...bindings, + // if clicking without previously dragging a point(s), and not holding + // shift, deselect all points except the one clicked. If holding shift, + // toggle the point. + selectedPointsIndices: + isDragging || event.shiftKey + ? !isDragging && + event.shiftKey && + pointerDownState.prevSelectedPointsIndices?.includes( + pointerDownState.lastClickedPoint, + ) + ? selectedPointsIndices && + selectedPointsIndices.filter( + (pointIndex) => + pointIndex !== pointerDownState.lastClickedPoint, + ) + : selectedPointsIndices + : selectedPointsIndices?.includes(pointerDownState.lastClickedPoint) + ? [pointerDownState.lastClickedPoint] + : selectedPointsIndices, + isDragging: false, + pointerOffset: { x: 0, y: 0 }, + }; + } + + static getEditorMidPoints = ( + element: NonDeleted<ExcalidrawLinearElement>, + elementsMap: ElementsMap, + appState: InteractiveCanvasAppState, + ): typeof editorMidPointsCache["points"] => { + const boundText = getBoundTextElement(element, elementsMap); + + // Since its not needed outside editor unless 2 pointer lines or bound text + if ( + !isElbowArrow(element) && + !appState.editingLinearElement && + element.points.length > 2 && + !boundText + ) { + return []; + } + if ( + editorMidPointsCache.version === element.version && + editorMidPointsCache.zoom === appState.zoom.value + ) { + return editorMidPointsCache.points; + } + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + appState, + ); + return editorMidPointsCache.points!; + }; + + static updateEditorMidPointsCache = ( + element: NonDeleted<ExcalidrawLinearElement>, + elementsMap: ElementsMap, + appState: InteractiveCanvasAppState, + ) => { + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); + + let index = 0; + const midpoints: (GlobalPoint | null)[] = []; + while (index < points.length - 1) { + if ( + LinearElementEditor.isSegmentTooShort( + element, + element.points[index], + element.points[index + 1], + index, + appState.zoom, + ) + ) { + midpoints.push(null); + index++; + continue; + } + const segmentMidPoint = LinearElementEditor.getSegmentMidPoint( + element, + points[index], + points[index + 1], + index + 1, + elementsMap, + ); + midpoints.push(segmentMidPoint); + index++; + } + editorMidPointsCache.points = midpoints; + editorMidPointsCache.version = element.version; + editorMidPointsCache.zoom = appState.zoom.value; + }; + + static getSegmentMidpointHitCoords = ( + linearElementEditor: LinearElementEditor, + scenePointer: { x: number; y: number }, + appState: AppState, + elementsMap: ElementsMap, + ): GlobalPoint | null => { + const { elementId } = linearElementEditor; + const element = LinearElementEditor.getElement(elementId, elementsMap); + if (!element) { + return null; + } + const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( + element, + elementsMap, + appState.zoom, + scenePointer.x, + scenePointer.y, + ); + if (!isElbowArrow(element) && clickedPointIndex >= 0) { + return null; + } + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); + if ( + points.length >= 3 && + !appState.editingLinearElement && + !isElbowArrow(element) + ) { + return null; + } + + const threshold = + (LinearElementEditor.POINT_HANDLE_SIZE + 1) / appState.zoom.value; + + const existingSegmentMidpointHitCoords = + linearElementEditor.segmentMidPointHoveredCoords; + if (existingSegmentMidpointHitCoords) { + const distance = pointDistance( + pointFrom( + existingSegmentMidpointHitCoords[0], + existingSegmentMidpointHitCoords[1], + ), + pointFrom(scenePointer.x, scenePointer.y), + ); + if (distance <= threshold) { + return existingSegmentMidpointHitCoords; + } + } + let index = 0; + const midPoints: typeof editorMidPointsCache["points"] = + LinearElementEditor.getEditorMidPoints(element, elementsMap, appState); + + while (index < midPoints.length) { + if (midPoints[index] !== null) { + const distance = pointDistance( + midPoints[index]!, + pointFrom(scenePointer.x, scenePointer.y), + ); + if (distance <= threshold) { + return midPoints[index]; + } + } + + index++; + } + return null; + }; + + static isSegmentTooShort<P extends GlobalPoint | LocalPoint>( + element: NonDeleted<ExcalidrawLinearElement>, + startPoint: P, + endPoint: P, + index: number, + zoom: Zoom, + ) { + if (isElbowArrow(element)) { + if (index >= 0 && index < element.points.length) { + return ( + pointDistance(startPoint, endPoint) * zoom.value < + LinearElementEditor.POINT_HANDLE_SIZE / 2 + ); + } + + return false; + } + + let distance = pointDistance(startPoint, endPoint); + if (element.points.length > 2 && element.roundness) { + distance = getBezierCurveLength(element, endPoint); + } + + return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4; + } + + static getSegmentMidPoint( + element: NonDeleted<ExcalidrawLinearElement>, + startPoint: GlobalPoint, + endPoint: GlobalPoint, + endPointIndex: number, + elementsMap: ElementsMap, + ): GlobalPoint { + let segmentMidPoint = pointCenter(startPoint, endPoint); + if (element.points.length > 2 && element.roundness) { + const controlPoints = getControlPointsForBezierCurve( + element, + element.points[endPointIndex], + ); + if (controlPoints) { + const t = mapIntervalToBezierT( + element, + element.points[endPointIndex], + 0.5, + ); + + segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates( + element, + getBezierXY( + controlPoints[0], + controlPoints[1], + controlPoints[2], + controlPoints[3], + t, + ), + elementsMap, + ); + } + } + + return segmentMidPoint; + } + + static getSegmentMidPointIndex( + linearElementEditor: LinearElementEditor, + appState: AppState, + midPoint: GlobalPoint, + elementsMap: ElementsMap, + ) { + const element = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ); + if (!element) { + return -1; + } + const midPoints = LinearElementEditor.getEditorMidPoints( + element, + elementsMap, + appState, + ); + let index = 0; + while (index < midPoints.length) { + if (LinearElementEditor.arePointsEqual(midPoint, midPoints[index])) { + return index + 1; + } + index++; + } + return -1; + } + + static handlePointerDown( + event: React.PointerEvent<HTMLElement>, + app: AppClassProperties, + store: Store, + scenePointer: { x: number; y: number }, + linearElementEditor: LinearElementEditor, + scene: Scene, + ): { + didAddPoint: boolean; + hitElement: NonDeleted<ExcalidrawElement> | null; + linearElementEditor: LinearElementEditor | null; + } { + const appState = app.state; + const elementsMap = scene.getNonDeletedElementsMap(); + const elements = scene.getNonDeletedElements(); + + const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = { + didAddPoint: false, + hitElement: null, + linearElementEditor: null, + }; + + if (!linearElementEditor) { + return ret; + } + + const { elementId } = linearElementEditor; + const element = LinearElementEditor.getElement(elementId, elementsMap); + + if (!element) { + return ret; + } + const segmentMidpoint = LinearElementEditor.getSegmentMidpointHitCoords( + linearElementEditor, + scenePointer, + appState, + elementsMap, + ); + let segmentMidpointIndex = null; + if (segmentMidpoint) { + segmentMidpointIndex = LinearElementEditor.getSegmentMidPointIndex( + linearElementEditor, + appState, + segmentMidpoint, + elementsMap, + ); + } else if (event.altKey && appState.editingLinearElement) { + if (linearElementEditor.lastUncommittedPoint == null) { + mutateElement(element, { + points: [ + ...element.points, + LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointer.x, + scenePointer.y, + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ), + ], + }); + ret.didAddPoint = true; + } + store.shouldCaptureIncrement(); + ret.linearElementEditor = { + ...linearElementEditor, + pointerDownState: { + prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, + lastClickedPoint: -1, + lastClickedIsEndPoint: false, + origin: { x: scenePointer.x, y: scenePointer.y }, + segmentMidpoint: { + value: segmentMidpoint, + index: segmentMidpointIndex, + added: false, + }, + }, + selectedPointsIndices: [element.points.length - 1], + lastUncommittedPoint: null, + endBindingElement: getHoveredElementForBinding( + scenePointer, + elements, + elementsMap, + app.state.zoom, + linearElementEditor.elbowed, + ), + }; + + ret.didAddPoint = true; + return ret; + } + + const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( + element, + elementsMap, + appState.zoom, + scenePointer.x, + scenePointer.y, + ); + // if we clicked on a point, set the element as hitElement otherwise + // it would get deselected if the point is outside the hitbox area + if (clickedPointIndex >= 0 || segmentMidpoint) { + ret.hitElement = element; + } else { + // You might be wandering why we are storing the binding elements on + // LinearElementEditor and passing them in, instead of calculating them + // from the end points of the `linearElement` - this is to allow disabling + // binding (which needs to happen at the point the user finishes moving + // the point). + const { startBindingElement, endBindingElement } = linearElementEditor; + if (isBindingEnabled(appState) && isBindingElement(element)) { + bindOrUnbindLinearElement( + element, + startBindingElement, + endBindingElement, + elementsMap, + scene, + ); + } + } + + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const targetPoint = + clickedPointIndex > -1 && + pointRotateRads( + pointFrom( + element.x + element.points[clickedPointIndex][0], + element.y + element.points[clickedPointIndex][1], + ), + pointFrom(cx, cy), + element.angle, + ); + + const nextSelectedPointsIndices = + clickedPointIndex > -1 || event.shiftKey + ? event.shiftKey || + linearElementEditor.selectedPointsIndices?.includes(clickedPointIndex) + ? normalizeSelectedPoints([ + ...(linearElementEditor.selectedPointsIndices || []), + clickedPointIndex, + ]) + : [clickedPointIndex] + : null; + ret.linearElementEditor = { + ...linearElementEditor, + pointerDownState: { + prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, + lastClickedPoint: clickedPointIndex, + lastClickedIsEndPoint: clickedPointIndex === element.points.length - 1, + origin: { x: scenePointer.x, y: scenePointer.y }, + segmentMidpoint: { + value: segmentMidpoint, + index: segmentMidpointIndex, + added: false, + }, + }, + selectedPointsIndices: nextSelectedPointsIndices, + pointerOffset: targetPoint + ? { + x: scenePointer.x - targetPoint[0], + y: scenePointer.y - targetPoint[1], + } + : { x: 0, y: 0 }, + }; + + return ret; + } + + static arePointsEqual<Point extends LocalPoint | GlobalPoint>( + point1: Point | null, + point2: Point | null, + ) { + if (!point1 && !point2) { + return true; + } + if (!point1 || !point2) { + return false; + } + return pointsEqual(point1, point2); + } + + static handlePointerMove( + event: React.PointerEvent<HTMLCanvasElement>, + scenePointerX: number, + scenePointerY: number, + app: AppClassProperties, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + ): LinearElementEditor | null { + const appState = app.state; + if (!appState.editingLinearElement) { + return null; + } + const { elementId, lastUncommittedPoint } = appState.editingLinearElement; + const element = LinearElementEditor.getElement(elementId, elementsMap); + if (!element) { + return appState.editingLinearElement; + } + + const { points } = element; + const lastPoint = points[points.length - 1]; + + if (!event.altKey) { + if (lastPoint === lastUncommittedPoint) { + LinearElementEditor.deletePoints(element, [points.length - 1]); + } + return { + ...appState.editingLinearElement, + lastUncommittedPoint: null, + }; + } + + let newPoint: LocalPoint; + + if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { + const lastCommittedPoint = points[points.length - 2]; + + const [width, height] = LinearElementEditor._getShiftLockedDelta( + element, + elementsMap, + lastCommittedPoint, + pointFrom(scenePointerX, scenePointerY), + event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ); + + newPoint = pointFrom( + width + lastCommittedPoint[0], + height + lastCommittedPoint[1], + ); + } else { + newPoint = LinearElementEditor.createPointAt( + element, + elementsMap, + scenePointerX - appState.editingLinearElement.pointerOffset.x, + scenePointerY - appState.editingLinearElement.pointerOffset.y, + event[KEYS.CTRL_OR_CMD] || isElbowArrow(element) + ? null + : app.getEffectiveGridSize(), + ); + } + + if (lastPoint === lastUncommittedPoint) { + LinearElementEditor.movePoints(element, [ + { + index: element.points.length - 1, + point: newPoint, + }, + ]); + } else { + LinearElementEditor.addPoints(element, [{ point: newPoint }]); + } + return { + ...appState.editingLinearElement, + lastUncommittedPoint: element.points[element.points.length - 1], + }; + } + + /** scene coords */ + static getPointGlobalCoordinates( + element: NonDeleted<ExcalidrawLinearElement>, + p: LocalPoint, + elementsMap: ElementsMap, + ): GlobalPoint { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + + const { x, y } = element; + return pointRotateRads( + pointFrom(x + p[0], y + p[1]), + pointFrom(cx, cy), + element.angle, + ); + } + + /** scene coords */ + static getPointsGlobalCoordinates( + element: NonDeleted<ExcalidrawLinearElement>, + elementsMap: ElementsMap, + ): GlobalPoint[] { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + return element.points.map((p) => { + const { x, y } = element; + return pointRotateRads( + pointFrom(x + p[0], y + p[1]), + pointFrom(cx, cy), + element.angle, + ); + }); + } + + static getPointAtIndexGlobalCoordinates( + element: NonDeleted<ExcalidrawLinearElement>, + + indexMaybeFromEnd: number, // -1 for last element + elementsMap: ElementsMap, + ): GlobalPoint { + const index = + indexMaybeFromEnd < 0 + ? element.points.length + indexMaybeFromEnd + : indexMaybeFromEnd; + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const p = element.points[index]; + const { x, y } = element; + + return p + ? pointRotateRads( + pointFrom(x + p[0], y + p[1]), + pointFrom(cx, cy), + element.angle, + ) + : pointRotateRads(pointFrom(x, y), pointFrom(cx, cy), element.angle); + } + + static pointFromAbsoluteCoords( + element: NonDeleted<ExcalidrawLinearElement>, + absoluteCoords: GlobalPoint, + elementsMap: ElementsMap, + ): LocalPoint { + if (isElbowArrow(element)) { + // No rotation for elbow arrows + return pointFrom( + absoluteCoords[0] - element.x, + absoluteCoords[1] - element.y, + ); + } + + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const [x, y] = pointRotateRads( + pointFrom(absoluteCoords[0], absoluteCoords[1]), + pointFrom(cx, cy), + -element.angle as Radians, + ); + return pointFrom(x - element.x, y - element.y); + } + + static getPointIndexUnderCursor( + element: NonDeleted<ExcalidrawLinearElement>, + elementsMap: ElementsMap, + zoom: AppState["zoom"], + x: number, + y: number, + ) { + const pointHandles = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); + let idx = pointHandles.length; + // loop from right to left because points on the right are rendered over + // points on the left, thus should take precedence when clicking, if they + // overlap + while (--idx > -1) { + const p = pointHandles[idx]; + if ( + pointDistance(pointFrom(x, y), pointFrom(p[0], p[1])) * zoom.value < + // +1px to account for outline stroke + LinearElementEditor.POINT_HANDLE_SIZE + 1 + ) { + return idx; + } + } + return -1; + } + + static createPointAt( + element: NonDeleted<ExcalidrawLinearElement>, + elementsMap: ElementsMap, + scenePointerX: number, + scenePointerY: number, + gridSize: NullableGridSize, + ): LocalPoint { + const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const [rotatedX, rotatedY] = pointRotateRads( + pointFrom(pointerOnGrid[0], pointerOnGrid[1]), + pointFrom(cx, cy), + -element.angle as Radians, + ); + + return pointFrom(rotatedX - element.x, rotatedY - element.y); + } + + /** + * Normalizes line points so that the start point is at [0,0]. This is + * expected in various parts of the codebase. Also returns new x/y to account + * for the potential normalization. + */ + static getNormalizedPoints(element: ExcalidrawLinearElement): { + points: LocalPoint[]; + x: number; + y: number; + } { + const { points } = element; + + const offsetX = points[0][0]; + const offsetY = points[0][1]; + + return { + points: points.map((p) => { + return pointFrom(p[0] - offsetX, p[1] - offsetY); + }), + x: element.x + offsetX, + y: element.y + offsetY, + }; + } + + // element-mutating methods + // --------------------------------------------------------------------------- + + static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) { + mutateElement(element, LinearElementEditor.getNormalizedPoints(element)); + } + + static duplicateSelectedPoints( + appState: AppState, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + ): AppState { + invariant( + appState.editingLinearElement, + "Not currently editing a linear element", + ); + + const { selectedPointsIndices, elementId } = appState.editingLinearElement; + const element = LinearElementEditor.getElement(elementId, elementsMap); + + invariant( + element, + "The linear element does not exist in the provided Scene", + ); + invariant( + selectedPointsIndices != null, + "There are no selected points to duplicate", + ); + + const { points } = element; + + const nextSelectedIndices: number[] = []; + + let pointAddedToEnd = false; + let indexCursor = -1; + const nextPoints = points.reduce((acc: LocalPoint[], p, index) => { + ++indexCursor; + acc.push(p); + + const isSelected = selectedPointsIndices.includes(index); + if (isSelected) { + const nextPoint = points[index + 1]; + + if (!nextPoint) { + pointAddedToEnd = true; + } + acc.push( + nextPoint + ? pointFrom((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2) + : pointFrom(p[0], p[1]), + ); + + nextSelectedIndices.push(indexCursor + 1); + ++indexCursor; + } + + return acc; + }, []); + + mutateElement(element, { points: nextPoints }); + + // temp hack to ensure the line doesn't move when adding point to the end, + // potentially expanding the bounding box + if (pointAddedToEnd) { + const lastPoint = element.points[element.points.length - 1]; + LinearElementEditor.movePoints(element, [ + { + index: element.points.length - 1, + point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30), + }, + ]); + } + + return { + ...appState, + editingLinearElement: { + ...appState.editingLinearElement, + selectedPointsIndices: nextSelectedIndices, + }, + }; + } + + static deletePoints( + element: NonDeleted<ExcalidrawLinearElement>, + pointIndices: readonly number[], + ) { + let offsetX = 0; + let offsetY = 0; + + const isDeletingOriginPoint = pointIndices.includes(0); + + // if deleting first point, make the next to be [0,0] and recalculate + // positions of the rest with respect to it + if (isDeletingOriginPoint) { + const firstNonDeletedPoint = element.points.find((point, idx) => { + return !pointIndices.includes(idx); + }); + if (firstNonDeletedPoint) { + offsetX = firstNonDeletedPoint[0]; + offsetY = firstNonDeletedPoint[1]; + } + } + + const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => { + if (!pointIndices.includes(idx)) { + acc.push( + !acc.length + ? pointFrom(0, 0) + : pointFrom(p[0] - offsetX, p[1] - offsetY), + ); + } + return acc; + }, []); + + LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); + } + + static addPoints( + element: NonDeleted<ExcalidrawLinearElement>, + targetPoints: { point: LocalPoint }[], + ) { + const offsetX = 0; + const offsetY = 0; + + const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)]; + LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); + } + + static movePoints( + element: NonDeleted<ExcalidrawLinearElement>, + targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[], + otherUpdates?: { + startBinding?: PointBinding | null; + endBinding?: PointBinding | null; + }, + ) { + const { points } = element; + + // in case we're moving start point, instead of modifying its position + // which would break the invariant of it being at [0,0], we move + // all the other points in the opposite direction by delta to + // offset it. We do the same with actual element.x/y position, so + // this hacks are completely transparent to the user. + const [deltaX, deltaY] = + targetPoints.find(({ index }) => index === 0)?.point ?? + pointFrom<LocalPoint>(0, 0); + const [offsetX, offsetY] = pointFrom<LocalPoint>( + deltaX - points[0][0], + deltaY - points[0][1], + ); + + const nextPoints = isElbowArrow(element) + ? [ + targetPoints.find((t) => t.index === 0)?.point ?? points[0], + targetPoints.find((t) => t.index === points.length - 1)?.point ?? + points[points.length - 1], + ] + : points.map((p, idx) => { + const current = targetPoints.find((t) => t.index === idx)?.point ?? p; + + return pointFrom<LocalPoint>( + current[0] - offsetX, + current[1] - offsetY, + ); + }); + + LinearElementEditor._updatePoints( + element, + nextPoints, + offsetX, + offsetY, + otherUpdates, + { + isDragging: targetPoints.reduce( + (dragging, targetPoint): boolean => + dragging || targetPoint.isDragging === true, + false, + ), + }, + ); + } + + static shouldAddMidpoint( + linearElementEditor: LinearElementEditor, + pointerCoords: PointerCoords, + appState: AppState, + elementsMap: ElementsMap, + ) { + const element = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ); + + // Elbow arrows don't allow midpoints + if (element && isElbowArrow(element)) { + return false; + } + + if (!element) { + return false; + } + + const { segmentMidpoint } = linearElementEditor.pointerDownState; + + if ( + segmentMidpoint.added || + segmentMidpoint.value === null || + segmentMidpoint.index === null || + linearElementEditor.pointerDownState.origin === null + ) { + return false; + } + + const origin = linearElementEditor.pointerDownState.origin!; + const dist = pointDistance( + pointFrom(origin.x, origin.y), + pointFrom(pointerCoords.x, pointerCoords.y), + ); + if ( + !appState.editingLinearElement && + dist < DRAGGING_THRESHOLD / appState.zoom.value + ) { + return false; + } + return true; + } + + static addMidpoint( + linearElementEditor: LinearElementEditor, + pointerCoords: PointerCoords, + app: AppClassProperties, + snapToGrid: boolean, + elementsMap: ElementsMap, + ) { + const element = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ); + if (!element) { + return; + } + const { segmentMidpoint } = linearElementEditor.pointerDownState; + const ret: { + pointerDownState: LinearElementEditor["pointerDownState"]; + selectedPointsIndices: LinearElementEditor["selectedPointsIndices"]; + } = { + pointerDownState: linearElementEditor.pointerDownState, + selectedPointsIndices: linearElementEditor.selectedPointsIndices, + }; + + const midpoint = LinearElementEditor.createPointAt( + element, + elementsMap, + pointerCoords.x, + pointerCoords.y, + snapToGrid && !isElbowArrow(element) ? app.getEffectiveGridSize() : null, + ); + const points = [ + ...element.points.slice(0, segmentMidpoint.index!), + midpoint, + ...element.points.slice(segmentMidpoint.index!), + ]; + + mutateElement(element, { + points, + }); + + ret.pointerDownState = { + ...linearElementEditor.pointerDownState, + segmentMidpoint: { + ...linearElementEditor.pointerDownState.segmentMidpoint, + added: true, + }, + lastClickedPoint: segmentMidpoint.index!, + }; + ret.selectedPointsIndices = [segmentMidpoint.index!]; + return ret; + } + + private static _updatePoints( + element: NonDeleted<ExcalidrawLinearElement>, + nextPoints: readonly LocalPoint[], + offsetX: number, + offsetY: number, + otherUpdates?: { + startBinding?: PointBinding | null; + endBinding?: PointBinding | null; + }, + options?: { + isDragging?: boolean; + zoom?: AppState["zoom"]; + }, + ) { + if (isElbowArrow(element)) { + const updates: { + startBinding?: FixedPointBinding | null; + endBinding?: FixedPointBinding | null; + points?: LocalPoint[]; + } = {}; + if (otherUpdates?.startBinding !== undefined) { + updates.startBinding = + otherUpdates.startBinding !== null && + isFixedPointBinding(otherUpdates.startBinding) + ? otherUpdates.startBinding + : null; + } + if (otherUpdates?.endBinding !== undefined) { + updates.endBinding = + otherUpdates.endBinding !== null && + isFixedPointBinding(otherUpdates.endBinding) + ? otherUpdates.endBinding + : null; + } + + updates.points = Array.from(nextPoints); + + mutateElement(element, updates, true, { + isDragging: options?.isDragging, + }); + } else { + const nextCoords = getElementPointsCoords(element, nextPoints); + const prevCoords = getElementPointsCoords(element, element.points); + const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2; + const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2; + const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2; + const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2; + const dX = prevCenterX - nextCenterX; + const dY = prevCenterY - nextCenterY; + const rotated = pointRotateRads( + pointFrom(offsetX, offsetY), + pointFrom(dX, dY), + element.angle, + ); + mutateElement(element, { + ...otherUpdates, + points: nextPoints, + x: element.x + rotated[0], + y: element.y + rotated[1], + }); + } + } + + private static _getShiftLockedDelta( + element: NonDeleted<ExcalidrawLinearElement>, + elementsMap: ElementsMap, + referencePoint: LocalPoint, + scenePointer: GlobalPoint, + gridSize: NullableGridSize, + ) { + const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( + element, + referencePoint, + elementsMap, + ); + + if (isElbowArrow(element)) { + return [ + scenePointer[0] - referencePointCoords[0], + scenePointer[1] - referencePointCoords[1], + ]; + } + + const [gridX, gridY] = getGridPoint( + scenePointer[0], + scenePointer[1], + gridSize, + ); + + const { width, height } = getLockedLinearCursorAlignSize( + referencePointCoords[0], + referencePointCoords[1], + gridX, + gridY, + ); + + return pointRotateRads( + pointFrom(width, height), + pointFrom(0, 0), + -element.angle as Radians, + ); + } + + static getBoundTextElementPosition = ( + element: ExcalidrawLinearElement, + boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, + ): { x: number; y: number } => { + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); + if (points.length < 2) { + mutateElement(boundTextElement, { isDeleted: true }); + } + let x = 0; + let y = 0; + if (element.points.length % 2 === 1) { + const index = Math.floor(element.points.length / 2); + const midPoint = LinearElementEditor.getPointGlobalCoordinates( + element, + element.points[index], + elementsMap, + ); + x = midPoint[0] - boundTextElement.width / 2; + y = midPoint[1] - boundTextElement.height / 2; + } else { + const index = element.points.length / 2 - 1; + + let midSegmentMidpoint = editorMidPointsCache.points[index]; + if (element.points.length === 2) { + midSegmentMidpoint = pointCenter(points[0], points[1]); + } + if ( + !midSegmentMidpoint || + editorMidPointsCache.version !== element.version + ) { + midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( + element, + points[index], + points[index + 1], + index + 1, + elementsMap, + ); + } + x = midSegmentMidpoint[0] - boundTextElement.width / 2; + y = midSegmentMidpoint[1] - boundTextElement.height / 2; + } + return { x, y }; + }; + + static getMinMaxXYWithBoundText = ( + element: ExcalidrawLinearElement, + elementsMap: ElementsMap, + elementBounds: Bounds, + boundTextElement: ExcalidrawTextElementWithContainer, + ): [number, number, number, number, number, number] => { + let [x1, y1, x2, y2] = elementBounds; + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const { x: boundTextX1, y: boundTextY1 } = + LinearElementEditor.getBoundTextElementPosition( + element, + boundTextElement, + elementsMap, + ); + const boundTextX2 = boundTextX1 + boundTextElement.width; + const boundTextY2 = boundTextY1 + boundTextElement.height; + const centerPoint = pointFrom(cx, cy); + + const topLeftRotatedPoint = pointRotateRads( + pointFrom(x1, y1), + centerPoint, + element.angle, + ); + const topRightRotatedPoint = pointRotateRads( + pointFrom(x2, y1), + centerPoint, + element.angle, + ); + + const counterRotateBoundTextTopLeft = pointRotateRads( + pointFrom(boundTextX1, boundTextY1), + centerPoint, + -element.angle as Radians, + ); + const counterRotateBoundTextTopRight = pointRotateRads( + pointFrom(boundTextX2, boundTextY1), + centerPoint, + -element.angle as Radians, + ); + const counterRotateBoundTextBottomLeft = pointRotateRads( + pointFrom(boundTextX1, boundTextY2), + centerPoint, + -element.angle as Radians, + ); + const counterRotateBoundTextBottomRight = pointRotateRads( + pointFrom(boundTextX2, boundTextY2), + centerPoint, + -element.angle as Radians, + ); + + if ( + topLeftRotatedPoint[0] < topRightRotatedPoint[0] && + topLeftRotatedPoint[1] >= topRightRotatedPoint[1] + ) { + x1 = Math.min(x1, counterRotateBoundTextBottomLeft[0]); + x2 = Math.max( + x2, + Math.max( + counterRotateBoundTextTopRight[0], + counterRotateBoundTextBottomRight[0], + ), + ); + y1 = Math.min(y1, counterRotateBoundTextTopLeft[1]); + + y2 = Math.max(y2, counterRotateBoundTextBottomRight[1]); + } else if ( + topLeftRotatedPoint[0] >= topRightRotatedPoint[0] && + topLeftRotatedPoint[1] > topRightRotatedPoint[1] + ) { + x1 = Math.min(x1, counterRotateBoundTextBottomRight[0]); + x2 = Math.max( + x2, + Math.max( + counterRotateBoundTextTopLeft[0], + counterRotateBoundTextTopRight[0], + ), + ); + y1 = Math.min(y1, counterRotateBoundTextBottomLeft[1]); + + y2 = Math.max(y2, counterRotateBoundTextTopRight[1]); + } else if (topLeftRotatedPoint[0] >= topRightRotatedPoint[0]) { + x1 = Math.min(x1, counterRotateBoundTextTopRight[0]); + x2 = Math.max(x2, counterRotateBoundTextBottomLeft[0]); + y1 = Math.min(y1, counterRotateBoundTextBottomRight[1]); + + y2 = Math.max(y2, counterRotateBoundTextTopLeft[1]); + } else if (topLeftRotatedPoint[1] <= topRightRotatedPoint[1]) { + x1 = Math.min( + x1, + Math.min( + counterRotateBoundTextTopRight[0], + counterRotateBoundTextTopLeft[0], + ), + ); + + x2 = Math.max(x2, counterRotateBoundTextBottomRight[0]); + y1 = Math.min(y1, counterRotateBoundTextTopRight[1]); + y2 = Math.max(y2, counterRotateBoundTextBottomLeft[1]); + } + + return [x1, y1, x2, y2, cx, cy]; + }; + + static getElementAbsoluteCoords = ( + element: ExcalidrawLinearElement, + elementsMap: ElementsMap, + includeBoundText: boolean = false, + ): [number, number, number, number, number, number] => { + let coords: [number, number, number, number, number, number]; + let x1; + let y1; + let x2; + let y2; + if (element.points.length < 2 || !ShapeCache.get(element)) { + // XXX this is just a poor estimate and not very useful + const { minX, minY, maxX, maxY } = element.points.reduce( + (limits, [x, y]) => { + limits.minY = Math.min(limits.minY, y); + limits.minX = Math.min(limits.minX, x); + + limits.maxX = Math.max(limits.maxX, x); + limits.maxY = Math.max(limits.maxY, y); + + return limits; + }, + { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, + ); + x1 = minX + element.x; + y1 = minY + element.y; + x2 = maxX + element.x; + y2 = maxY + element.y; + } else { + const shape = ShapeCache.generateElementShape(element, null); + + // first element is always the curve + const ops = getCurvePathOps(shape[0]); + + const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops); + x1 = minX + element.x; + y1 = minY + element.y; + x2 = maxX + element.x; + y2 = maxY + element.y; + } + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + coords = [x1, y1, x2, y2, cx, cy]; + + if (!includeBoundText) { + return coords; + } + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + coords = LinearElementEditor.getMinMaxXYWithBoundText( + element, + elementsMap, + [x1, y1, x2, y2], + boundTextElement, + ); + } + + return coords; + }; + + static moveFixedSegment( + linearElement: LinearElementEditor, + index: number, + x: number, + y: number, + elementsMap: ElementsMap, + ): LinearElementEditor { + const element = LinearElementEditor.getElement( + linearElement.elementId, + elementsMap, + ); + + if (!element || !isElbowArrow(element)) { + return linearElement; + } + + if (index && index > 0 && index < element.points.length) { + const isHorizontal = headingIsHorizontal( + vectorToHeading( + vectorFromPoint(element.points[index], element.points[index - 1]), + ), + ); + + const fixedSegments = (element.fixedSegments ?? []).reduce( + (segments, s) => { + segments[s.index] = s; + return segments; + }, + {} as Record<number, FixedSegment>, + ); + fixedSegments[index] = { + index, + start: pointFrom<LocalPoint>( + !isHorizontal ? x - element.x : element.points[index - 1][0], + isHorizontal ? y - element.y : element.points[index - 1][1], + ), + end: pointFrom<LocalPoint>( + !isHorizontal ? x - element.x : element.points[index][0], + isHorizontal ? y - element.y : element.points[index][1], + ), + }; + const nextFixedSegments = Object.values(fixedSegments).sort( + (a, b) => a.index - b.index, + ); + + const offset = nextFixedSegments + .map((segment) => segment.index) + .reduce((count, idx) => (idx < index ? count + 1 : count), 0); + + mutateElement(element, { + fixedSegments: nextFixedSegments, + }); + + const point = pointFrom<GlobalPoint>( + element.x + + (element.fixedSegments![offset].start[0] + + element.fixedSegments![offset].end[0]) / + 2, + element.y + + (element.fixedSegments![offset].start[1] + + element.fixedSegments![offset].end[1]) / + 2, + ); + + return { + ...linearElement, + segmentMidPointHoveredCoords: point, + pointerDownState: { + ...linearElement.pointerDownState, + segmentMidpoint: { + added: false, + index: element.fixedSegments![offset].index, + value: point, + }, + }, + }; + } + + return linearElement; + } + + static deleteFixedSegment( + element: ExcalidrawElbowArrowElement, + index: number, + ): void { + mutateElement(element, { + fixedSegments: element.fixedSegments?.filter( + (segment) => segment.index !== index, + ), + }); + mutateElement(element, {}, true); + } +} + +const normalizeSelectedPoints = ( + points: (number | null)[], +): number[] | null => { + let nextPoints = [ + ...new Set(points.filter((p) => p !== null && p !== -1)), + ] as number[]; + nextPoints = nextPoints.sort((a, b) => a - b); + return nextPoints.length ? nextPoints : null; +}; diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts new file mode 100644 index 0000000..cfff5c8 --- /dev/null +++ b/packages/excalidraw/element/mutateElement.ts @@ -0,0 +1,196 @@ +import type { ExcalidrawElement, NonDeletedSceneElementsMap } from "./types"; +import Scene from "../scene/Scene"; +import { getSizeFromPoints } from "../points"; +import { randomInteger } from "../random"; +import { getUpdatedTimestamp, toBrandedType } from "../utils"; +import type { Mutable } from "../utility-types"; +import { ShapeCache } from "../scene/ShapeCache"; +import { isElbowArrow } from "./typeChecks"; +import { updateElbowArrowPoints } from "./elbowArrow"; +import type { Radians } from "@excalidraw/math"; + +export type ElementUpdate<TElement extends ExcalidrawElement> = Omit< + Partial<TElement>, + "id" | "version" | "versionNonce" | "updated" +>; + +// This function tracks updates of text elements for the purposes for collaboration. +// The version is used to compare updates when more than one user is working in +// the same drawing. Note: this will trigger the component to update. Make sure you +// are calling it either from a React event handler or within unstable_batchedUpdates(). +export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>( + element: TElement, + updates: ElementUpdate<TElement>, + informMutation = true, + options?: { + // Currently only for elbow arrows. + // If true, the elbow arrow tries to bind to the nearest element. If false + // it tries to keep the same bound element, if any. + isDragging?: boolean; + }, +): TElement => { + let didChange = false; + + // casting to any because can't use `in` operator + // (see https://github.com/microsoft/TypeScript/issues/21732) + const { points, fixedSegments, fileId, startBinding, endBinding } = + updates as any; + + if ( + isElbowArrow(element) && + (Object.keys(updates).length === 0 || // normalization case + typeof points !== "undefined" || // repositioning + typeof fixedSegments !== "undefined" || // segment fixing + typeof startBinding !== "undefined" || + typeof endBinding !== "undefined") // manual binding to element + ) { + const elementsMap = toBrandedType<NonDeletedSceneElementsMap>( + Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(), + ); + + updates = { + ...updates, + angle: 0 as Radians, + ...updateElbowArrowPoints( + { + ...element, + x: updates.x || element.x, + y: updates.y || element.y, + }, + elementsMap, + { + fixedSegments, + points, + startBinding, + endBinding, + }, + { + isDragging: options?.isDragging, + }, + ), + }; + } else if (typeof points !== "undefined") { + updates = { ...getSizeFromPoints(points), ...updates }; + } + + for (const key in updates) { + const value = (updates as any)[key]; + if (typeof value !== "undefined") { + if ( + (element as any)[key] === value && + // if object, always update because its attrs could have changed + // (except for specific keys we handle below) + (typeof value !== "object" || + value === null || + key === "groupIds" || + key === "scale") + ) { + continue; + } + + if (key === "scale") { + const prevScale = (element as any)[key]; + const nextScale = value; + if (prevScale[0] === nextScale[0] && prevScale[1] === nextScale[1]) { + continue; + } + } else if (key === "points") { + const prevPoints = (element as any)[key]; + const nextPoints = value; + if (prevPoints.length === nextPoints.length) { + let didChangePoints = false; + let index = prevPoints.length; + while (--index) { + const prevPoint = prevPoints[index]; + const nextPoint = nextPoints[index]; + if ( + prevPoint[0] !== nextPoint[0] || + prevPoint[1] !== nextPoint[1] + ) { + didChangePoints = true; + break; + } + } + if (!didChangePoints) { + continue; + } + } + } + + (element as any)[key] = value; + didChange = true; + } + } + + if (!didChange) { + return element; + } + + if ( + typeof updates.height !== "undefined" || + typeof updates.width !== "undefined" || + typeof fileId != "undefined" || + typeof points !== "undefined" + ) { + ShapeCache.delete(element); + } + + element.version++; + element.versionNonce = randomInteger(); + element.updated = getUpdatedTimestamp(); + + if (informMutation) { + Scene.getScene(element)?.triggerUpdate(); + } + + return element; +}; + +export const newElementWith = <TElement extends ExcalidrawElement>( + element: TElement, + updates: ElementUpdate<TElement>, + /** pass `true` to always regenerate */ + force = false, +): TElement => { + let didChange = false; + for (const key in updates) { + const value = (updates as any)[key]; + if (typeof value !== "undefined") { + if ( + (element as any)[key] === value && + // if object, always update because its attrs could have changed + (typeof value !== "object" || value === null) + ) { + continue; + } + didChange = true; + } + } + + if (!didChange && !force) { + return element; + } + + return { + ...element, + ...updates, + updated: getUpdatedTimestamp(), + version: element.version + 1, + versionNonce: randomInteger(), + }; +}; + +/** + * Mutates element, bumping `version`, `versionNonce`, and `updated`. + * + * NOTE: does not trigger re-render. + */ +export const bumpVersion = <T extends Mutable<ExcalidrawElement>>( + element: T, + version?: ExcalidrawElement["version"], +) => { + element.version = (version ?? element.version) + 1; + element.versionNonce = randomInteger(); + element.updated = getUpdatedTimestamp(); + return element; +}; diff --git a/packages/excalidraw/element/newElement.test.ts b/packages/excalidraw/element/newElement.test.ts new file mode 100644 index 0000000..9c9006b --- /dev/null +++ b/packages/excalidraw/element/newElement.test.ts @@ -0,0 +1,373 @@ +import { duplicateElement, duplicateElements } from "./newElement"; +import { mutateElement } from "./mutateElement"; +import { API } from "../tests/helpers/api"; +import { FONT_FAMILY, ROUNDNESS } from "../constants"; +import { isPrimitive } from "../utils"; +import type { ExcalidrawLinearElement } from "./types"; +import type { LocalPoint } from "@excalidraw/math"; +import { pointFrom } from "@excalidraw/math"; + +const assertCloneObjects = (source: any, clone: any) => { + for (const key in clone) { + if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) { + expect(clone[key]).not.toBe(source[key]); + if (source[key]) { + assertCloneObjects(source[key], clone[key]); + } + } + } +}; + +describe("duplicating single elements", () => { + it("clones arrow element", () => { + const element = API.createElement({ + type: "arrow", + x: 0, + y: 0, + strokeColor: "#000000", + backgroundColor: "transparent", + fillStyle: "hachure", + strokeWidth: 1, + strokeStyle: "solid", + roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, + roughness: 1, + opacity: 100, + }); + + // @ts-ignore + element.__proto__ = { hello: "world" }; + + mutateElement(element, { + points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)], + }); + + const copy = duplicateElement(null, new Map(), element); + + assertCloneObjects(element, copy); + + // assert we clone the object's prototype + // @ts-ignore + expect(copy.__proto__).toEqual({ hello: "world" }); + expect(copy.hasOwnProperty("hello")).toBe(false); + + expect(copy.points).not.toBe(element.points); + expect(copy).not.toHaveProperty("shape"); + expect(copy.id).not.toBe(element.id); + expect(typeof copy.id).toBe("string"); + expect(copy.seed).not.toBe(element.seed); + expect(typeof copy.seed).toBe("number"); + expect(copy).toEqual({ + ...element, + id: copy.id, + seed: copy.seed, + }); + }); + + it("clones text element", () => { + const element = API.createElement({ + type: "text", + x: 0, + y: 0, + strokeColor: "#000000", + backgroundColor: "transparent", + fillStyle: "hachure", + strokeWidth: 1, + strokeStyle: "solid", + roundness: null, + roughness: 1, + opacity: 100, + text: "hello", + fontSize: 20, + fontFamily: FONT_FAMILY.Virgil, + textAlign: "left", + verticalAlign: "top", + }); + + const copy = duplicateElement(null, new Map(), element); + + assertCloneObjects(element, copy); + + expect(copy).not.toHaveProperty("points"); + expect(copy).not.toHaveProperty("shape"); + expect(copy.id).not.toBe(element.id); + expect(typeof copy.id).toBe("string"); + expect(typeof copy.seed).toBe("number"); + }); +}); + +describe("duplicating multiple elements", () => { + it("duplicateElements should clone bindings", () => { + const rectangle1 = API.createElement({ + type: "rectangle", + id: "rectangle1", + boundElements: [ + { id: "arrow1", type: "arrow" }, + { id: "arrow2", type: "arrow" }, + { id: "text1", type: "text" }, + ], + }); + + const text1 = API.createElement({ + type: "text", + id: "text1", + containerId: "rectangle1", + }); + + const arrow1 = API.createElement({ + type: "arrow", + id: "arrow1", + startBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + fixedPoint: [0.5, 1], + }, + }); + + const arrow2 = API.createElement({ + type: "arrow", + id: "arrow2", + endBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + fixedPoint: [0.5, 1], + }, + boundElements: [{ id: "text2", type: "text" }], + }); + + const text2 = API.createElement({ + type: "text", + id: "text2", + containerId: "arrow2", + }); + + // ------------------------------------------------------------------------- + + const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const; + const clonedElements = duplicateElements(origElements); + + // generic id in-equality checks + // -------------------------------------------------------------------------- + expect(origElements.map((e) => e.type)).toEqual( + clonedElements.map((e) => e.type), + ); + origElements.forEach((origElement, idx) => { + const clonedElement = clonedElements[idx]; + expect(origElement).toEqual( + expect.objectContaining({ + id: expect.not.stringMatching(clonedElement.id), + type: clonedElement.type, + }), + ); + if ("containerId" in origElement) { + expect(origElement.containerId).not.toBe( + (clonedElement as any).containerId, + ); + } + if ("endBinding" in origElement) { + if (origElement.endBinding) { + expect(origElement.endBinding.elementId).not.toBe( + (clonedElement as any).endBinding?.elementId, + ); + } else { + expect((clonedElement as any).endBinding).toBeNull(); + } + } + if ("startBinding" in origElement) { + if (origElement.startBinding) { + expect(origElement.startBinding.elementId).not.toBe( + (clonedElement as any).startBinding?.elementId, + ); + } else { + expect((clonedElement as any).startBinding).toBeNull(); + } + } + }); + // -------------------------------------------------------------------------- + + const clonedArrows = clonedElements.filter( + (e) => e.type === "arrow", + ) as ExcalidrawLinearElement[]; + + const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] = + clonedElements as any as typeof origElements; + + expect(clonedText1.containerId).toBe(clonedRectangle.id); + expect( + clonedRectangle.boundElements!.find((e) => e.id === clonedText1.id), + ).toEqual( + expect.objectContaining({ + id: clonedText1.id, + type: clonedText1.type, + }), + ); + + clonedArrows.forEach((arrow) => { + expect( + clonedRectangle.boundElements!.find((e) => e.id === arrow.id), + ).toEqual( + expect.objectContaining({ + id: arrow.id, + type: arrow.type, + }), + ); + + if (arrow.endBinding) { + expect(arrow.endBinding.elementId).toBe(clonedRectangle.id); + } + if (arrow.startBinding) { + expect(arrow.startBinding.elementId).toBe(clonedRectangle.id); + } + }); + + expect(clonedArrow2.boundElements).toEqual([ + { type: "text", id: clonedArrowLabel.id }, + ]); + expect(clonedArrowLabel.containerId).toBe(clonedArrow2.id); + }); + + it("should remove id references of elements that aren't found", () => { + const rectangle1 = API.createElement({ + type: "rectangle", + id: "rectangle1", + boundElements: [ + // should keep + { id: "arrow1", type: "arrow" }, + // should drop + { id: "arrow-not-exists", type: "arrow" }, + // should drop + { id: "text-not-exists", type: "text" }, + ], + }); + + const arrow1 = API.createElement({ + type: "arrow", + id: "arrow1", + startBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + fixedPoint: [0.5, 1], + }, + }); + + const text1 = API.createElement({ + type: "text", + id: "text1", + containerId: "rectangle-not-exists", + }); + + const arrow2 = API.createElement({ + type: "arrow", + id: "arrow2", + startBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + fixedPoint: [0.5, 1], + }, + endBinding: { + elementId: "rectangle-not-exists", + focus: 0.2, + gap: 7, + fixedPoint: [0.5, 1], + }, + }); + + const arrow3 = API.createElement({ + type: "arrow", + id: "arrow2", + startBinding: { + elementId: "rectangle-not-exists", + focus: 0.2, + gap: 7, + fixedPoint: [0.5, 1], + }, + endBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + fixedPoint: [0.5, 1], + }, + }); + + // ------------------------------------------------------------------------- + + const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const; + const clonedElements = duplicateElements( + origElements, + ) as any as typeof origElements; + const [ + clonedRectangle, + clonedText1, + clonedArrow1, + clonedArrow2, + clonedArrow3, + ] = clonedElements; + + expect(clonedRectangle.boundElements).toEqual([ + { id: clonedArrow1.id, type: "arrow" }, + ]); + + expect(clonedText1.containerId).toBe(null); + + expect(clonedArrow2.startBinding).toEqual({ + ...arrow2.startBinding, + elementId: clonedRectangle.id, + }); + expect(clonedArrow2.endBinding).toBe(null); + + expect(clonedArrow3.startBinding).toBe(null); + expect(clonedArrow3.endBinding).toEqual({ + ...arrow3.endBinding, + elementId: clonedRectangle.id, + }); + }); + + describe("should duplicate all group ids", () => { + it("should regenerate all group ids and keep them consistent across elements", () => { + const rectangle1 = API.createElement({ + type: "rectangle", + groupIds: ["g1"], + }); + const rectangle2 = API.createElement({ + type: "rectangle", + groupIds: ["g2", "g1"], + }); + const rectangle3 = API.createElement({ + type: "rectangle", + groupIds: ["g2", "g1"], + }); + + const origElements = [rectangle1, rectangle2, rectangle3] as const; + const clonedElements = duplicateElements( + origElements, + ) as any as typeof origElements; + const [clonedRectangle1, clonedRectangle2, clonedRectangle3] = + clonedElements; + + expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]); + expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]); + expect(rectangle2.groupIds[1]).not.toBe(clonedRectangle2.groupIds[1]); + + expect(clonedRectangle1.groupIds[0]).toBe(clonedRectangle2.groupIds[1]); + expect(clonedRectangle2.groupIds[0]).toBe(clonedRectangle3.groupIds[0]); + expect(clonedRectangle2.groupIds[1]).toBe(clonedRectangle3.groupIds[1]); + }); + + it("should keep and regenerate ids of groups even if invalid", () => { + // lone element shouldn't be able to be grouped with itself, + // but hard to check against in a performant way so we ignore it + const rectangle1 = API.createElement({ + type: "rectangle", + groupIds: ["g1"], + }); + + const [clonedRectangle1] = duplicateElements([rectangle1]); + + expect(typeof clonedRectangle1.groupIds[0]).toBe("string"); + expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]); + }); + }); +}); diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts new file mode 100644 index 0000000..7d1f149 --- /dev/null +++ b/packages/excalidraw/element/newElement.ts @@ -0,0 +1,793 @@ +import type { + ExcalidrawElement, + ExcalidrawImageElement, + ExcalidrawTextElement, + ExcalidrawLinearElement, + ExcalidrawGenericElement, + NonDeleted, + TextAlign, + GroupId, + VerticalAlign, + Arrowhead, + ExcalidrawFreeDrawElement, + FontFamilyValues, + ExcalidrawTextContainer, + ExcalidrawFrameElement, + ExcalidrawEmbeddableElement, + ExcalidrawMagicFrameElement, + ExcalidrawIframeElement, + ElementsMap, + ExcalidrawArrowElement, + FixedSegment, + ExcalidrawElbowArrowElement, +} from "./types"; +import { + arrayToMap, + getFontString, + getUpdatedTimestamp, + isTestEnv, +} from "../utils"; +import { randomInteger, randomId } from "../random"; +import { bumpVersion, newElementWith } from "./mutateElement"; +import { getNewGroupIdsForDuplication } from "../groups"; +import type { AppState } from "../types"; +import { getElementAbsoluteCoords } from "."; +import { getResizedElementAbsoluteCoords } from "./bounds"; +import { getBoundTextMaxWidth } from "./textElement"; +import { wrapText } from "./textWrapping"; +import { + DEFAULT_ELEMENT_PROPS, + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + DEFAULT_TEXT_ALIGN, + DEFAULT_VERTICAL_ALIGN, + ORIG_ID, + VERTICAL_ALIGN, +} from "../constants"; +import type { MarkOptional, Merge, Mutable } from "../utility-types"; +import { getLineHeight } from "../fonts"; +import type { Radians } from "@excalidraw/math"; +import { normalizeText, measureText } from "./textMeasurements"; + +export type ElementConstructorOpts = MarkOptional< + Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, + | "width" + | "height" + | "angle" + | "groupIds" + | "frameId" + | "index" + | "boundElements" + | "seed" + | "version" + | "versionNonce" + | "link" + | "strokeStyle" + | "fillStyle" + | "strokeColor" + | "backgroundColor" + | "roughness" + | "strokeWidth" + | "roundness" + | "locked" + | "opacity" + | "customData" +>; + +const _newElementBase = <T extends ExcalidrawElement>( + type: T["type"], + { + x, + y, + strokeColor = DEFAULT_ELEMENT_PROPS.strokeColor, + backgroundColor = DEFAULT_ELEMENT_PROPS.backgroundColor, + fillStyle = DEFAULT_ELEMENT_PROPS.fillStyle, + strokeWidth = DEFAULT_ELEMENT_PROPS.strokeWidth, + strokeStyle = DEFAULT_ELEMENT_PROPS.strokeStyle, + roughness = DEFAULT_ELEMENT_PROPS.roughness, + opacity = DEFAULT_ELEMENT_PROPS.opacity, + width = 0, + height = 0, + angle = 0 as Radians, + groupIds = [], + frameId = null, + index = null, + roundness = null, + boundElements = null, + link = null, + locked = DEFAULT_ELEMENT_PROPS.locked, + ...rest + }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, +) => { + // NOTE (mtolmacs): This is a temporary check to detect extremely large + // element position or sizing + if ( + x < -1e6 || + x > 1e6 || + y < -1e6 || + y > 1e6 || + width < -1e6 || + width > 1e6 || + height < -1e6 || + height > 1e6 + ) { + console.error("New element size or position is too large", { + x, + y, + width, + height, + // @ts-ignore + points: rest.points, + }); + } + + // assign type to guard against excess properties + const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = { + id: rest.id || randomId(), + type, + x, + y, + width, + height, + angle, + strokeColor, + backgroundColor, + fillStyle, + strokeWidth, + strokeStyle, + roughness, + opacity, + groupIds, + frameId, + index, + roundness, + seed: rest.seed ?? randomInteger(), + version: rest.version || 1, + versionNonce: rest.versionNonce ?? 0, + isDeleted: false as false, + boundElements, + updated: getUpdatedTimestamp(), + link, + locked, + customData: rest.customData, + }; + return element; +}; + +export const newElement = ( + opts: { + type: ExcalidrawGenericElement["type"]; + } & ElementConstructorOpts, +): NonDeleted<ExcalidrawGenericElement> => + _newElementBase<ExcalidrawGenericElement>(opts.type, opts); + +export const newEmbeddableElement = ( + opts: { + type: "embeddable"; + } & ElementConstructorOpts, +): NonDeleted<ExcalidrawEmbeddableElement> => { + return _newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts); +}; + +export const newIframeElement = ( + opts: { + type: "iframe"; + } & ElementConstructorOpts, +): NonDeleted<ExcalidrawIframeElement> => { + return { + ..._newElementBase<ExcalidrawIframeElement>("iframe", opts), + }; +}; + +export const newFrameElement = ( + opts: { + name?: string; + } & ElementConstructorOpts, +): NonDeleted<ExcalidrawFrameElement> => { + const frameElement = newElementWith( + { + ..._newElementBase<ExcalidrawFrameElement>("frame", opts), + type: "frame", + name: opts?.name || null, + }, + {}, + ); + + return frameElement; +}; + +export const newMagicFrameElement = ( + opts: { + name?: string; + } & ElementConstructorOpts, +): NonDeleted<ExcalidrawMagicFrameElement> => { + const frameElement = newElementWith( + { + ..._newElementBase<ExcalidrawMagicFrameElement>("magicframe", opts), + type: "magicframe", + name: opts?.name || null, + }, + {}, + ); + + return frameElement; +}; + +/** computes element x/y offset based on textAlign/verticalAlign */ +const getTextElementPositionOffsets = ( + opts: { + textAlign: ExcalidrawTextElement["textAlign"]; + verticalAlign: ExcalidrawTextElement["verticalAlign"]; + }, + metrics: { + width: number; + height: number; + }, +) => { + return { + x: + opts.textAlign === "center" + ? metrics.width / 2 + : opts.textAlign === "right" + ? metrics.width + : 0, + y: opts.verticalAlign === "middle" ? metrics.height / 2 : 0, + }; +}; + +export const newTextElement = ( + opts: { + text: string; + originalText?: string; + fontSize?: number; + fontFamily?: FontFamilyValues; + textAlign?: TextAlign; + verticalAlign?: VerticalAlign; + containerId?: ExcalidrawTextContainer["id"] | null; + lineHeight?: ExcalidrawTextElement["lineHeight"]; + autoResize?: ExcalidrawTextElement["autoResize"]; + } & ElementConstructorOpts, +): NonDeleted<ExcalidrawTextElement> => { + const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY; + const fontSize = opts.fontSize || DEFAULT_FONT_SIZE; + const lineHeight = opts.lineHeight || getLineHeight(fontFamily); + const text = normalizeText(opts.text); + const metrics = measureText( + text, + getFontString({ fontFamily, fontSize }), + lineHeight, + ); + const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN; + const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN; + const offsets = getTextElementPositionOffsets( + { textAlign, verticalAlign }, + metrics, + ); + + const textElementProps: ExcalidrawTextElement = { + ..._newElementBase<ExcalidrawTextElement>("text", opts), + text, + fontSize, + fontFamily, + textAlign, + verticalAlign, + x: opts.x - offsets.x, + y: opts.y - offsets.y, + width: metrics.width, + height: metrics.height, + containerId: opts.containerId || null, + originalText: opts.originalText ?? text, + autoResize: opts.autoResize ?? true, + lineHeight, + }; + + const textElement: ExcalidrawTextElement = newElementWith( + textElementProps, + {}, + ); + + return textElement; +}; + +const getAdjustedDimensions = ( + element: ExcalidrawTextElement, + elementsMap: ElementsMap, + nextText: string, +): { + x: number; + y: number; + width: number; + height: number; +} => { + let { width: nextWidth, height: nextHeight } = measureText( + nextText, + getFontString(element), + element.lineHeight, + ); + + // wrapped text + if (!element.autoResize) { + nextWidth = element.width; + } + + const { textAlign, verticalAlign } = element; + let x: number; + let y: number; + if ( + textAlign === "center" && + verticalAlign === VERTICAL_ALIGN.MIDDLE && + !element.containerId && + element.autoResize + ) { + const prevMetrics = measureText( + element.text, + getFontString(element), + element.lineHeight, + ); + const offsets = getTextElementPositionOffsets(element, { + width: nextWidth - prevMetrics.width, + height: nextHeight - prevMetrics.height, + }); + + x = element.x - offsets.x; + y = element.y - offsets.y; + } else { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + + const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords( + element, + nextWidth, + nextHeight, + false, + ); + const deltaX1 = (x1 - nextX1) / 2; + const deltaY1 = (y1 - nextY1) / 2; + const deltaX2 = (x2 - nextX2) / 2; + const deltaY2 = (y2 - nextY2) / 2; + + [x, y] = adjustXYWithRotation( + { + s: true, + e: textAlign === "center" || textAlign === "left", + w: textAlign === "center" || textAlign === "right", + }, + element.x, + element.y, + element.angle, + deltaX1, + deltaY1, + deltaX2, + deltaY2, + ); + } + + return { + width: nextWidth, + height: nextHeight, + x: Number.isFinite(x) ? x : element.x, + y: Number.isFinite(y) ? y : element.y, + }; +}; + +const adjustXYWithRotation = ( + sides: { + n?: boolean; + e?: boolean; + s?: boolean; + w?: boolean; + }, + x: number, + y: number, + angle: number, + deltaX1: number, + deltaY1: number, + deltaX2: number, + deltaY2: number, +): [number, number] => { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + if (sides.e && sides.w) { + x += deltaX1 + deltaX2; + } else if (sides.e) { + x += deltaX1 * (1 + cos); + y += deltaX1 * sin; + x += deltaX2 * (1 - cos); + y += deltaX2 * -sin; + } else if (sides.w) { + x += deltaX1 * (1 - cos); + y += deltaX1 * -sin; + x += deltaX2 * (1 + cos); + y += deltaX2 * sin; + } + + if (sides.n && sides.s) { + y += deltaY1 + deltaY2; + } else if (sides.n) { + x += deltaY1 * sin; + y += deltaY1 * (1 - cos); + x += deltaY2 * -sin; + y += deltaY2 * (1 + cos); + } else if (sides.s) { + x += deltaY1 * -sin; + y += deltaY1 * (1 + cos); + x += deltaY2 * sin; + y += deltaY2 * (1 - cos); + } + return [x, y]; +}; + +export const refreshTextDimensions = ( + textElement: ExcalidrawTextElement, + container: ExcalidrawTextContainer | null, + elementsMap: ElementsMap, + text = textElement.text, +) => { + if (textElement.isDeleted) { + return; + } + if (container || !textElement.autoResize) { + text = wrapText( + text, + getFontString(textElement), + container + ? getBoundTextMaxWidth(container, textElement) + : textElement.width, + ); + } + const dimensions = getAdjustedDimensions(textElement, elementsMap, text); + return { text, ...dimensions }; +}; + +export const newFreeDrawElement = ( + opts: { + type: "freedraw"; + points?: ExcalidrawFreeDrawElement["points"]; + simulatePressure: boolean; + pressures?: ExcalidrawFreeDrawElement["pressures"]; + } & ElementConstructorOpts, +): NonDeleted<ExcalidrawFreeDrawElement> => { + return { + ..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts), + points: opts.points || [], + pressures: opts.pressures || [], + simulatePressure: opts.simulatePressure, + lastCommittedPoint: null, + }; +}; + +export const newLinearElement = ( + opts: { + type: ExcalidrawLinearElement["type"]; + points?: ExcalidrawLinearElement["points"]; + } & ElementConstructorOpts, +): NonDeleted<ExcalidrawLinearElement> => { + return { + ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts), + points: opts.points || [], + lastCommittedPoint: null, + startBinding: null, + endBinding: null, + startArrowhead: null, + endArrowhead: null, + }; +}; + +export const newArrowElement = <T extends boolean>( + opts: { + type: ExcalidrawArrowElement["type"]; + startArrowhead?: Arrowhead | null; + endArrowhead?: Arrowhead | null; + points?: ExcalidrawArrowElement["points"]; + elbowed?: T; + fixedSegments?: FixedSegment[] | null; + } & ElementConstructorOpts, +): T extends true + ? NonDeleted<ExcalidrawElbowArrowElement> + : NonDeleted<ExcalidrawArrowElement> => { + if (opts.elbowed) { + return { + ..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts), + points: opts.points || [], + lastCommittedPoint: null, + startBinding: null, + endBinding: null, + startArrowhead: opts.startArrowhead || null, + endArrowhead: opts.endArrowhead || null, + elbowed: true, + fixedSegments: opts.fixedSegments || [], + startIsSpecial: false, + endIsSpecial: false, + } as NonDeleted<ExcalidrawElbowArrowElement>; + } + + return { + ..._newElementBase<ExcalidrawArrowElement>(opts.type, opts), + points: opts.points || [], + lastCommittedPoint: null, + startBinding: null, + endBinding: null, + startArrowhead: opts.startArrowhead || null, + endArrowhead: opts.endArrowhead || null, + elbowed: false, + } as T extends true + ? NonDeleted<ExcalidrawElbowArrowElement> + : NonDeleted<ExcalidrawArrowElement>; +}; + +export const newImageElement = ( + opts: { + type: ExcalidrawImageElement["type"]; + status?: ExcalidrawImageElement["status"]; + fileId?: ExcalidrawImageElement["fileId"]; + scale?: ExcalidrawImageElement["scale"]; + crop?: ExcalidrawImageElement["crop"]; + } & ElementConstructorOpts, +): NonDeleted<ExcalidrawImageElement> => { + return { + ..._newElementBase<ExcalidrawImageElement>("image", opts), + // in the future we'll support changing stroke color for some SVG elements, + // and `transparent` will likely mean "use original colors of the image" + strokeColor: "transparent", + status: opts.status ?? "pending", + fileId: opts.fileId ?? null, + scale: opts.scale ?? [1, 1], + crop: opts.crop ?? null, + }; +}; + +// Simplified deep clone for the purpose of cloning ExcalidrawElement. +// +// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set, +// Typed arrays and other non-null objects. +// +// Adapted from https://github.com/lukeed/klona +// +// The reason for `deepCopyElement()` wrapper is type safety (only allow +// passing ExcalidrawElement as the top-level argument). +const _deepCopyElement = (val: any, depth: number = 0) => { + // only clone non-primitives + if (val == null || typeof val !== "object") { + return val; + } + + const objectType = Object.prototype.toString.call(val); + + if (objectType === "[object Object]") { + const tmp = + typeof val.constructor === "function" + ? Object.create(Object.getPrototypeOf(val)) + : {}; + for (const key in val) { + if (val.hasOwnProperty(key)) { + // don't copy non-serializable objects like these caches. They'll be + // populated when the element is rendered. + if (depth === 0 && (key === "shape" || key === "canvas")) { + continue; + } + tmp[key] = _deepCopyElement(val[key], depth + 1); + } + } + return tmp; + } + + if (Array.isArray(val)) { + let k = val.length; + const arr = new Array(k); + while (k--) { + arr[k] = _deepCopyElement(val[k], depth + 1); + } + return arr; + } + + // we're not cloning non-array & non-plain-object objects because we + // don't support them on excalidraw elements yet. If we do, we need to make + // sure we start cloning them, so let's warn about it. + if (import.meta.env.DEV) { + if ( + objectType !== "[object Object]" && + objectType !== "[object Array]" && + objectType.startsWith("[object ") + ) { + console.warn( + `_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`, + ); + } + } + + return val; +}; + +/** + * Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or + * any value. The purpose is to to break object references for immutability + * reasons, whenever we want to keep the original element, but ensure it's not + * mutated. + * + * Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set, + * Typed arrays and other non-null objects. + */ +export const deepCopyElement = <T extends ExcalidrawElement>( + val: T, +): Mutable<T> => { + return _deepCopyElement(val); +}; + +const __test__defineOrigId = (clonedObj: object, origId: string) => { + Object.defineProperty(clonedObj, ORIG_ID, { + value: origId, + writable: false, + enumerable: false, + }); +}; + +/** + * utility wrapper to generate new id. + */ +const regenerateId = () => { + return randomId(); +}; + +/** + * Duplicate an element, often used in the alt-drag operation. + * Note that this method has gotten a bit complicated since the + * introduction of gruoping/ungrouping elements. + * @param editingGroupId The current group being edited. The new + * element will inherit this group and its + * parents. + * @param groupIdMapForOperation A Map that maps old group IDs to + * duplicated ones. If you are duplicating + * multiple elements at once, share this map + * amongst all of them + * @param element Element to duplicate + * @param overrides Any element properties to override + */ +export const duplicateElement = <TElement extends ExcalidrawElement>( + editingGroupId: AppState["editingGroupId"], + groupIdMapForOperation: Map<GroupId, GroupId>, + element: TElement, + overrides?: Partial<TElement>, +): Readonly<TElement> => { + let copy = deepCopyElement(element); + + if (isTestEnv()) { + __test__defineOrigId(copy, element.id); + } + + copy.id = regenerateId(); + copy.boundElements = null; + copy.updated = getUpdatedTimestamp(); + copy.seed = randomInteger(); + copy.groupIds = getNewGroupIdsForDuplication( + copy.groupIds, + editingGroupId, + (groupId) => { + if (!groupIdMapForOperation.has(groupId)) { + groupIdMapForOperation.set(groupId, regenerateId()); + } + return groupIdMapForOperation.get(groupId)!; + }, + ); + if (overrides) { + copy = Object.assign(copy, overrides); + } + return copy; +}; + +/** + * Clones elements, regenerating their ids (including bindings) and group ids. + * + * If bindings don't exist in the elements array, they are removed. Therefore, + * it's advised to supply the whole elements array, or sets of elements that + * are encapsulated (such as library items), if the purpose is to retain + * bindings to the cloned elements intact. + * + * NOTE by default does not randomize or regenerate anything except the id. + */ +export const duplicateElements = ( + elements: readonly ExcalidrawElement[], + opts?: { + /** NOTE also updates version flags and `updated` */ + randomizeSeed: boolean; + }, +) => { + const clonedElements: ExcalidrawElement[] = []; + + const origElementsMap = arrayToMap(elements); + + // used for for migrating old ids to new ids + const elementNewIdsMap = new Map< + /* orig */ ExcalidrawElement["id"], + /* new */ ExcalidrawElement["id"] + >(); + + const maybeGetNewId = (id: ExcalidrawElement["id"]) => { + // if we've already migrated the element id, return the new one directly + if (elementNewIdsMap.has(id)) { + return elementNewIdsMap.get(id)!; + } + // if we haven't migrated the element id, but an old element with the same + // id exists, generate a new id for it and return it + if (origElementsMap.has(id)) { + const newId = regenerateId(); + elementNewIdsMap.set(id, newId); + return newId; + } + // if old element doesn't exist, return null to mark it for removal + return null; + }; + + const groupNewIdsMap = new Map</* orig */ GroupId, /* new */ GroupId>(); + + for (const element of elements) { + const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element); + + clonedElement.id = maybeGetNewId(element.id)!; + if (isTestEnv()) { + __test__defineOrigId(clonedElement, element.id); + } + + if (opts?.randomizeSeed) { + clonedElement.seed = randomInteger(); + bumpVersion(clonedElement); + } + + if (clonedElement.groupIds) { + clonedElement.groupIds = clonedElement.groupIds.map((groupId) => { + if (!groupNewIdsMap.has(groupId)) { + groupNewIdsMap.set(groupId, regenerateId()); + } + return groupNewIdsMap.get(groupId)!; + }); + } + + if ("containerId" in clonedElement && clonedElement.containerId) { + const newContainerId = maybeGetNewId(clonedElement.containerId); + clonedElement.containerId = newContainerId; + } + + if ("boundElements" in clonedElement && clonedElement.boundElements) { + clonedElement.boundElements = clonedElement.boundElements.reduce( + ( + acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>, + binding, + ) => { + const newBindingId = maybeGetNewId(binding.id); + if (newBindingId) { + acc.push({ ...binding, id: newBindingId }); + } + return acc; + }, + [], + ); + } + + if ("endBinding" in clonedElement && clonedElement.endBinding) { + const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId); + clonedElement.endBinding = newEndBindingId + ? { + ...clonedElement.endBinding, + elementId: newEndBindingId, + } + : null; + } + if ("startBinding" in clonedElement && clonedElement.startBinding) { + const newEndBindingId = maybeGetNewId( + clonedElement.startBinding.elementId, + ); + clonedElement.startBinding = newEndBindingId + ? { + ...clonedElement.startBinding, + elementId: newEndBindingId, + } + : null; + } + + if (clonedElement.frameId) { + clonedElement.frameId = maybeGetNewId(clonedElement.frameId); + } + + clonedElements.push(clonedElement); + } + + return clonedElements; +}; diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts new file mode 100644 index 0000000..b86ba02 --- /dev/null +++ b/packages/excalidraw/element/resizeElements.ts @@ -0,0 +1,1545 @@ +import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants"; +import { rescalePoints } from "../points"; +import type { + ExcalidrawLinearElement, + ExcalidrawTextElement, + NonDeletedExcalidrawElement, + NonDeleted, + ExcalidrawElement, + ExcalidrawTextElementWithContainer, + ExcalidrawImageElement, + ElementsMap, + SceneElementsMap, + ExcalidrawElbowArrowElement, +} from "./types"; +import type { Mutable } from "../utility-types"; +import { + getElementAbsoluteCoords, + getCommonBounds, + getResizedElementAbsoluteCoords, + getCommonBoundingBox, + getElementBounds, +} from "./bounds"; +import type { BoundingBox } from "./bounds"; +import { + isArrowElement, + isBoundToContainer, + isElbowArrow, + isFrameLikeElement, + isFreeDrawElement, + isImageElement, + isLinearElement, + isTextElement, +} from "./typeChecks"; +import { mutateElement } from "./mutateElement"; +import { getFontString } from "../utils"; +import { getArrowLocalFixedPoints, updateBoundElements } from "./binding"; +import type { + MaybeTransformHandleType, + TransformHandleDirection, +} from "./transformHandles"; +import type { PointerDownState } from "../types"; +import type Scene from "../scene/Scene"; +import { + getBoundTextElement, + getBoundTextElementId, + getContainerElement, + handleBindTextResize, + getBoundTextMaxWidth, +} from "./textElement"; +import { wrapText } from "./textWrapping"; +import { LinearElementEditor } from "./linearElementEditor"; +import { isInGroup } from "../groups"; +import type { GlobalPoint } from "@excalidraw/math"; +import { + pointCenter, + normalizeRadians, + pointFrom, + pointFromPair, + pointRotateRads, + type Radians, + type LocalPoint, +} from "@excalidraw/math"; +import { + getMinTextElementWidth, + measureText, + getApproxMinLineWidth, + getApproxMinLineHeight, +} from "./textMeasurements"; + +// Returns true when transform (resizing/rotation) happened +export const transformElements = ( + originalElements: PointerDownState["originalElements"], + transformHandleType: MaybeTransformHandleType, + selectedElements: readonly NonDeletedExcalidrawElement[], + elementsMap: SceneElementsMap, + scene: Scene, + shouldRotateWithDiscreteAngle: boolean, + shouldResizeFromCenter: boolean, + shouldMaintainAspectRatio: boolean, + pointerX: number, + pointerY: number, + centerX: number, + centerY: number, +): boolean => { + if (selectedElements.length === 1) { + const [element] = selectedElements; + if (transformHandleType === "rotation") { + if (!isElbowArrow(element)) { + rotateSingleElement( + element, + elementsMap, + scene, + pointerX, + pointerY, + shouldRotateWithDiscreteAngle, + ); + updateBoundElements(element, elementsMap); + } + } else if (isTextElement(element) && transformHandleType) { + resizeSingleTextElement( + originalElements, + element, + elementsMap, + transformHandleType, + shouldResizeFromCenter, + pointerX, + pointerY, + ); + updateBoundElements(element, elementsMap); + return true; + } else if (transformHandleType) { + const elementId = selectedElements[0].id; + const latestElement = elementsMap.get(elementId); + const origElement = originalElements.get(elementId); + + if (latestElement && origElement) { + const { nextWidth, nextHeight } = + getNextSingleWidthAndHeightFromPointer( + latestElement, + origElement, + elementsMap, + originalElements, + transformHandleType, + pointerX, + pointerY, + { + shouldMaintainAspectRatio, + shouldResizeFromCenter, + }, + ); + + resizeSingleElement( + nextWidth, + nextHeight, + latestElement, + origElement, + elementsMap, + originalElements, + transformHandleType, + { + shouldMaintainAspectRatio, + shouldResizeFromCenter, + }, + ); + } + } + return true; + } else if (selectedElements.length > 1) { + if (transformHandleType === "rotation") { + rotateMultipleElements( + originalElements, + selectedElements, + elementsMap, + scene, + pointerX, + pointerY, + shouldRotateWithDiscreteAngle, + centerX, + centerY, + ); + return true; + } else if (transformHandleType) { + const { nextWidth, nextHeight, flipByX, flipByY, originalBoundingBox } = + getNextMultipleWidthAndHeightFromPointer( + selectedElements, + originalElements, + elementsMap, + transformHandleType, + pointerX, + pointerY, + { + shouldMaintainAspectRatio, + shouldResizeFromCenter, + }, + ); + + resizeMultipleElements( + selectedElements, + elementsMap, + transformHandleType, + scene, + originalElements, + { + shouldResizeFromCenter, + shouldMaintainAspectRatio, + flipByX, + flipByY, + nextWidth, + nextHeight, + originalBoundingBox, + }, + ); + + return true; + } + } + return false; +}; + +const rotateSingleElement = ( + element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, + scene: Scene, + pointerX: number, + pointerY: number, + shouldRotateWithDiscreteAngle: boolean, +) => { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + let angle: Radians; + if (isFrameLikeElement(element)) { + angle = 0 as Radians; + } else { + angle = ((5 * Math.PI) / 2 + + Math.atan2(pointerY - cy, pointerX - cx)) as Radians; + if (shouldRotateWithDiscreteAngle) { + angle = (angle + SHIFT_LOCKING_ANGLE / 2) as Radians; + angle = (angle - (angle % SHIFT_LOCKING_ANGLE)) as Radians; + } + angle = normalizeRadians(angle as Radians); + } + const boundTextElementId = getBoundTextElementId(element); + + mutateElement(element, { angle }); + if (boundTextElementId) { + const textElement = + scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId); + + if (textElement && !isArrowElement(element)) { + mutateElement(textElement, { angle }); + } + } +}; + +export const rescalePointsInElement = ( + element: NonDeletedExcalidrawElement, + width: number, + height: number, + normalizePoints: boolean, +) => + isLinearElement(element) || isFreeDrawElement(element) + ? { + points: rescalePoints( + 0, + width, + rescalePoints(1, height, element.points, normalizePoints), + normalizePoints, + ), + } + : {}; + +export const measureFontSizeFromWidth = ( + element: NonDeleted<ExcalidrawTextElement>, + elementsMap: ElementsMap, + nextWidth: number, +): { size: number } | null => { + // We only use width to scale font on resize + let width = element.width; + + const hasContainer = isBoundToContainer(element); + if (hasContainer) { + const container = getContainerElement(element, elementsMap); + if (container) { + width = getBoundTextMaxWidth(container, element); + } + } + const nextFontSize = element.fontSize * (nextWidth / width); + if (nextFontSize < MIN_FONT_SIZE) { + return null; + } + + return { + size: nextFontSize, + }; +}; + +const resizeSingleTextElement = ( + originalElements: PointerDownState["originalElements"], + element: NonDeleted<ExcalidrawTextElement>, + elementsMap: ElementsMap, + transformHandleType: TransformHandleDirection, + shouldResizeFromCenter: boolean, + pointerX: number, + pointerY: number, +) => { + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); + // rotation pointer with reverse angle + const [rotatedX, rotatedY] = pointRotateRads( + pointFrom(pointerX, pointerY), + pointFrom(cx, cy), + -element.angle as Radians, + ); + let scaleX = 0; + let scaleY = 0; + + if (transformHandleType !== "e" && transformHandleType !== "w") { + if (transformHandleType.includes("e")) { + scaleX = (rotatedX - x1) / (x2 - x1); + } + if (transformHandleType.includes("w")) { + scaleX = (x2 - rotatedX) / (x2 - x1); + } + if (transformHandleType.includes("n")) { + scaleY = (y2 - rotatedY) / (y2 - y1); + } + if (transformHandleType.includes("s")) { + scaleY = (rotatedY - y1) / (y2 - y1); + } + } + + const scale = Math.max(scaleX, scaleY); + + if (scale > 0) { + const nextWidth = element.width * scale; + const nextHeight = element.height * scale; + const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth); + if (metrics === null) { + return; + } + + const startTopLeft = [x1, y1]; + const startBottomRight = [x2, y2]; + const startCenter = [cx, cy]; + + let newTopLeft = pointFrom<GlobalPoint>(x1, y1); + if (["n", "w", "nw"].includes(transformHandleType)) { + newTopLeft = pointFrom<GlobalPoint>( + startBottomRight[0] - Math.abs(nextWidth), + startBottomRight[1] - Math.abs(nextHeight), + ); + } + if (transformHandleType === "ne") { + const bottomLeft = [startTopLeft[0], startBottomRight[1]]; + newTopLeft = pointFrom<GlobalPoint>( + bottomLeft[0], + bottomLeft[1] - Math.abs(nextHeight), + ); + } + if (transformHandleType === "sw") { + const topRight = [startBottomRight[0], startTopLeft[1]]; + newTopLeft = pointFrom<GlobalPoint>( + topRight[0] - Math.abs(nextWidth), + topRight[1], + ); + } + + if (["s", "n"].includes(transformHandleType)) { + newTopLeft[0] = startCenter[0] - nextWidth / 2; + } + if (["e", "w"].includes(transformHandleType)) { + newTopLeft[1] = startCenter[1] - nextHeight / 2; + } + + if (shouldResizeFromCenter) { + newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2; + newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2; + } + + const angle = element.angle; + const rotatedTopLeft = pointRotateRads( + newTopLeft, + pointFrom(cx, cy), + angle, + ); + const newCenter = pointFrom<GlobalPoint>( + newTopLeft[0] + Math.abs(nextWidth) / 2, + newTopLeft[1] + Math.abs(nextHeight) / 2, + ); + const rotatedNewCenter = pointRotateRads( + newCenter, + pointFrom(cx, cy), + angle, + ); + newTopLeft = pointRotateRads( + rotatedTopLeft, + rotatedNewCenter, + -angle as Radians, + ); + const [nextX, nextY] = newTopLeft; + + mutateElement(element, { + fontSize: metrics.size, + width: nextWidth, + height: nextHeight, + x: nextX, + y: nextY, + }); + } + + if (transformHandleType === "e" || transformHandleType === "w") { + const stateAtResizeStart = originalElements.get(element.id)!; + const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords( + stateAtResizeStart, + stateAtResizeStart.width, + stateAtResizeStart.height, + true, + ); + const startTopLeft = pointFrom<GlobalPoint>(x1, y1); + const startBottomRight = pointFrom<GlobalPoint>(x2, y2); + const startCenter = pointCenter(startTopLeft, startBottomRight); + + const rotatedPointer = pointRotateRads( + pointFrom(pointerX, pointerY), + startCenter, + -stateAtResizeStart.angle as Radians, + ); + + const [esx1, , esx2] = getResizedElementAbsoluteCoords( + element, + element.width, + element.height, + true, + ); + + const boundsCurrentWidth = esx2 - esx1; + + const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0]; + const minWidth = getMinTextElementWidth( + getFontString({ + fontSize: element.fontSize, + fontFamily: element.fontFamily, + }), + element.lineHeight, + ); + + let scaleX = atStartBoundsWidth / boundsCurrentWidth; + + if (transformHandleType.includes("e")) { + scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth; + } + if (transformHandleType.includes("w")) { + scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth; + } + + const newWidth = + element.width * scaleX < minWidth ? minWidth : element.width * scaleX; + + const text = wrapText( + element.originalText, + getFontString(element), + Math.abs(newWidth), + ); + const metrics = measureText( + text, + getFontString(element), + element.lineHeight, + ); + + const eleNewHeight = metrics.height; + + const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] = + getResizedElementAbsoluteCoords( + stateAtResizeStart, + newWidth, + eleNewHeight, + true, + ); + const newBoundsWidth = newBoundsX2 - newBoundsX1; + const newBoundsHeight = newBoundsY2 - newBoundsY1; + + let newTopLeft = [...startTopLeft] as [number, number]; + if (["n", "w", "nw"].includes(transformHandleType)) { + newTopLeft = [ + startBottomRight[0] - Math.abs(newBoundsWidth), + startTopLeft[1], + ]; + } + + // adjust topLeft to new rotation point + const angle = stateAtResizeStart.angle; + const rotatedTopLeft = pointRotateRads( + pointFromPair(newTopLeft), + startCenter, + angle, + ); + const newCenter = pointFrom( + newTopLeft[0] + Math.abs(newBoundsWidth) / 2, + newTopLeft[1] + Math.abs(newBoundsHeight) / 2, + ); + const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle); + newTopLeft = pointRotateRads( + rotatedTopLeft, + rotatedNewCenter, + -angle as Radians, + ); + + const resizedElement: Partial<ExcalidrawTextElement> = { + width: Math.abs(newWidth), + height: Math.abs(metrics.height), + x: newTopLeft[0], + y: newTopLeft[1], + text, + autoResize: false, + }; + + mutateElement(element, resizedElement); + } +}; + +const rotateMultipleElements = ( + originalElements: PointerDownState["originalElements"], + elements: readonly NonDeletedExcalidrawElement[], + elementsMap: SceneElementsMap, + scene: Scene, + pointerX: number, + pointerY: number, + shouldRotateWithDiscreteAngle: boolean, + centerX: number, + centerY: number, +) => { + let centerAngle = + (5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX); + if (shouldRotateWithDiscreteAngle) { + centerAngle += SHIFT_LOCKING_ANGLE / 2; + centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE; + } + + for (const element of elements) { + if (!isFrameLikeElement(element)) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const origAngle = + originalElements.get(element.id)?.angle ?? element.angle; + const [rotatedCX, rotatedCY] = pointRotateRads( + pointFrom(cx, cy), + pointFrom(centerX, centerY), + (centerAngle + origAngle - element.angle) as Radians, + ); + + if (isElbowArrow(element)) { + // Needed to re-route the arrow + mutateElement(element, { + points: getArrowLocalFixedPoints(element, elementsMap), + }); + } else { + mutateElement( + element, + { + x: element.x + (rotatedCX - cx), + y: element.y + (rotatedCY - cy), + angle: normalizeRadians((centerAngle + origAngle) as Radians), + }, + false, + ); + } + + updateBoundElements(element, elementsMap, { + simultaneouslyUpdated: elements, + }); + + const boundText = getBoundTextElement(element, elementsMap); + if (boundText && !isArrowElement(element)) { + mutateElement( + boundText, + { + x: boundText.x + (rotatedCX - cx), + y: boundText.y + (rotatedCY - cy), + angle: normalizeRadians((centerAngle + origAngle) as Radians), + }, + false, + ); + } + } + } + + scene.triggerUpdate(); +}; + +export const getResizeOffsetXY = ( + transformHandleType: MaybeTransformHandleType, + selectedElements: NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, + x: number, + y: number, +): [number, number] => { + const [x1, y1, x2, y2] = + selectedElements.length === 1 + ? getElementAbsoluteCoords(selectedElements[0], elementsMap) + : getCommonBounds(selectedElements); + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const angle = ( + selectedElements.length === 1 ? selectedElements[0].angle : 0 + ) as Radians; + [x, y] = pointRotateRads( + pointFrom(x, y), + pointFrom(cx, cy), + -angle as Radians, + ); + switch (transformHandleType) { + case "n": + return pointRotateRads( + pointFrom(x - (x1 + x2) / 2, y - y1), + pointFrom(0, 0), + angle, + ); + case "s": + return pointRotateRads( + pointFrom(x - (x1 + x2) / 2, y - y2), + pointFrom(0, 0), + angle, + ); + case "w": + return pointRotateRads( + pointFrom(x - x1, y - (y1 + y2) / 2), + pointFrom(0, 0), + angle, + ); + case "e": + return pointRotateRads( + pointFrom(x - x2, y - (y1 + y2) / 2), + pointFrom(0, 0), + angle, + ); + case "nw": + return pointRotateRads(pointFrom(x - x1, y - y1), pointFrom(0, 0), angle); + case "ne": + return pointRotateRads(pointFrom(x - x2, y - y1), pointFrom(0, 0), angle); + case "sw": + return pointRotateRads(pointFrom(x - x1, y - y2), pointFrom(0, 0), angle); + case "se": + return pointRotateRads(pointFrom(x - x2, y - y2), pointFrom(0, 0), angle); + default: + return [0, 0]; + } +}; + +export const getResizeArrowDirection = ( + transformHandleType: MaybeTransformHandleType, + element: NonDeleted<ExcalidrawLinearElement>, +): "origin" | "end" => { + const [, [px, py]] = element.points; + const isResizeEnd = + (transformHandleType === "nw" && (px < 0 || py < 0)) || + (transformHandleType === "ne" && px >= 0) || + (transformHandleType === "sw" && px <= 0) || + (transformHandleType === "se" && (px > 0 || py > 0)); + return isResizeEnd ? "end" : "origin"; +}; + +type ResizeAnchor = + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" + | "west-side" + | "north-side" + | "east-side" + | "south-side" + | "center"; + +const getResizeAnchor = ( + handleDirection: TransformHandleDirection, + shouldMaintainAspectRatio: boolean, + shouldResizeFromCenter: boolean, +): ResizeAnchor => { + if (shouldResizeFromCenter) { + return "center"; + } + + if (shouldMaintainAspectRatio) { + switch (handleDirection) { + case "n": + return "south-side"; + case "e": { + return "west-side"; + } + case "s": + return "north-side"; + case "w": + return "east-side"; + case "ne": + return "bottom-left"; + case "nw": + return "bottom-right"; + case "se": + return "top-left"; + case "sw": + return "top-right"; + } + } + + if (["e", "se", "s"].includes(handleDirection)) { + return "top-left"; + } else if (["n", "nw", "w"].includes(handleDirection)) { + return "bottom-right"; + } else if (handleDirection === "ne") { + return "bottom-left"; + } + return "top-right"; +}; + +const getResizedOrigin = ( + prevOrigin: GlobalPoint, + prevWidth: number, + prevHeight: number, + newWidth: number, + newHeight: number, + angle: number, + handleDirection: TransformHandleDirection, + shouldMaintainAspectRatio: boolean, + shouldResizeFromCenter: boolean, +): { x: number; y: number } => { + const anchor = getResizeAnchor( + handleDirection, + shouldMaintainAspectRatio, + shouldResizeFromCenter, + ); + + const [x, y] = prevOrigin; + + switch (anchor) { + case "top-left": + return { + x: + x + + (prevWidth - newWidth) / 2 + + ((newWidth - prevWidth) / 2) * Math.cos(angle) + + ((prevHeight - newHeight) / 2) * Math.sin(angle), + y: + y + + (prevHeight - newHeight) / 2 + + ((newWidth - prevWidth) / 2) * Math.sin(angle) + + ((newHeight - prevHeight) / 2) * Math.cos(angle), + }; + case "top-right": + return { + x: + x + + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1) + + ((prevHeight - newHeight) / 2) * Math.sin(angle), + y: + y + + (prevHeight - newHeight) / 2 + + ((prevWidth - newWidth) / 2) * Math.sin(angle) + + ((newHeight - prevHeight) / 2) * Math.cos(angle), + }; + + case "bottom-left": + return { + x: + x + + ((prevWidth - newWidth) / 2) * (1 - Math.cos(angle)) + + ((newHeight - prevHeight) / 2) * Math.sin(angle), + y: + y + + ((prevHeight - newHeight) / 2) * (Math.cos(angle) + 1) + + ((newWidth - prevWidth) / 2) * Math.sin(angle), + }; + case "bottom-right": + return { + x: + x + + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1) + + ((newHeight - prevHeight) / 2) * Math.sin(angle), + y: + y + + ((prevHeight - newHeight) / 2) * (Math.cos(angle) + 1) + + ((prevWidth - newWidth) / 2) * Math.sin(angle), + }; + case "center": + return { + x: x - (newWidth - prevWidth) / 2, + y: y - (newHeight - prevHeight) / 2, + }; + case "east-side": + return { + x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1), + y: + y + + ((prevWidth - newWidth) / 2) * Math.sin(angle) + + (prevHeight - newHeight) / 2, + }; + case "west-side": + return { + x: x + ((prevWidth - newWidth) / 2) * (1 - Math.cos(angle)), + y: + y + + ((newWidth - prevWidth) / 2) * Math.sin(angle) + + (prevHeight - newHeight) / 2, + }; + case "north-side": + return { + x: + x + + (prevWidth - newWidth) / 2 + + ((prevHeight - newHeight) / 2) * Math.sin(angle), + y: y + ((newHeight - prevHeight) / 2) * (Math.cos(angle) - 1), + }; + case "south-side": + return { + x: + x + + (prevWidth - newWidth) / 2 + + ((newHeight - prevHeight) / 2) * Math.sin(angle), + y: y + ((prevHeight - newHeight) / 2) * (Math.cos(angle) + 1), + }; + } +}; + +export const resizeSingleElement = ( + nextWidth: number, + nextHeight: number, + latestElement: ExcalidrawElement, + origElement: ExcalidrawElement, + elementsMap: ElementsMap, + originalElementsMap: ElementsMap, + handleDirection: TransformHandleDirection, + { + shouldInformMutation = true, + shouldMaintainAspectRatio = false, + shouldResizeFromCenter = false, + }: { + shouldMaintainAspectRatio?: boolean; + shouldResizeFromCenter?: boolean; + shouldInformMutation?: boolean; + } = {}, +) => { + let boundTextFont: { fontSize?: number } = {}; + const boundTextElement = getBoundTextElement(latestElement, elementsMap); + + if (boundTextElement) { + const stateOfBoundTextElementAtResize = originalElementsMap.get( + boundTextElement.id, + ) as typeof boundTextElement | undefined; + if (stateOfBoundTextElementAtResize) { + boundTextFont = { + fontSize: stateOfBoundTextElementAtResize.fontSize, + }; + } + if (shouldMaintainAspectRatio) { + const updatedElement = { + ...latestElement, + width: nextWidth, + height: nextHeight, + }; + + const nextFont = measureFontSizeFromWidth( + boundTextElement, + elementsMap, + getBoundTextMaxWidth(updatedElement, boundTextElement), + ); + if (nextFont === null) { + return; + } + boundTextFont = { + fontSize: nextFont.size, + }; + } else { + const minWidth = getApproxMinLineWidth( + getFontString(boundTextElement), + boundTextElement.lineHeight, + ); + const minHeight = getApproxMinLineHeight( + boundTextElement.fontSize, + boundTextElement.lineHeight, + ); + nextWidth = Math.max(nextWidth, minWidth); + nextHeight = Math.max(nextHeight, minHeight); + } + } + + const rescaledPoints = rescalePointsInElement( + origElement, + nextWidth, + nextHeight, + true, + ); + + let previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y); + + if (isLinearElement(origElement)) { + const [x1, y1] = getElementBounds(origElement, originalElementsMap); + previousOrigin = pointFrom<GlobalPoint>(x1, y1); + } + + const newOrigin: { + x: number; + y: number; + } = getResizedOrigin( + previousOrigin, + origElement.width, + origElement.height, + nextWidth, + nextHeight, + origElement.angle, + handleDirection, + shouldMaintainAspectRatio!!, + shouldResizeFromCenter!!, + ); + + if (isLinearElement(origElement) && rescaledPoints.points) { + const offsetX = origElement.x - previousOrigin[0]; + const offsetY = origElement.y - previousOrigin[1]; + + newOrigin.x += offsetX; + newOrigin.y += offsetY; + + const scaledX = rescaledPoints.points[0][0]; + const scaledY = rescaledPoints.points[0][1]; + + newOrigin.x += scaledX; + newOrigin.y += scaledY; + + rescaledPoints.points = rescaledPoints.points.map((p) => + pointFrom<LocalPoint>(p[0] - scaledX, p[1] - scaledY), + ); + } + + // flipping + if (nextWidth < 0) { + newOrigin.x = newOrigin.x + nextWidth; + } + if (nextHeight < 0) { + newOrigin.y = newOrigin.y + nextHeight; + } + + if ("scale" in latestElement && "scale" in origElement) { + mutateElement(latestElement, { + scale: [ + // defaulting because scaleX/Y can be 0/-0 + (Math.sign(nextWidth) || origElement.scale[0]) * origElement.scale[0], + (Math.sign(nextHeight) || origElement.scale[1]) * origElement.scale[1], + ], + }); + } + + if ( + isArrowElement(latestElement) && + boundTextElement && + shouldMaintainAspectRatio + ) { + const fontSize = + (nextWidth / latestElement.width) * boundTextElement.fontSize; + if (fontSize < MIN_FONT_SIZE) { + return; + } + boundTextFont.fontSize = fontSize; + } + + if ( + nextWidth !== 0 && + nextHeight !== 0 && + Number.isFinite(newOrigin.x) && + Number.isFinite(newOrigin.y) + ) { + const updates = { + ...newOrigin, + width: Math.abs(nextWidth), + height: Math.abs(nextHeight), + ...rescaledPoints, + }; + + mutateElement(latestElement, updates, shouldInformMutation); + + updateBoundElements(latestElement, elementsMap as SceneElementsMap, { + // TODO: confirm with MARK if this actually makes sense + newSize: { width: nextWidth, height: nextHeight }, + }); + + if (boundTextElement && boundTextFont != null) { + mutateElement(boundTextElement, { + fontSize: boundTextFont.fontSize, + }); + } + handleBindTextResize( + latestElement, + elementsMap, + handleDirection, + shouldMaintainAspectRatio, + ); + } +}; + +const getNextSingleWidthAndHeightFromPointer = ( + latestElement: ExcalidrawElement, + origElement: ExcalidrawElement, + elementsMap: ElementsMap, + originalElementsMap: ElementsMap, + handleDirection: TransformHandleDirection, + pointerX: number, + pointerY: number, + { + shouldMaintainAspectRatio = false, + shouldResizeFromCenter = false, + }: { + shouldMaintainAspectRatio?: boolean; + shouldResizeFromCenter?: boolean; + } = {}, +) => { + // Gets bounds corners + const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords( + origElement, + origElement.width, + origElement.height, + true, + ); + const startTopLeft = pointFrom(x1, y1); + const startBottomRight = pointFrom(x2, y2); + const startCenter = pointCenter(startTopLeft, startBottomRight); + + // Calculate new dimensions based on cursor position + const rotatedPointer = pointRotateRads( + pointFrom(pointerX, pointerY), + startCenter, + -origElement.angle as Radians, + ); + + // Get bounds corners rendered on screen + const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords( + latestElement, + latestElement.width, + latestElement.height, + true, + ); + + const boundsCurrentWidth = esx2 - esx1; + const boundsCurrentHeight = esy2 - esy1; + + // It's important we set the initial scale value based on the width and height at resize start, + // otherwise previous dimensions affected by modifiers will be taken into account. + const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0]; + const atStartBoundsHeight = startBottomRight[1] - startTopLeft[1]; + let scaleX = atStartBoundsWidth / boundsCurrentWidth; + let scaleY = atStartBoundsHeight / boundsCurrentHeight; + + if (handleDirection.includes("e")) { + scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth; + } + if (handleDirection.includes("s")) { + scaleY = (rotatedPointer[1] - startTopLeft[1]) / boundsCurrentHeight; + } + if (handleDirection.includes("w")) { + scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth; + } + if (handleDirection.includes("n")) { + scaleY = (startBottomRight[1] - rotatedPointer[1]) / boundsCurrentHeight; + } + + // We have to use dimensions of element on screen, otherwise the scaling of the + // dimensions won't match the cursor for linear elements. + let nextWidth = latestElement.width * scaleX; + let nextHeight = latestElement.height * scaleY; + + if (shouldResizeFromCenter) { + nextWidth = 2 * nextWidth - origElement.width; + nextHeight = 2 * nextHeight - origElement.height; + } + + // adjust dimensions to keep sides ratio + if (shouldMaintainAspectRatio) { + const widthRatio = Math.abs(nextWidth) / origElement.width; + const heightRatio = Math.abs(nextHeight) / origElement.height; + if (handleDirection.length === 1) { + nextHeight *= widthRatio; + nextWidth *= heightRatio; + } + if (handleDirection.length === 2) { + const ratio = Math.max(widthRatio, heightRatio); + nextWidth = origElement.width * ratio * Math.sign(nextWidth); + nextHeight = origElement.height * ratio * Math.sign(nextHeight); + } + } + + return { + nextWidth, + nextHeight, + }; +}; + +const getNextMultipleWidthAndHeightFromPointer = ( + selectedElements: readonly NonDeletedExcalidrawElement[], + originalElementsMap: ElementsMap, + elementsMap: ElementsMap, + handleDirection: TransformHandleDirection, + pointerX: number, + pointerY: number, + { + shouldMaintainAspectRatio = false, + shouldResizeFromCenter = false, + }: { + shouldResizeFromCenter?: boolean; + shouldMaintainAspectRatio?: boolean; + } = {}, +) => { + const originalElementsArray = selectedElements.map( + (el) => originalElementsMap.get(el.id)!, + ); + + // getCommonBoundingBox() uses getBoundTextElement() which returns null for + // original elements from pointerDownState, so we have to find and add these + // bound text elements manually. Additionally, the coordinates of bound text + // elements aren't always up to date. + const boundTextElements = originalElementsArray.reduce((acc, orig) => { + if (!isLinearElement(orig)) { + return acc; + } + const textId = getBoundTextElementId(orig); + if (!textId) { + return acc; + } + const text = originalElementsMap.get(textId) ?? null; + if (!isBoundToContainer(text)) { + return acc; + } + return [ + ...acc, + { + ...text, + ...LinearElementEditor.getBoundTextElementPosition( + orig, + text, + elementsMap, + ), + }, + ]; + }, [] as ExcalidrawTextElementWithContainer[]); + + const originalBoundingBox = getCommonBoundingBox( + originalElementsArray.map((orig) => orig).concat(boundTextElements), + ); + + const { minX, minY, maxX, maxY, midX, midY } = originalBoundingBox; + const width = maxX - minX; + const height = maxY - minY; + + const anchorsMap = { + ne: [minX, maxY], + se: [minX, minY], + sw: [maxX, minY], + nw: [maxX, maxY], + e: [minX, minY + height / 2], + w: [maxX, minY + height / 2], + n: [minX + width / 2, maxY], + s: [minX + width / 2, minY], + } as Record<TransformHandleDirection, GlobalPoint>; + + // anchor point must be on the opposite side of the dragged selection handle + // or be the center of the selection if shouldResizeFromCenter + const [anchorX, anchorY] = shouldResizeFromCenter + ? [midX, midY] + : anchorsMap[handleDirection]; + + const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1; + + const scale = + Math.max( + Math.abs(pointerX - anchorX) / width || 0, + Math.abs(pointerY - anchorY) / height || 0, + ) * resizeFromCenterScale; + + let nextWidth = + handleDirection.includes("e") || handleDirection.includes("w") + ? Math.abs(pointerX - anchorX) * resizeFromCenterScale + : width; + let nextHeight = + handleDirection.includes("n") || handleDirection.includes("s") + ? Math.abs(pointerY - anchorY) * resizeFromCenterScale + : height; + + if (shouldMaintainAspectRatio) { + nextWidth = width * scale * Math.sign(pointerX - anchorX); + nextHeight = height * scale * Math.sign(pointerY - anchorY); + } + + const flipConditionsMap: Record< + TransformHandleDirection, + // Condition for which we should flip or not flip the selected elements + // - when evaluated to `true`, we flip + // - therefore, setting it to always `false` means we do not flip (in that direction) at all + [x: boolean, y: boolean] + > = { + ne: [pointerX < anchorX, pointerY > anchorY], + se: [pointerX < anchorX, pointerY < anchorY], + sw: [pointerX > anchorX, pointerY < anchorY], + nw: [pointerX > anchorX, pointerY > anchorY], + // e.g. when resizing from the "e" side, we do not need to consider changes in the `y` direction + // and therefore, we do not need to flip in the `y` direction at all + e: [pointerX < anchorX, false], + w: [pointerX > anchorX, false], + n: [false, pointerY > anchorY], + s: [false, pointerY < anchorY], + }; + + const [flipByX, flipByY] = flipConditionsMap[handleDirection].map( + (condition) => condition, + ); + + return { + originalBoundingBox, + nextWidth, + nextHeight, + flipByX, + flipByY, + }; +}; + +export const resizeMultipleElements = ( + selectedElements: readonly NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, + handleDirection: TransformHandleDirection, + scene: Scene, + originalElementsMap: ElementsMap, + { + shouldMaintainAspectRatio = false, + shouldResizeFromCenter = false, + flipByX = false, + flipByY = false, + nextHeight, + nextWidth, + originalBoundingBox, + }: { + nextWidth?: number; + nextHeight?: number; + shouldMaintainAspectRatio?: boolean; + shouldResizeFromCenter?: boolean; + flipByX?: boolean; + flipByY?: boolean; + // added to improve performance + originalBoundingBox?: BoundingBox; + } = {}, +) => { + // in the case of just flipping, there is no need to specify the next width and height + if ( + nextWidth === undefined && + nextHeight === undefined && + flipByX === undefined && + flipByY === undefined + ) { + return; + } + + // do not allow next width or height to be 0 + if (nextHeight === 0 || nextWidth === 0) { + return; + } + + if (!originalElementsMap) { + originalElementsMap = elementsMap; + } + + const targetElements = selectedElements.reduce( + ( + acc: { + /** element at resize start */ + orig: NonDeletedExcalidrawElement; + /** latest element */ + latest: NonDeletedExcalidrawElement; + }[], + element, + ) => { + const origElement = originalElementsMap!.get(element.id); + if (origElement) { + acc.push({ orig: origElement, latest: element }); + } + return acc; + }, + [], + ); + + let boundingBox: BoundingBox; + + if (originalBoundingBox) { + boundingBox = originalBoundingBox; + } else { + const boundTextElements = targetElements.reduce((acc, { orig }) => { + if (!isLinearElement(orig)) { + return acc; + } + const textId = getBoundTextElementId(orig); + if (!textId) { + return acc; + } + const text = originalElementsMap!.get(textId) ?? null; + if (!isBoundToContainer(text)) { + return acc; + } + return [ + ...acc, + { + ...text, + ...LinearElementEditor.getBoundTextElementPosition( + orig, + text, + elementsMap, + ), + }, + ]; + }, [] as ExcalidrawTextElementWithContainer[]); + + boundingBox = getCommonBoundingBox( + targetElements.map(({ orig }) => orig).concat(boundTextElements), + ); + } + const { minX, minY, maxX, maxY, midX, midY } = boundingBox; + const width = maxX - minX; + const height = maxY - minY; + + if (nextWidth === undefined && nextHeight === undefined) { + nextWidth = width; + nextHeight = height; + } + + if (shouldMaintainAspectRatio) { + if (nextWidth === undefined) { + nextWidth = nextHeight! * (width / height); + } else if (nextHeight === undefined) { + nextHeight = nextWidth! * (height / width); + } else if (Math.abs(nextWidth / nextHeight - width / height) > 0.001) { + nextWidth = nextHeight * (width / height); + } + } + + if (nextWidth && nextHeight) { + let scaleX = + handleDirection.includes("e") || handleDirection.includes("w") + ? Math.abs(nextWidth) / width + : 1; + let scaleY = + handleDirection.includes("n") || handleDirection.includes("s") + ? Math.abs(nextHeight) / height + : 1; + + let scale: number; + + if (handleDirection.length === 1) { + scale = + handleDirection.includes("e") || handleDirection.includes("w") + ? scaleX + : scaleY; + } else { + scale = Math.max( + Math.abs(nextWidth) / width || 0, + Math.abs(nextHeight) / height || 0, + ); + } + + const anchorsMap = { + ne: [minX, maxY], + se: [minX, minY], + sw: [maxX, minY], + nw: [maxX, maxY], + e: [minX, minY + height / 2], + w: [maxX, minY + height / 2], + n: [minX + width / 2, maxY], + s: [minX + width / 2, minY], + } as Record<TransformHandleDirection, GlobalPoint>; + + // anchor point must be on the opposite side of the dragged selection handle + // or be the center of the selection if shouldResizeFromCenter + const [anchorX, anchorY] = shouldResizeFromCenter + ? [midX, midY] + : anchorsMap[handleDirection]; + + const keepAspectRatio = + shouldMaintainAspectRatio || + targetElements.some( + (item) => + item.latest.angle !== 0 || + isTextElement(item.latest) || + isInGroup(item.latest), + ); + + if (keepAspectRatio) { + scaleX = scale; + scaleY = scale; + } + + /** + * to flip an element: + * 1. determine over which axis is the element being flipped + * (could be x, y, or both) indicated by `flipFactorX` & `flipFactorY` + * 2. shift element's position by the amount of width or height (or both) or + * mirror points in the case of linear & freedraw elemenets + * 3. adjust element angle + */ + const [flipFactorX, flipFactorY] = [flipByX ? -1 : 1, flipByY ? -1 : 1]; + + const elementsAndUpdates: { + element: NonDeletedExcalidrawElement; + update: Mutable< + Pick<ExcalidrawElement, "x" | "y" | "width" | "height" | "angle"> + > & { + points?: ExcalidrawLinearElement["points"]; + fontSize?: ExcalidrawTextElement["fontSize"]; + scale?: ExcalidrawImageElement["scale"]; + boundTextFontSize?: ExcalidrawTextElement["fontSize"]; + startBinding?: ExcalidrawElbowArrowElement["startBinding"]; + endBinding?: ExcalidrawElbowArrowElement["endBinding"]; + fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"]; + }; + }[] = []; + + for (const { orig, latest } of targetElements) { + // bounded text elements are updated along with their container elements + if (isTextElement(orig) && isBoundToContainer(orig)) { + continue; + } + + const width = orig.width * scaleX; + const height = orig.height * scaleY; + const angle = normalizeRadians( + (orig.angle * flipFactorX * flipFactorY) as Radians, + ); + + const isLinearOrFreeDraw = + isLinearElement(orig) || isFreeDrawElement(orig); + const offsetX = orig.x - anchorX; + const offsetY = orig.y - anchorY; + const shiftX = flipByX && !isLinearOrFreeDraw ? width : 0; + const shiftY = flipByY && !isLinearOrFreeDraw ? height : 0; + const x = anchorX + flipFactorX * (offsetX * scaleX + shiftX); + const y = anchorY + flipFactorY * (offsetY * scaleY + shiftY); + + const rescaledPoints = rescalePointsInElement( + orig, + width * flipFactorX, + height * flipFactorY, + false, + ); + + const update: typeof elementsAndUpdates[0]["update"] = { + x, + y, + width, + height, + angle, + ...rescaledPoints, + }; + + if (isElbowArrow(orig)) { + // Mirror fixed point binding for elbow arrows + // when resize goes into the negative direction + if (orig.startBinding) { + update.startBinding = { + ...orig.startBinding, + fixedPoint: [ + flipByX + ? -orig.startBinding.fixedPoint[0] + 1 + : orig.startBinding.fixedPoint[0], + flipByY + ? -orig.startBinding.fixedPoint[1] + 1 + : orig.startBinding.fixedPoint[1], + ], + }; + } + if (orig.endBinding) { + update.endBinding = { + ...orig.endBinding, + fixedPoint: [ + flipByX + ? -orig.endBinding.fixedPoint[0] + 1 + : orig.endBinding.fixedPoint[0], + flipByY + ? -orig.endBinding.fixedPoint[1] + 1 + : orig.endBinding.fixedPoint[1], + ], + }; + } + if (orig.fixedSegments && rescaledPoints.points) { + update.fixedSegments = orig.fixedSegments.map((segment) => ({ + ...segment, + start: rescaledPoints.points[segment.index - 1], + end: rescaledPoints.points[segment.index], + })); + } + } + + if (isImageElement(orig)) { + update.scale = [ + orig.scale[0] * flipFactorX, + orig.scale[1] * flipFactorY, + ]; + } + + if (isTextElement(orig)) { + const metrics = measureFontSizeFromWidth(orig, elementsMap, width); + if (!metrics) { + return; + } + update.fontSize = metrics.size; + } + + const boundTextElement = originalElementsMap.get( + getBoundTextElementId(orig) ?? "", + ) as ExcalidrawTextElementWithContainer | undefined; + + if (boundTextElement) { + if (keepAspectRatio) { + const newFontSize = boundTextElement.fontSize * scale; + if (newFontSize < MIN_FONT_SIZE) { + return; + } + update.boundTextFontSize = newFontSize; + } else { + update.boundTextFontSize = boundTextElement.fontSize; + } + } + + elementsAndUpdates.push({ + element: latest, + update, + }); + } + + const elementsToUpdate = elementsAndUpdates.map(({ element }) => element); + + for (const { + element, + update: { boundTextFontSize, ...update }, + } of elementsAndUpdates) { + const { width, height, angle } = update; + + mutateElement(element, update, false, { + // needed for the fixed binding point udpate to take effect + isDragging: true, + }); + + updateBoundElements(element, elementsMap as SceneElementsMap, { + simultaneouslyUpdated: elementsToUpdate, + newSize: { width, height }, + }); + + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement && boundTextFontSize) { + mutateElement( + boundTextElement, + { + fontSize: boundTextFontSize, + angle: isLinearElement(element) ? undefined : angle, + }, + false, + ); + handleBindTextResize(element, elementsMap, handleDirection, true); + } + } + + scene.triggerUpdate(); + } +}; diff --git a/packages/excalidraw/element/resizeTest.ts b/packages/excalidraw/element/resizeTest.ts new file mode 100644 index 0000000..375ff98 --- /dev/null +++ b/packages/excalidraw/element/resizeTest.ts @@ -0,0 +1,287 @@ +import type { + ExcalidrawElement, + PointerType, + NonDeletedExcalidrawElement, + ElementsMap, +} from "./types"; + +import type { + TransformHandleType, + TransformHandle, + MaybeTransformHandleType, +} from "./transformHandles"; +import { + getTransformHandlesFromCoords, + getTransformHandles, + getOmitSidesForDevice, + canResizeFromSides, +} from "./transformHandles"; +import type { AppState, Device, Zoom } from "../types"; +import type { Bounds } from "./bounds"; +import { getElementAbsoluteCoords } from "./bounds"; +import { SIDE_RESIZING_THRESHOLD } from "../constants"; +import { isImageElement, isLinearElement } from "./typeChecks"; +import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math"; +import { + pointFrom, + pointOnLineSegment, + pointRotateRads, + type Radians, +} from "@excalidraw/math"; + +const isInsideTransformHandle = ( + transformHandle: TransformHandle, + x: number, + y: number, +) => + x >= transformHandle[0] && + x <= transformHandle[0] + transformHandle[2] && + y >= transformHandle[1] && + y <= transformHandle[1] + transformHandle[3]; + +export const resizeTest = <Point extends GlobalPoint | LocalPoint>( + element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, + appState: AppState, + x: number, + y: number, + zoom: Zoom, + pointerType: PointerType, + device: Device, +): MaybeTransformHandleType => { + if (!appState.selectedElementIds[element.id]) { + return false; + } + + const { rotation: rotationTransformHandle, ...transformHandles } = + getTransformHandles( + element, + zoom, + elementsMap, + pointerType, + getOmitSidesForDevice(device), + ); + + if ( + rotationTransformHandle && + isInsideTransformHandle(rotationTransformHandle, x, y) + ) { + return "rotation" as TransformHandleType; + } + + const filter = Object.keys(transformHandles).filter((key) => { + const transformHandle = + transformHandles[key as Exclude<TransformHandleType, "rotation">]!; + if (!transformHandle) { + return false; + } + return isInsideTransformHandle(transformHandle, x, y); + }); + + if (filter.length > 0) { + return filter[0] as TransformHandleType; + } + + if (canResizeFromSides(device)) { + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); + + // do not resize from the sides for linear elements with only two points + if (!(isLinearElement(element) && element.points.length <= 2)) { + const SPACING = isImageElement(element) + ? 0 + : SIDE_RESIZING_THRESHOLD / zoom.value; + const ZOOMED_SIDE_RESIZING_THRESHOLD = + SIDE_RESIZING_THRESHOLD / zoom.value; + const sides = getSelectionBorders( + pointFrom(x1 - SPACING, y1 - SPACING), + pointFrom(x2 + SPACING, y2 + SPACING), + pointFrom(cx, cy), + element.angle, + ); + + for (const [dir, side] of Object.entries(sides)) { + // test to see if x, y are on the line segment + if ( + pointOnLineSegment( + pointFrom(x, y), + side as LineSegment<Point>, + ZOOMED_SIDE_RESIZING_THRESHOLD, + ) + ) { + return dir as TransformHandleType; + } + } + } + } + + return false; +}; + +export const getElementWithTransformHandleType = ( + elements: readonly NonDeletedExcalidrawElement[], + appState: AppState, + scenePointerX: number, + scenePointerY: number, + zoom: Zoom, + pointerType: PointerType, + elementsMap: ElementsMap, + device: Device, +) => { + return elements.reduce((result, element) => { + if (result) { + return result; + } + const transformHandleType = resizeTest( + element, + elementsMap, + appState, + scenePointerX, + scenePointerY, + zoom, + pointerType, + device, + ); + return transformHandleType ? { element, transformHandleType } : null; + }, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null); +}; + +export const getTransformHandleTypeFromCoords = < + Point extends GlobalPoint | LocalPoint, +>( + [x1, y1, x2, y2]: Bounds, + scenePointerX: number, + scenePointerY: number, + zoom: Zoom, + pointerType: PointerType, + device: Device, +): MaybeTransformHandleType => { + const transformHandles = getTransformHandlesFromCoords( + [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], + 0 as Radians, + zoom, + pointerType, + getOmitSidesForDevice(device), + ); + + const found = Object.keys(transformHandles).find((key) => { + const transformHandle = + transformHandles[key as Exclude<TransformHandleType, "rotation">]!; + return ( + transformHandle && + isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY) + ); + }); + + if (found) { + return found as MaybeTransformHandleType; + } + + if (canResizeFromSides(device)) { + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + + const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; + + const sides = getSelectionBorders( + pointFrom(x1 - SPACING, y1 - SPACING), + pointFrom(x2 + SPACING, y2 + SPACING), + pointFrom(cx, cy), + 0 as Radians, + ); + + for (const [dir, side] of Object.entries(sides)) { + // test to see if x, y are on the line segment + if ( + pointOnLineSegment( + pointFrom(scenePointerX, scenePointerY), + side as LineSegment<Point>, + SPACING, + ) + ) { + return dir as TransformHandleType; + } + } + } + + return false; +}; + +const RESIZE_CURSORS = ["ns", "nesw", "ew", "nwse"]; +const rotateResizeCursor = (cursor: string, angle: number) => { + const index = RESIZE_CURSORS.indexOf(cursor); + if (index >= 0) { + const a = Math.round(angle / (Math.PI / 4)); + cursor = RESIZE_CURSORS[(index + a) % RESIZE_CURSORS.length]; + } + return cursor; +}; + +/* + * Returns bi-directional cursor for the element being resized + */ +export const getCursorForResizingElement = (resizingElement: { + element?: ExcalidrawElement; + transformHandleType: MaybeTransformHandleType; +}): string => { + const { element, transformHandleType } = resizingElement; + const shouldSwapCursors = + element && Math.sign(element.height) * Math.sign(element.width) === -1; + let cursor = null; + + switch (transformHandleType) { + case "n": + case "s": + cursor = "ns"; + break; + case "w": + case "e": + cursor = "ew"; + break; + case "nw": + case "se": + if (shouldSwapCursors) { + cursor = "nesw"; + } else { + cursor = "nwse"; + } + break; + case "ne": + case "sw": + if (shouldSwapCursors) { + cursor = "nwse"; + } else { + cursor = "nesw"; + } + break; + case "rotation": + return "grab"; + } + + if (cursor && element) { + cursor = rotateResizeCursor(cursor, element.angle); + } + + return cursor ? `${cursor}-resize` : ""; +}; + +const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>( + [x1, y1]: Point, + [x2, y2]: Point, + center: Point, + angle: Radians, +) => { + const topLeft = pointRotateRads(pointFrom(x1, y1), center, angle); + const topRight = pointRotateRads(pointFrom(x2, y1), center, angle); + const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, angle); + const bottomRight = pointRotateRads(pointFrom(x2, y2), center, angle); + + return { + n: [topLeft, topRight], + e: [topRight, bottomRight], + s: [bottomRight, bottomLeft], + w: [bottomLeft, topLeft], + }; +}; diff --git a/packages/excalidraw/element/showSelectedShapeActions.ts b/packages/excalidraw/element/showSelectedShapeActions.ts new file mode 100644 index 0000000..bbf313d --- /dev/null +++ b/packages/excalidraw/element/showSelectedShapeActions.ts @@ -0,0 +1,19 @@ +import type { NonDeletedExcalidrawElement } from "./types"; +import { getSelectedElements } from "../scene"; +import type { UIAppState } from "../types"; + +export const showSelectedShapeActions = ( + appState: UIAppState, + elements: readonly NonDeletedExcalidrawElement[], +) => + Boolean( + !appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" && + ((appState.activeTool.type !== "custom" && + (appState.editingTextElement || + (appState.activeTool.type !== "selection" && + appState.activeTool.type !== "eraser" && + appState.activeTool.type !== "hand" && + appState.activeTool.type !== "laser"))) || + getSelectedElements(elements, appState).length), + ); diff --git a/packages/excalidraw/element/sizeHelpers.test.ts b/packages/excalidraw/element/sizeHelpers.test.ts new file mode 100644 index 0000000..8e63dd9 --- /dev/null +++ b/packages/excalidraw/element/sizeHelpers.test.ts @@ -0,0 +1,67 @@ +import { vi } from "vitest"; +import { getPerfectElementSize } from "./sizeHelpers"; +import * as constants from "../constants"; + +const EPSILON_DIGITS = 3; +// Needed so that we can mock the value of constants which is done in +// below tests. In Jest this wasn't needed as global override was possible +// but vite doesn't allow that hence we need to mock +vi.mock( + "../constants.ts", + //@ts-ignore + async (importOriginal) => { + const module: any = await importOriginal(); + return { ...module }; + }, +); +describe("getPerfectElementSize", () => { + it("should return height:0 if `elementType` is line and locked angle is 0", () => { + const { height, width } = getPerfectElementSize("line", 149, 10); + expect(width).toBeCloseTo(149, EPSILON_DIGITS); + expect(height).toBeCloseTo(0, EPSILON_DIGITS); + }); + + it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => { + const { height, width } = getPerfectElementSize("line", 10, 140); + expect(width).toBeCloseTo(0, EPSILON_DIGITS); + expect(height).toBeCloseTo(140, EPSILON_DIGITS); + }); + + it("should return height:0 if `elementType` is arrow and locked angle is 0", () => { + const { height, width } = getPerfectElementSize("arrow", 200, 20); + expect(width).toBeCloseTo(200, EPSILON_DIGITS); + expect(height).toBeCloseTo(0, EPSILON_DIGITS); + }); + it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => { + const { height, width } = getPerfectElementSize("arrow", 10, 100); + expect(width).toBeCloseTo(0, EPSILON_DIGITS); + expect(height).toBeCloseTo(100, EPSILON_DIGITS); + }); + + it("should return adjust height to be width * tan(locked angle)", () => { + const { height, width } = getPerfectElementSize("arrow", 120, 185); + expect(width).toBeCloseTo(120, EPSILON_DIGITS); + expect(height).toBeCloseTo(207.846, EPSILON_DIGITS); + }); + + it("should return height equals to width if locked angle is 45 deg", () => { + const { height, width } = getPerfectElementSize("arrow", 135, 145); + expect(width).toBeCloseTo(135, EPSILON_DIGITS); + expect(height).toBeCloseTo(135, EPSILON_DIGITS); + }); + + it("should return height:0 and width:0 when width and height are 0", () => { + const { height, width } = getPerfectElementSize("arrow", 0, 0); + expect(width).toBeCloseTo(0, EPSILON_DIGITS); + expect(height).toBeCloseTo(0, EPSILON_DIGITS); + }); + + describe("should respond to SHIFT_LOCKING_ANGLE constant", () => { + it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => { + (constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4; + const { height, width } = getPerfectElementSize("arrow", 120, 185); + expect(width).toBeCloseTo(120, EPSILON_DIGITS); + expect(height).toBeCloseTo(120, EPSILON_DIGITS); + }); + }); +}); diff --git a/packages/excalidraw/element/sizeHelpers.ts b/packages/excalidraw/element/sizeHelpers.ts new file mode 100644 index 0000000..f633789 --- /dev/null +++ b/packages/excalidraw/element/sizeHelpers.ts @@ -0,0 +1,231 @@ +import type { ElementsMap, ExcalidrawElement } from "./types"; +import { mutateElement } from "./mutateElement"; +import { isFreeDrawElement, isLinearElement } from "./typeChecks"; +import { SHIFT_LOCKING_ANGLE } from "../constants"; +import type { AppState, Offsets, Zoom } from "../types"; +import { getCommonBounds, getElementBounds } from "./bounds"; +import { viewportCoordsToSceneCoords } from "../utils"; + +// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted +// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize' +// - could also be part of `_clearElements` +export const isInvisiblySmallElement = ( + element: ExcalidrawElement, +): boolean => { + if (isLinearElement(element) || isFreeDrawElement(element)) { + return element.points.length < 2; + } + return element.width === 0 && element.height === 0; +}; + +export const isElementInViewport = ( + element: ExcalidrawElement, + width: number, + height: number, + viewTransformations: { + zoom: Zoom; + offsetLeft: number; + offsetTop: number; + scrollX: number; + scrollY: number; + }, + elementsMap: ElementsMap, +) => { + const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); // scene coordinates + const topLeftSceneCoords = viewportCoordsToSceneCoords( + { + clientX: viewTransformations.offsetLeft, + clientY: viewTransformations.offsetTop, + }, + viewTransformations, + ); + const bottomRightSceneCoords = viewportCoordsToSceneCoords( + { + clientX: viewTransformations.offsetLeft + width, + clientY: viewTransformations.offsetTop + height, + }, + viewTransformations, + ); + + return ( + topLeftSceneCoords.x <= x2 && + topLeftSceneCoords.y <= y2 && + bottomRightSceneCoords.x >= x1 && + bottomRightSceneCoords.y >= y1 + ); +}; + +export const isElementCompletelyInViewport = ( + elements: ExcalidrawElement[], + width: number, + height: number, + viewTransformations: { + zoom: Zoom; + offsetLeft: number; + offsetTop: number; + scrollX: number; + scrollY: number; + }, + elementsMap: ElementsMap, + padding?: Offsets, +) => { + const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates + const topLeftSceneCoords = viewportCoordsToSceneCoords( + { + clientX: viewTransformations.offsetLeft + (padding?.left || 0), + clientY: viewTransformations.offsetTop + (padding?.top || 0), + }, + viewTransformations, + ); + const bottomRightSceneCoords = viewportCoordsToSceneCoords( + { + clientX: viewTransformations.offsetLeft + width - (padding?.right || 0), + clientY: viewTransformations.offsetTop + height - (padding?.bottom || 0), + }, + viewTransformations, + ); + + return ( + x1 >= topLeftSceneCoords.x && + y1 >= topLeftSceneCoords.y && + x2 <= bottomRightSceneCoords.x && + y2 <= bottomRightSceneCoords.y + ); +}; + +/** + * Makes a perfect shape or diagonal/horizontal/vertical line + */ +export const getPerfectElementSize = ( + elementType: AppState["activeTool"]["type"], + width: number, + height: number, +): { width: number; height: number } => { + const absWidth = Math.abs(width); + const absHeight = Math.abs(height); + + if ( + elementType === "line" || + elementType === "arrow" || + elementType === "freedraw" + ) { + const lockedAngle = + Math.round(Math.atan(absHeight / absWidth) / SHIFT_LOCKING_ANGLE) * + SHIFT_LOCKING_ANGLE; + if (lockedAngle === 0) { + height = 0; + } else if (lockedAngle === Math.PI / 2) { + width = 0; + } else { + height = absWidth * Math.tan(lockedAngle) * Math.sign(height) || height; + } + } else if (elementType !== "selection") { + height = absWidth * Math.sign(height); + } + return { width, height }; +}; + +export const getLockedLinearCursorAlignSize = ( + originX: number, + originY: number, + x: number, + y: number, +) => { + let width = x - originX; + let height = y - originY; + + const lockedAngle = + Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) * + SHIFT_LOCKING_ANGLE; + + if (lockedAngle === 0) { + height = 0; + } else if (lockedAngle === Math.PI / 2) { + width = 0; + } else { + // locked angle line, y = mx + b => mx - y + b = 0 + const a1 = Math.tan(lockedAngle); + const b1 = -1; + const c1 = originY - a1 * originX; + + // line through cursor, perpendicular to locked angle line + const a2 = -1 / a1; + const b2 = -1; + const c2 = y - a2 * x; + + // intersection of the two lines above + const intersectX = (b1 * c2 - b2 * c1) / (a1 * b2 - a2 * b1); + const intersectY = (c1 * a2 - c2 * a1) / (a1 * b2 - a2 * b1); + + // delta + width = intersectX - originX; + height = intersectY - originY; + } + + return { width, height }; +}; + +export const resizePerfectLineForNWHandler = ( + element: ExcalidrawElement, + x: number, + y: number, +) => { + const anchorX = element.x + element.width; + const anchorY = element.y + element.height; + const distanceToAnchorX = x - anchorX; + const distanceToAnchorY = y - anchorY; + if (Math.abs(distanceToAnchorX) < Math.abs(distanceToAnchorY) / 2) { + mutateElement(element, { + x: anchorX, + width: 0, + y, + height: -distanceToAnchorY, + }); + } else if (Math.abs(distanceToAnchorY) < Math.abs(element.width) / 2) { + mutateElement(element, { + y: anchorY, + height: 0, + }); + } else { + const nextHeight = + Math.sign(distanceToAnchorY) * + Math.sign(distanceToAnchorX) * + element.width; + mutateElement(element, { + x, + y: anchorY - nextHeight, + width: -distanceToAnchorX, + height: nextHeight, + }); + } +}; + +export const getNormalizedDimensions = ( + element: Pick<ExcalidrawElement, "width" | "height" | "x" | "y">, +): { + width: ExcalidrawElement["width"]; + height: ExcalidrawElement["height"]; + x: ExcalidrawElement["x"]; + y: ExcalidrawElement["y"]; +} => { + const ret = { + width: element.width, + height: element.height, + x: element.x, + y: element.y, + }; + + if (element.width < 0) { + const nextWidth = Math.abs(element.width); + ret.width = nextWidth; + ret.x = element.x - nextWidth; + } + + if (element.height < 0) { + const nextHeight = Math.abs(element.height); + ret.height = nextHeight; + ret.y = element.y - nextHeight; + } + + return ret; +}; diff --git a/packages/excalidraw/element/sortElements.test.ts b/packages/excalidraw/element/sortElements.test.ts new file mode 100644 index 0000000..a7b78e8 --- /dev/null +++ b/packages/excalidraw/element/sortElements.test.ts @@ -0,0 +1,402 @@ +import { API } from "../tests/helpers/api"; +import { mutateElement } from "./mutateElement"; +import { normalizeElementOrder } from "./sortElements"; +import type { ExcalidrawElement } from "./types"; + +const assertOrder = ( + elements: readonly ExcalidrawElement[], + expectedOrder: string[], +) => { + const actualOrder = elements.map((element) => element.id); + expect(actualOrder).toEqual(expectedOrder); +}; + +describe("normalizeElementsOrder", () => { + it("sort bound-text elements", () => { + const container = API.createElement({ + id: "container", + type: "rectangle", + }); + const boundText = API.createElement({ + id: "boundText", + type: "text", + containerId: container.id, + }); + const otherElement = API.createElement({ + id: "otherElement", + type: "rectangle", + boundElements: [], + }); + const otherElement2 = API.createElement({ + id: "otherElement2", + type: "rectangle", + boundElements: [], + }); + + mutateElement(container, { + boundElements: [{ type: "text", id: boundText.id }], + }); + + assertOrder(normalizeElementOrder([container, boundText]), [ + "container", + "boundText", + ]); + assertOrder(normalizeElementOrder([boundText, container]), [ + "container", + "boundText", + ]); + assertOrder( + normalizeElementOrder([ + boundText, + container, + otherElement, + otherElement2, + ]), + ["container", "boundText", "otherElement", "otherElement2"], + ); + assertOrder(normalizeElementOrder([container, otherElement, boundText]), [ + "container", + "boundText", + "otherElement", + ]); + assertOrder( + normalizeElementOrder([ + container, + otherElement, + otherElement2, + boundText, + ]), + ["container", "boundText", "otherElement", "otherElement2"], + ); + + assertOrder( + normalizeElementOrder([ + boundText, + otherElement, + container, + otherElement2, + ]), + ["otherElement", "container", "boundText", "otherElement2"], + ); + + // noop + assertOrder( + normalizeElementOrder([ + otherElement, + container, + boundText, + otherElement2, + ]), + ["otherElement", "container", "boundText", "otherElement2"], + ); + + // text has existing containerId, but container doesn't list is + // as a boundElement + assertOrder( + normalizeElementOrder([ + API.createElement({ + id: "boundText", + type: "text", + containerId: "container", + }), + API.createElement({ + id: "container", + type: "rectangle", + }), + ]), + ["boundText", "container"], + ); + assertOrder( + normalizeElementOrder([ + API.createElement({ + id: "boundText", + type: "text", + containerId: "container", + }), + ]), + ["boundText"], + ); + assertOrder( + normalizeElementOrder([ + API.createElement({ + id: "container", + type: "rectangle", + boundElements: [], + }), + ]), + ["container"], + ); + assertOrder( + normalizeElementOrder([ + API.createElement({ + id: "container", + type: "rectangle", + boundElements: [{ id: "x", type: "text" }], + }), + ]), + ["container"], + ); + assertOrder( + normalizeElementOrder([ + API.createElement({ + id: "arrow", + type: "arrow", + }), + API.createElement({ + id: "container", + type: "rectangle", + boundElements: [{ id: "arrow", type: "arrow" }], + }), + ]), + ["arrow", "container"], + ); + }); + + it("normalize group order", () => { + assertOrder( + normalizeElementOrder([ + API.createElement({ + id: "A_rect1", + type: "rectangle", + groupIds: ["A"], + }), + API.createElement({ + id: "rect2", + type: "rectangle", + }), + API.createElement({ + id: "rect3", + type: "rectangle", + }), + API.createElement({ + id: "A_rect4", + type: "rectangle", + groupIds: ["A"], + }), + API.createElement({ + id: "A_rect5", + type: "rectangle", + groupIds: ["A"], + }), + API.createElement({ + id: "rect6", + type: "rectangle", + }), + API.createElement({ + id: "A_rect7", + type: "rectangle", + groupIds: ["A"], + }), + ]), + ["A_rect1", "A_rect4", "A_rect5", "A_rect7", "rect2", "rect3", "rect6"], + ); + assertOrder( + normalizeElementOrder([ + API.createElement({ + id: "A_rect1", + type: "rectangle", + groupIds: ["A"], + }), + API.createElement({ + id: "rect2", + type: "rectangle", + }), + API.createElement({ + id: "B_rect3", + type: "rectangle", + groupIds: ["B"], + }), + API.createElement({ + id: "A_rect4", + type: "rectangle", + groupIds: ["A"], + }), + API.createElement({ + id: "B_rect5", + type: "rectangle", + groupIds: ["B"], + }), + API.createElement({ + id: "rect6", + type: "rectangle", + }), + API.createElement({ + id: "A_rect7", + type: "rectangle", + groupIds: ["A"], + }), + ]), + ["A_rect1", "A_rect4", "A_rect7", "rect2", "B_rect3", "B_rect5", "rect6"], + ); + // nested groups + assertOrder( + normalizeElementOrder([ + API.createElement({ + id: "A_rect1", + type: "rectangle", + groupIds: ["A"], + }), + API.createElement({ + id: "BA_rect2", + type: "rectangle", + groupIds: ["B", "A"], + }), + ]), + ["A_rect1", "BA_rect2"], + ); + assertOrder( + normalizeElementOrder([ + API.createElement({ + id: "BA_rect1", + type: "rectangle", + groupIds: ["B", "A"], + }), + API.createElement({ + id: "A_rect2", + type: "rectangle", + groupIds: ["A"], + }), + ]), + ["BA_rect1", "A_rect2"], + ); + assertOrder( + normalizeElementOrder([ + API.createElement({ + id: "BA_rect1", + type: "rectangle", + groupIds: ["B", "A"], + }), + API.createElement({ + id: "A_rect2", + type: "rectangle", + groupIds: ["A"], + }), + API.createElement({ + id: "CBA_rect3", + type: "rectangle", + groupIds: ["C", "B", "A"], + }), + API.createElement({ + id: "rect4", + type: "rectangle", + }), + API.createElement({ + id: "A_rect5", + type: "rectangle", + groupIds: ["A"], + }), + API.createElement({ + id: "BA_rect5", + type: "rectangle", + groupIds: ["B", "A"], + }), + API.createElement({ + id: "BA_rect6", + type: "rectangle", + groupIds: ["B", "A"], + }), + API.createElement({ + id: "CBA_rect7", + type: "rectangle", + groupIds: ["C", "B", "A"], + }), + API.createElement({ + id: "X_rect8", + type: "rectangle", + groupIds: ["X"], + }), + API.createElement({ + id: "rect9", + type: "rectangle", + }), + API.createElement({ + id: "YX_rect10", + type: "rectangle", + groupIds: ["Y", "X"], + }), + API.createElement({ + id: "X_rect11", + type: "rectangle", + groupIds: ["X"], + }), + ]), + [ + "BA_rect1", + "BA_rect5", + "BA_rect6", + "A_rect2", + "A_rect5", + "CBA_rect3", + "CBA_rect7", + "rect4", + "X_rect8", + "X_rect11", + "YX_rect10", + "rect9", + ], + ); + }); + + // TODO + it.skip("normalize boundElements array", () => { + const container = API.createElement({ + id: "container", + type: "rectangle", + boundElements: [], + }); + const boundText = API.createElement({ + id: "boundText", + type: "text", + containerId: container.id, + }); + + mutateElement(container, { + boundElements: [ + { type: "text", id: boundText.id }, + { type: "text", id: "xxx" }, + ], + }); + + expect(normalizeElementOrder([container, boundText])).toEqual([ + expect.objectContaining({ + id: container.id, + }), + expect.objectContaining({ id: boundText.id }), + ]); + }); + + // should take around <100ms for 10K iterations (@dwelle's PC 22-05-25) + it.skip("normalizeElementsOrder() perf", () => { + const makeElements = (iterations: number) => { + const elements: ExcalidrawElement[] = []; + while (iterations--) { + const container = API.createElement({ + type: "rectangle", + boundElements: [], + groupIds: ["B", "A"], + }); + const boundText = API.createElement({ + type: "text", + containerId: container.id, + groupIds: ["A"], + }); + const otherElement = API.createElement({ + type: "rectangle", + boundElements: [], + groupIds: ["C", "A"], + }); + mutateElement(container, { + boundElements: [{ type: "text", id: boundText.id }], + }); + + elements.push(boundText, otherElement, container); + } + return elements; + }; + + const elements = makeElements(10000); + const t0 = Date.now(); + normalizeElementOrder(elements); + console.info(`${Date.now() - t0}ms`); + }); +}); diff --git a/packages/excalidraw/element/sortElements.ts b/packages/excalidraw/element/sortElements.ts new file mode 100644 index 0000000..3078a68 --- /dev/null +++ b/packages/excalidraw/element/sortElements.ts @@ -0,0 +1,120 @@ +import { arrayToMapWithIndex } from "../utils"; +import type { ExcalidrawElement } from "./types"; + +const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => { + const origElements: ExcalidrawElement[] = elements.slice(); + const sortedElements = new Set<ExcalidrawElement>(); + + const orderInnerGroups = ( + elements: readonly ExcalidrawElement[], + ): ExcalidrawElement[] => { + const firstGroupSig = elements[0]?.groupIds?.join(""); + const aGroup: ExcalidrawElement[] = [elements[0]]; + const bGroup: ExcalidrawElement[] = []; + for (const element of elements.slice(1)) { + if (element.groupIds?.join("") === firstGroupSig) { + aGroup.push(element); + } else { + bGroup.push(element); + } + } + return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup; + }; + + const groupHandledElements = new Map<string, true>(); + + origElements.forEach((element, idx) => { + if (groupHandledElements.has(element.id)) { + return; + } + if (element.groupIds?.length) { + const topGroup = element.groupIds[element.groupIds.length - 1]; + const groupElements = origElements.slice(idx).filter((element) => { + const ret = element?.groupIds?.some((id) => id === topGroup); + if (ret) { + groupHandledElements.set(element!.id, true); + } + return ret; + }); + + for (const elem of orderInnerGroups(groupElements)) { + sortedElements.add(elem); + } + } else { + sortedElements.add(element); + } + }); + + // if there's a bug which resulted in losing some of the elements, return + // original instead as that's better than losing data + if (sortedElements.size !== elements.length) { + console.error("normalizeGroupElementOrder: lost some elements... bailing!"); + return elements; + } + + return [...sortedElements]; +}; + +/** + * In theory, when we have text elements bound to a container, they + * should be right after the container element in the elements array. + * However, this is not guaranteed due to old and potential future bugs. + * + * This function sorts containers and their bound texts together. It prefers + * original z-index of container (i.e. it moves bound text elements after + * containers). + */ +const normalizeBoundElementsOrder = ( + elements: readonly ExcalidrawElement[], +) => { + const elementsMap = arrayToMapWithIndex(elements); + + const origElements: (ExcalidrawElement | null)[] = elements.slice(); + const sortedElements = new Set<ExcalidrawElement>(); + + origElements.forEach((element, idx) => { + if (!element) { + return; + } + if (element.boundElements?.length) { + sortedElements.add(element); + origElements[idx] = null; + element.boundElements.forEach((boundElement) => { + const child = elementsMap.get(boundElement.id); + if (child && boundElement.type === "text") { + sortedElements.add(child[0]); + origElements[child[1]] = null; + } + }); + } else if (element.type === "text" && element.containerId) { + const parent = elementsMap.get(element.containerId); + if (!parent?.[0].boundElements?.find((x) => x.id === element.id)) { + sortedElements.add(element); + origElements[idx] = null; + + // if element has a container and container lists it, skip this element + // as it'll be taken care of by the container + } + } else { + sortedElements.add(element); + origElements[idx] = null; + } + }); + + // if there's a bug which resulted in losing some of the elements, return + // original instead as that's better than losing data + if (sortedElements.size !== elements.length) { + console.error( + "normalizeBoundElementsOrder: lost some elements... bailing!", + ); + return elements; + } + + return [...sortedElements]; +}; + +export const normalizeElementOrder = ( + elements: readonly ExcalidrawElement[], +) => { + return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements)); +}; diff --git a/packages/excalidraw/element/textElement.test.ts b/packages/excalidraw/element/textElement.test.ts new file mode 100644 index 0000000..2c23c2b --- /dev/null +++ b/packages/excalidraw/element/textElement.test.ts @@ -0,0 +1,206 @@ +import { FONT_FAMILY } from "../constants"; +import { getLineHeight } from "../fonts"; +import { API } from "../tests/helpers/api"; +import { + computeContainerDimensionForBoundText, + getContainerCoords, + getBoundTextMaxWidth, + getBoundTextMaxHeight, +} from "./textElement"; +import { detectLineHeight, getLineHeightInPx } from "./textMeasurements"; +import type { ExcalidrawTextElementWithContainer } from "./types"; + +describe("Test measureText", () => { + describe("Test getContainerCoords", () => { + const params = { width: 200, height: 100, x: 10, y: 20 }; + + it("should compute coords correctly when ellipse", () => { + const element = API.createElement({ + type: "ellipse", + ...params, + }); + expect(getContainerCoords(element)).toEqual({ + x: 44.2893218813452455, + y: 39.64466094067262, + }); + }); + + it("should compute coords correctly when rectangle", () => { + const element = API.createElement({ + type: "rectangle", + ...params, + }); + expect(getContainerCoords(element)).toEqual({ + x: 15, + y: 25, + }); + }); + + it("should compute coords correctly when diamond", () => { + const element = API.createElement({ + type: "diamond", + ...params, + }); + expect(getContainerCoords(element)).toEqual({ + x: 65, + y: 50, + }); + }); + }); + + describe("Test computeContainerDimensionForBoundText", () => { + const params = { + width: 178, + height: 194, + }; + + it("should compute container height correctly for rectangle", () => { + const element = API.createElement({ + type: "rectangle", + ...params, + }); + expect(computeContainerDimensionForBoundText(150, element.type)).toEqual( + 160, + ); + }); + + it("should compute container height correctly for ellipse", () => { + const element = API.createElement({ + type: "ellipse", + ...params, + }); + expect(computeContainerDimensionForBoundText(150, element.type)).toEqual( + 226, + ); + }); + + it("should compute container height correctly for diamond", () => { + const element = API.createElement({ + type: "diamond", + ...params, + }); + expect(computeContainerDimensionForBoundText(150, element.type)).toEqual( + 320, + ); + }); + }); + + describe("Test getBoundTextMaxWidth", () => { + const params = { + width: 178, + height: 194, + }; + + it("should return max width when container is rectangle", () => { + const container = API.createElement({ type: "rectangle", ...params }); + expect(getBoundTextMaxWidth(container, null)).toBe(168); + }); + + it("should return max width when container is ellipse", () => { + const container = API.createElement({ type: "ellipse", ...params }); + expect(getBoundTextMaxWidth(container, null)).toBe(116); + }); + + it("should return max width when container is diamond", () => { + const container = API.createElement({ type: "diamond", ...params }); + expect(getBoundTextMaxWidth(container, null)).toBe(79); + }); + }); + + describe("Test getBoundTextMaxHeight", () => { + const params = { + width: 178, + height: 194, + id: '"container-id', + }; + + const boundTextElement = API.createElement({ + type: "text", + id: "text-id", + x: 560.51171875, + y: 202.033203125, + width: 154, + height: 175, + fontSize: 20, + fontFamily: 1, + text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams", + textAlign: "center", + verticalAlign: "middle", + containerId: params.id, + }) as ExcalidrawTextElementWithContainer; + + it("should return max height when container is rectangle", () => { + const container = API.createElement({ type: "rectangle", ...params }); + expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184); + }); + + it("should return max height when container is ellipse", () => { + const container = API.createElement({ type: "ellipse", ...params }); + expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127); + }); + + it("should return max height when container is diamond", () => { + const container = API.createElement({ type: "diamond", ...params }); + expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87); + }); + + it("should return max height when container is arrow", () => { + const container = API.createElement({ + type: "arrow", + ...params, + }); + expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194); + }); + + it("should return max height when container is arrow and height is less than threshold", () => { + const container = API.createElement({ + type: "arrow", + ...params, + height: 70, + boundElements: [{ type: "text", id: "text-id" }], + }); + + expect(getBoundTextMaxHeight(container, boundTextElement)).toBe( + boundTextElement.height, + ); + }); + }); +}); + +const textElement = API.createElement({ + type: "text", + text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams", + fontSize: 20, + fontFamily: 1, + height: 175, +}); + +describe("Test detectLineHeight", () => { + it("should return correct line height", () => { + expect(detectLineHeight(textElement)).toBe(1.25); + }); +}); + +describe("Test getLineHeightInPx", () => { + it("should return correct line height", () => { + expect( + getLineHeightInPx(textElement.fontSize, textElement.lineHeight), + ).toBe(25); + }); +}); + +describe("Test getDefaultLineHeight", () => { + it("should return line height using default font family when not passed", () => { + //@ts-ignore + expect(getLineHeight()).toBe(1.25); + }); + + it("should return line height using default font family for unknown font", () => { + const UNKNOWN_FONT = 5; + expect(getLineHeight(UNKNOWN_FONT)).toBe(1.25); + }); + + it("should return correct line height", () => { + expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); + }); +}); diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts new file mode 100644 index 0000000..de948d9 --- /dev/null +++ b/packages/excalidraw/element/textElement.ts @@ -0,0 +1,521 @@ +import { getFontString, arrayToMap } from "../utils"; +import type { + ElementsMap, + ExcalidrawElement, + ExcalidrawElementType, + ExcalidrawTextContainer, + ExcalidrawTextElement, + ExcalidrawTextElementWithContainer, + NonDeletedExcalidrawElement, +} from "./types"; +import { mutateElement } from "./mutateElement"; +import { + ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO, + ARROW_LABEL_WIDTH_FRACTION, + BOUND_TEXT_PADDING, + DEFAULT_FONT_SIZE, + TEXT_ALIGN, + VERTICAL_ALIGN, +} from "../constants"; +import type { MaybeTransformHandleType } from "./transformHandles"; +import { isTextElement } from "."; +import { wrapText } from "./textWrapping"; +import { isBoundToContainer, isArrowElement } from "./typeChecks"; +import { LinearElementEditor } from "./linearElementEditor"; +import type { AppState } from "../types"; +import { + resetOriginalContainerCache, + updateOriginalContainerCache, +} from "./containerCache"; +import type { ExtractSetType } from "../utility-types"; +import { measureText } from "./textMeasurements"; + +export const redrawTextBoundingBox = ( + textElement: ExcalidrawTextElement, + container: ExcalidrawElement | null, + elementsMap: ElementsMap, + informMutation = true, +) => { + let maxWidth = undefined; + const boundTextUpdates = { + x: textElement.x, + y: textElement.y, + text: textElement.text, + width: textElement.width, + height: textElement.height, + angle: container?.angle ?? textElement.angle, + }; + + boundTextUpdates.text = textElement.text; + + if (container || !textElement.autoResize) { + maxWidth = container + ? getBoundTextMaxWidth(container, textElement) + : textElement.width; + boundTextUpdates.text = wrapText( + textElement.originalText, + getFontString(textElement), + maxWidth, + ); + } + + const metrics = measureText( + boundTextUpdates.text, + getFontString(textElement), + textElement.lineHeight, + ); + + // Note: only update width for unwrapped text and bound texts (which always have autoResize set to true) + if (textElement.autoResize) { + boundTextUpdates.width = metrics.width; + } + boundTextUpdates.height = metrics.height; + + if (container) { + const maxContainerHeight = getBoundTextMaxHeight( + container, + textElement as ExcalidrawTextElementWithContainer, + ); + const maxContainerWidth = getBoundTextMaxWidth(container, textElement); + + if (!isArrowElement(container) && metrics.height > maxContainerHeight) { + const nextHeight = computeContainerDimensionForBoundText( + metrics.height, + container.type, + ); + mutateElement(container, { height: nextHeight }, informMutation); + updateOriginalContainerCache(container.id, nextHeight); + } + if (metrics.width > maxContainerWidth) { + const nextWidth = computeContainerDimensionForBoundText( + metrics.width, + container.type, + ); + mutateElement(container, { width: nextWidth }, informMutation); + } + const updatedTextElement = { + ...textElement, + ...boundTextUpdates, + } as ExcalidrawTextElementWithContainer; + const { x, y } = computeBoundTextPosition( + container, + updatedTextElement, + elementsMap, + ); + boundTextUpdates.x = x; + boundTextUpdates.y = y; + } + + mutateElement(textElement, boundTextUpdates, informMutation); +}; + +export const bindTextToShapeAfterDuplication = ( + newElements: ExcalidrawElement[], + oldElements: ExcalidrawElement[], + oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>, +): void => { + const newElementsMap = arrayToMap(newElements) as Map< + ExcalidrawElement["id"], + ExcalidrawElement + >; + oldElements.forEach((element) => { + const newElementId = oldIdToDuplicatedId.get(element.id) as string; + const boundTextElementId = getBoundTextElementId(element); + + if (boundTextElementId) { + const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId); + if (newTextElementId) { + const newContainer = newElementsMap.get(newElementId); + if (newContainer) { + mutateElement(newContainer, { + boundElements: (element.boundElements || []) + .filter( + (boundElement) => + boundElement.id !== newTextElementId && + boundElement.id !== boundTextElementId, + ) + .concat({ + type: "text", + id: newTextElementId, + }), + }); + } + const newTextElement = newElementsMap.get(newTextElementId); + if (newTextElement && isTextElement(newTextElement)) { + mutateElement(newTextElement, { + containerId: newContainer ? newElementId : null, + }); + } + } + } + }); +}; + +export const handleBindTextResize = ( + container: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, + transformHandleType: MaybeTransformHandleType, + shouldMaintainAspectRatio = false, +) => { + const boundTextElementId = getBoundTextElementId(container); + if (!boundTextElementId) { + return; + } + resetOriginalContainerCache(container.id); + const textElement = getBoundTextElement(container, elementsMap); + if (textElement && textElement.text) { + if (!container) { + return; + } + + let text = textElement.text; + let nextHeight = textElement.height; + let nextWidth = textElement.width; + const maxWidth = getBoundTextMaxWidth(container, textElement); + const maxHeight = getBoundTextMaxHeight(container, textElement); + let containerHeight = container.height; + if ( + shouldMaintainAspectRatio || + (transformHandleType !== "n" && transformHandleType !== "s") + ) { + if (text) { + text = wrapText( + textElement.originalText, + getFontString(textElement), + maxWidth, + ); + } + const metrics = measureText( + text, + getFontString(textElement), + textElement.lineHeight, + ); + nextHeight = metrics.height; + nextWidth = metrics.width; + } + // increase height in case text element height exceeds + if (nextHeight > maxHeight) { + containerHeight = computeContainerDimensionForBoundText( + nextHeight, + container.type, + ); + + const diff = containerHeight - container.height; + // fix the y coord when resizing from ne/nw/n + const updatedY = + !isArrowElement(container) && + (transformHandleType === "ne" || + transformHandleType === "nw" || + transformHandleType === "n") + ? container.y - diff + : container.y; + mutateElement(container, { + height: containerHeight, + y: updatedY, + }); + } + + mutateElement(textElement, { + text, + width: nextWidth, + height: nextHeight, + }); + + if (!isArrowElement(container)) { + mutateElement( + textElement, + computeBoundTextPosition(container, textElement, elementsMap), + ); + } + } +}; + +export const computeBoundTextPosition = ( + container: ExcalidrawElement, + boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, +) => { + if (isArrowElement(container)) { + return LinearElementEditor.getBoundTextElementPosition( + container, + boundTextElement, + elementsMap, + ); + } + const containerCoords = getContainerCoords(container); + const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement); + const maxContainerWidth = getBoundTextMaxWidth(container, boundTextElement); + + let x; + let y; + if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) { + y = containerCoords.y; + } else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) { + y = containerCoords.y + (maxContainerHeight - boundTextElement.height); + } else { + y = + containerCoords.y + + (maxContainerHeight / 2 - boundTextElement.height / 2); + } + if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) { + x = containerCoords.x; + } else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) { + x = containerCoords.x + (maxContainerWidth - boundTextElement.width); + } else { + x = + containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2); + } + return { x, y }; +}; + +export const getBoundTextElementId = (container: ExcalidrawElement | null) => { + return container?.boundElements?.length + ? container?.boundElements?.find((ele) => ele.type === "text")?.id || null + : null; +}; + +export const getBoundTextElement = ( + element: ExcalidrawElement | null, + elementsMap: ElementsMap, +) => { + if (!element) { + return null; + } + const boundTextElementId = getBoundTextElementId(element); + + if (boundTextElementId) { + return (elementsMap.get(boundTextElementId) || + null) as ExcalidrawTextElementWithContainer | null; + } + return null; +}; + +export const getContainerElement = ( + element: ExcalidrawTextElement | null, + elementsMap: ElementsMap, +): ExcalidrawTextContainer | null => { + if (!element) { + return null; + } + if (element.containerId) { + return (elementsMap.get(element.containerId) || + null) as ExcalidrawTextContainer | null; + } + return null; +}; + +export const getContainerCenter = ( + container: ExcalidrawElement, + appState: AppState, + elementsMap: ElementsMap, +) => { + if (!isArrowElement(container)) { + return { + x: container.x + container.width / 2, + y: container.y + container.height / 2, + }; + } + const points = LinearElementEditor.getPointsGlobalCoordinates( + container, + elementsMap, + ); + if (points.length % 2 === 1) { + const index = Math.floor(container.points.length / 2); + const midPoint = LinearElementEditor.getPointGlobalCoordinates( + container, + container.points[index], + elementsMap, + ); + return { x: midPoint[0], y: midPoint[1] }; + } + const index = container.points.length / 2 - 1; + let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints( + container, + elementsMap, + appState, + )[index]; + if (!midSegmentMidpoint) { + midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( + container, + points[index], + points[index + 1], + index + 1, + elementsMap, + ); + } + return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; +}; + +export const getContainerCoords = (container: NonDeletedExcalidrawElement) => { + let offsetX = BOUND_TEXT_PADDING; + let offsetY = BOUND_TEXT_PADDING; + + if (container.type === "ellipse") { + // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172 + offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2); + offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2); + } + // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265 + if (container.type === "diamond") { + offsetX += container.width / 4; + offsetY += container.height / 4; + } + return { + x: container.x + offsetX, + y: container.y + offsetY, + }; +}; + +export const getTextElementAngle = ( + textElement: ExcalidrawTextElement, + container: ExcalidrawTextContainer | null, +) => { + if (!container || isArrowElement(container)) { + return textElement.angle; + } + return container.angle; +}; + +export const getBoundTextElementPosition = ( + container: ExcalidrawElement, + boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, +) => { + if (isArrowElement(container)) { + return LinearElementEditor.getBoundTextElementPosition( + container, + boundTextElement, + elementsMap, + ); + } +}; + +export const shouldAllowVerticalAlign = ( + selectedElements: NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, +) => { + return selectedElements.some((element) => { + if (isBoundToContainer(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { + return false; + } + return true; + } + return false; + }); +}; + +export const suppportsHorizontalAlign = ( + selectedElements: NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, +) => { + return selectedElements.some((element) => { + if (isBoundToContainer(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { + return false; + } + return true; + } + + return isTextElement(element); + }); +}; + +const VALID_CONTAINER_TYPES = new Set([ + "rectangle", + "ellipse", + "diamond", + "arrow", +]); + +export const isValidTextContainer = (element: { + type: ExcalidrawElementType; +}) => VALID_CONTAINER_TYPES.has(element.type); + +export const computeContainerDimensionForBoundText = ( + dimension: number, + containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>, +) => { + dimension = Math.ceil(dimension); + const padding = BOUND_TEXT_PADDING * 2; + + if (containerType === "ellipse") { + return Math.round(((dimension + padding) / Math.sqrt(2)) * 2); + } + if (containerType === "arrow") { + return dimension + padding * 8; + } + if (containerType === "diamond") { + return 2 * (dimension + padding); + } + return dimension + padding; +}; + +export const getBoundTextMaxWidth = ( + container: ExcalidrawElement, + boundTextElement: ExcalidrawTextElement | null, +) => { + const { width } = container; + if (isArrowElement(container)) { + const minWidth = + (boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) * + ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO; + return Math.max(ARROW_LABEL_WIDTH_FRACTION * width, minWidth); + } + if (container.type === "ellipse") { + // The width of the largest rectangle inscribed inside an ellipse is + // Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from + // equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172 + return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2; + } + if (container.type === "diamond") { + // The width of the largest rectangle inscribed inside a rhombus is + // Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265 + return Math.round(width / 2) - BOUND_TEXT_PADDING * 2; + } + return width - BOUND_TEXT_PADDING * 2; +}; + +export const getBoundTextMaxHeight = ( + container: ExcalidrawElement, + boundTextElement: ExcalidrawTextElementWithContainer, +) => { + const { height } = container; + if (isArrowElement(container)) { + const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2; + if (containerHeight <= 0) { + return boundTextElement.height; + } + return height; + } + if (container.type === "ellipse") { + // The height of the largest rectangle inscribed inside an ellipse is + // Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from + // equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172 + return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2; + } + if (container.type === "diamond") { + // The height of the largest rectangle inscribed inside a rhombus is + // Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265 + return Math.round(height / 2) - BOUND_TEXT_PADDING * 2; + } + return height - BOUND_TEXT_PADDING * 2; +}; + +/** retrieves text from text elements and concatenates to a single string */ +export const getTextFromElements = ( + elements: readonly ExcalidrawElement[], + separator = "\n\n", +) => { + const text = elements + .reduce((acc: string[], element) => { + if (isTextElement(element)) { + acc.push(element.text); + } + return acc; + }, []) + .join(separator); + return text; +}; diff --git a/packages/excalidraw/element/textMeasurements.ts b/packages/excalidraw/element/textMeasurements.ts new file mode 100644 index 0000000..f2a132a --- /dev/null +++ b/packages/excalidraw/element/textMeasurements.ts @@ -0,0 +1,224 @@ +import { + BOUND_TEXT_PADDING, + DEFAULT_FONT_SIZE, + DEFAULT_FONT_FAMILY, +} from "../constants"; +import { getFontString, isTestEnv, normalizeEOL } from "../utils"; +import type { FontString, ExcalidrawTextElement } from "./types"; + +export const measureText = ( + text: string, + font: FontString, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + const _text = text + .split("\n") + // replace empty lines with single space because leading/trailing empty + // lines would be stripped from computation + .map((x) => x || " ") + .join("\n"); + const fontSize = parseFloat(font); + const height = getTextHeight(_text, fontSize, lineHeight); + const width = getTextWidth(_text, font); + return { width, height }; +}; + +const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); + +// FIXME rename to getApproxMinContainerWidth +export const getApproxMinLineWidth = ( + font: FontString, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + const maxCharWidth = getMaxCharWidth(font); + if (maxCharWidth === 0) { + return ( + measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width + + BOUND_TEXT_PADDING * 2 + ); + } + return maxCharWidth + BOUND_TEXT_PADDING * 2; +}; + +export const getMinTextElementWidth = ( + font: FontString, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2; +}; + +export const isMeasureTextSupported = () => { + const width = getTextWidth( + DUMMY_TEXT, + getFontString({ + fontSize: DEFAULT_FONT_SIZE, + fontFamily: DEFAULT_FONT_FAMILY, + }), + ); + return width > 0; +}; + +export const normalizeText = (text: string) => { + return ( + normalizeEOL(text) + // replace tabs with spaces so they render and measure correctly + .replace(/\t/g, " ") + ); +}; + +const splitIntoLines = (text: string) => { + return normalizeText(text).split("\n"); +}; + +/** + * To get unitless line-height (if unknown) we can calculate it by dividing + * height-per-line by fontSize. + */ +export const detectLineHeight = (textElement: ExcalidrawTextElement) => { + const lineCount = splitIntoLines(textElement.text).length; + return (textElement.height / + lineCount / + textElement.fontSize) as ExcalidrawTextElement["lineHeight"]; +}; + +/** + * We calculate the line height from the font size and the unitless line height, + * aligning with the W3C spec. + */ +export const getLineHeightInPx = ( + fontSize: ExcalidrawTextElement["fontSize"], + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + return fontSize * lineHeight; +}; + +// FIXME rename to getApproxMinContainerHeight +export const getApproxMinLineHeight = ( + fontSize: ExcalidrawTextElement["fontSize"], + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2; +}; + +let textMetricsProvider: TextMetricsProvider | undefined; + +/** + * Set a custom text metrics provider. + * + * Useful for overriding the width calculation algorithm where canvas API is not available / desired. + */ +export const setCustomTextMetricsProvider = (provider: TextMetricsProvider) => { + textMetricsProvider = provider; +}; + +export interface TextMetricsProvider { + getLineWidth(text: string, fontString: FontString): number; +} + +class CanvasTextMetricsProvider implements TextMetricsProvider { + private canvas: HTMLCanvasElement; + + constructor() { + this.canvas = document.createElement("canvas"); + } + + /** + * We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for: + * - text wrapping + * - wysiwyg editor (+padding) + * + * > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position. + */ + public getLineWidth(text: string, fontString: FontString): number { + const context = this.canvas.getContext("2d")!; + context.font = fontString; + const metrics = context.measureText(text); + const advanceWidth = metrics.width; + + // since in test env the canvas measureText algo + // doesn't measure text and instead just returns number of + // characters hence we assume that each letteris 10px + if (isTestEnv()) { + return advanceWidth * 10; + } + + return advanceWidth; + } +} + +export const getLineWidth = (text: string, font: FontString) => { + if (!textMetricsProvider) { + textMetricsProvider = new CanvasTextMetricsProvider(); + } + + return textMetricsProvider.getLineWidth(text, font); +}; + +export const getTextWidth = (text: string, font: FontString) => { + const lines = splitIntoLines(text); + let width = 0; + lines.forEach((line) => { + width = Math.max(width, getLineWidth(line, font)); + }); + + return width; +}; + +export const getTextHeight = ( + text: string, + fontSize: number, + lineHeight: ExcalidrawTextElement["lineHeight"], +) => { + const lineCount = splitIntoLines(text).length; + return getLineHeightInPx(fontSize, lineHeight) * lineCount; +}; + +export const charWidth = (() => { + const cachedCharWidth: { [key: FontString]: Array<number> } = {}; + + const calculate = (char: string, font: FontString) => { + const unicode = char.charCodeAt(0); + if (!cachedCharWidth[font]) { + cachedCharWidth[font] = []; + } + if (!cachedCharWidth[font][unicode]) { + const width = getLineWidth(char, font); + cachedCharWidth[font][unicode] = width; + } + + return cachedCharWidth[font][unicode]; + }; + + const getCache = (font: FontString) => { + return cachedCharWidth[font]; + }; + + const clearCache = (font: FontString) => { + cachedCharWidth[font] = []; + }; + + return { + calculate, + getCache, + clearCache, + }; +})(); + +export const getMinCharWidth = (font: FontString) => { + const cache = charWidth.getCache(font); + if (!cache) { + return 0; + } + const cacheWithOutEmpty = cache.filter((val) => val !== undefined); + + return Math.min(...cacheWithOutEmpty); +}; + +export const getMaxCharWidth = (font: FontString) => { + const cache = charWidth.getCache(font); + if (!cache) { + return 0; + } + const cacheWithOutEmpty = cache.filter((val) => val !== undefined); + return Math.max(...cacheWithOutEmpty); +}; diff --git a/packages/excalidraw/element/textWrapping.test.ts b/packages/excalidraw/element/textWrapping.test.ts new file mode 100644 index 0000000..6c7bcb8 --- /dev/null +++ b/packages/excalidraw/element/textWrapping.test.ts @@ -0,0 +1,633 @@ +import { wrapText, parseTokens } from "./textWrapping"; +import type { FontString } from "./types"; + +describe("Test wrapText", () => { + // font is irrelevant as jsdom does not support FontFace API + // `measureText` width is mocked to return `text.length` by `jest-canvas-mock` + // https://github.com/hustcc/jest-canvas-mock/blob/master/src/classes/TextMetrics.js + const font = "10px Cascadia, Segoe UI Emoji" as FontString; + + it("should wrap the text correctly when word length is exactly equal to max width", () => { + const text = "Hello Excalidraw"; + // Length of "Excalidraw" is 100 and exacty equal to max width + const res = wrapText(text, font, 100); + expect(res).toEqual(`Hello\nExcalidraw`); + }); + + it("should return the text as is if max width is invalid", () => { + const text = "Hello Excalidraw"; + expect(wrapText(text, font, NaN)).toEqual(text); + expect(wrapText(text, font, -1)).toEqual(text); + expect(wrapText(text, font, Infinity)).toEqual(text); + }); + + it("should show the text correctly when max width reached", () => { + const text = "Hello😀"; + const maxWidth = 10; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("H\ne\nl\nl\no\n😀"); + }); + + it("should not wrap number when wrapping line", () => { + const text = "don't wrap this number 99,100.99"; + const maxWidth = 300; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("don't wrap this number\n99,100.99"); + }); + + it("should trim all trailing whitespaces", () => { + const text = "Hello "; + const maxWidth = 50; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hello"); + }); + + it("should trim all but one trailing whitespaces", () => { + const text = "Hello "; + const maxWidth = 60; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hello "); + }); + + it("should keep preceding whitespaces and trim all trailing whitespaces", () => { + const text = " Hello World"; + const maxWidth = 90; + const res = wrapText(text, font, maxWidth); + expect(res).toBe(" Hello\nWorld"); + }); + + it("should keep some preceding whitespaces, trim trailing whitespaces, but kep those that fit in the trailing line", () => { + const text = " Hello World "; + const maxWidth = 90; + const res = wrapText(text, font, maxWidth); + expect(res).toBe(" Hello\nWorld "); + }); + + it("should trim keep those whitespace that fit in the trailing line", () => { + const text = "Hello Wo rl d "; + const maxWidth = 100; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hello Wo\nrl d "); + }); + + it("should support multiple (multi-codepoint) emojis", () => { + const text = "😀🗺🔥👩🏽🦰👨👩👧👦🇨🇿"; + const maxWidth = 1; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("😀\n🗺\n🔥\n👩🏽🦰\n👨👩👧👦\n🇨🇿"); + }); + + it("should wrap the text correctly when text contains hyphen", () => { + let text = + "Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects"; + const res = wrapText(text, font, 110); + expect(res).toBe( + `Wikipedia\nis hosted\nby\nWikimedia-\nFoundation,\na non-\nprofit\norganizatio\nn that also\nhosts a\nrange-of\nother\nprojects`, + ); + + text = "Hello thereusing-now"; + expect(wrapText(text, font, 100)).toEqual("Hello\nthereusing\n-now"); + }); + + it("should support wrapping nested lists", () => { + const text = `\tA) one tab\t\t- two tabs - 8 spaces`; + + const maxWidth = 100; + const res = wrapText(text, font, maxWidth); + expect(res).toBe(`\tA) one\ntab\t\t- two\ntabs\n- 8 spaces`); + + const maxWidth2 = 50; + const res2 = wrapText(text, font, maxWidth2); + expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`); + }); + + describe("When text is CJK", () => { + it("should break each CJK character when width is very small", () => { + // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" + const text = "안녕하세요こんにちは世界コンニチハ你好"; + const maxWidth = 10; + const res = wrapText(text, font, maxWidth); + expect(res).toBe( + "안\n녕\n하\n세\n요\nこ\nん\nに\nち\nは\n世\n界\nコ\nン\nニ\nチ\nハ\n你\n好", + ); + }); + + it("should break CJK text into longer segments when width is larger", () => { + // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" + const text = "안녕하세요こんにちは世界コンニチハ你好"; + const maxWidth = 30; + const res = wrapText(text, font, maxWidth); + + // measureText is mocked, so it's not precisely what would happen in prod + expect(res).toBe("안녕하\n세요こ\nんにち\nは世界\nコンニ\nチハ你\n好"); + }); + + it("should handle a combination of CJK, latin, emojis and whitespaces", () => { + const text = `a醫 醫 bb 你好 world-i-😀🗺🔥`; + + const maxWidth = 150; + const res = wrapText(text, font, maxWidth); + expect(res).toBe(`a醫 醫 bb 你\n好 world-i-😀🗺\n🔥`); + + const maxWidth2 = 50; + const res2 = wrapText(text, font, maxWidth2); + expect(res2).toBe(`a醫 醫\nbb 你\n好\nworld\n-i-😀\n🗺🔥`); + + const maxWidth3 = 30; + const res3 = wrapText(text, font, maxWidth3); + expect(res3).toBe(`a醫\n醫\nbb\n你好\nwor\nld-\ni-\n😀\n🗺\n🔥`); + }); + + it("should break before and after a regular CJK character", () => { + const text = "HelloたWorld"; + const maxWidth1 = 50; + const res1 = wrapText(text, font, maxWidth1); + expect(res1).toBe("Hello\nた\nWorld"); + + const maxWidth2 = 60; + const res2 = wrapText(text, font, maxWidth2); + expect(res2).toBe("Helloた\nWorld"); + }); + + it("should break before and after certain CJK symbols", () => { + const text = "こんにちは〃世界"; + const maxWidth1 = 50; + const res1 = wrapText(text, font, maxWidth1); + expect(res1).toBe("こんにちは\n〃世界"); + + const maxWidth2 = 60; + const res2 = wrapText(text, font, maxWidth2); + expect(res2).toBe("こんにちは〃\n世界"); + }); + + it("should break after, not before for certain CJK pairs", () => { + const text = "Hello た。"; + const maxWidth = 70; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hello\nた。"); + }); + + it("should break before, not after for certain CJK pairs", () => { + const text = "Hello「たWorld」"; + const maxWidth = 60; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hello\n「た\nWorld」"); + }); + + it("should break after, not before for certain CJK character pairs", () => { + const text = "「Helloた」World"; + const maxWidth = 70; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("「Hello\nた」World"); + }); + + it("should break Chinese sentences", () => { + const text = `中国你好!这是一个测试。 +我们来看看:人民币¥1234「很贵」 +(括号)、逗号,句号。空格 换行 全角符号…—`; + + const maxWidth1 = 80; + const res1 = wrapText(text, font, maxWidth1); + expect(res1).toBe(`中国你好!这是一\n个测试。 +我们来看看:人民\n币¥1234「很\n贵」 +(括号)、逗号,\n句号。空格 换行\n全角符号…—`); + + const maxWidth2 = 50; + const res2 = wrapText(text, font, maxWidth2); + expect(res2).toBe(`中国你好!\n这是一个测\n试。 +我们来看\n看:人民币\n¥1234\n「很贵」 +(括号)、\n逗号,句\n号。空格\n换行 全角\n符号…—`); + }); + + it("should break Japanese sentences", () => { + const text = `日本こんにちは!これはテストです。 + 見てみましょう:円¥1234「高い」 + (括弧)、読点、句点。 + 空白 改行 全角記号…ー`; + + const maxWidth1 = 80; + const res1 = wrapText(text, font, maxWidth1); + expect(res1).toBe(`日本こんにちは!\nこれはテストで\nす。 + 見てみましょ\nう:円¥1234\n「高い」 + (括弧)、読\n点、句点。 + 空白 改行\n全角記号…ー`); + + const maxWidth2 = 50; + const res2 = wrapText(text, font, maxWidth2); + expect(res2).toBe(`日本こんに\nちは!これ\nはテストで\nす。 + 見てみ\nましょう:\n円\n¥1234\n「高い」 + (括\n弧)、読\n点、句点。 + 空白\n改行 全角\n記号…ー`); + }); + + it("should break Korean sentences", () => { + const text = `한국 안녕하세요! 이것은 테스트입니다. +우리 보자: 원화₩1234「비싸다」 +(괄호), 쉼표, 마침표. +공백 줄바꿈 전각기호…—`; + + const maxWidth1 = 80; + const res1 = wrapText(text, font, maxWidth1); + expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다. +우리 보자: 원\n화₩1234「비\n싸다」 +(괄호), 쉼\n표, 마침표. +공백 줄바꿈 전\n각기호…—`); + + const maxWidth2 = 60; + const res2 = wrapText(text, font, maxWidth2); + expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다. +우리 보자:\n원화\n₩1234\n「비싸다」 +(괄호),\n쉼표, 마침\n표. +공백 줄바꿈\n전각기호…—`); + }); + }); + + describe("When text contains leading whitespaces", () => { + const text = " \t Hello world"; + + it("should preserve leading whitespaces", () => { + const maxWidth = 120; + const res = wrapText(text, font, maxWidth); + expect(res).toBe(" \t Hello\nworld"); + }); + + it("should break and collapse leading whitespaces when line breaks", () => { + const maxWidth = 60; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("\nHello\nworld"); + }); + + it("should break and collapse leading whitespaces whe words break", () => { + const maxWidth = 30; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("\nHel\nlo\nwor\nld"); + }); + }); + + describe("When text contains trailing whitespaces", () => { + it("shouldn't add new lines for trailing spaces", () => { + const text = "Hello whats up "; + const maxWidth = 190; + const res = wrapText(text, font, maxWidth); + expect(res).toBe(text); + }); + + it("should ignore trailing whitespaces when line breaks", () => { + const text = "Hippopotomonstrosesquippedaliophobia ??????"; + const maxWidth = 400; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hippopotomonstrosesquippedaliophobia\n??????"); + }); + + it("should not ignore trailing whitespaces when word breaks", () => { + const text = "Hippopotomonstrosesquippedaliophobia ??????"; + const maxWidth = 300; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hippopotomonstrosesquippedalio\nphobia ??????"); + }); + + it("should ignore trailing whitespaces when word breaks and line breaks", () => { + const text = "Hippopotomonstrosesquippedaliophobia ??????"; + const maxWidth = 180; + const res = wrapText(text, font, maxWidth); + expect(res).toBe("Hippopotomonstrose\nsquippedaliophobia\n??????"); + }); + }); + + describe("When text doesn't contain new lines", () => { + const text = "Hello whats up"; + + [ + { + desc: "break all words when width of each word is less than container width", + width: 70, + res: `Hello\nwhats\nup`, + }, + { + desc: "break all characters when width of each character is less than container width", + width: 15, + res: `H\ne\nl\nl\no\nw\nh\na\nt\ns\nu\np`, + }, + { + desc: "break words as per the width", + + width: 130, + res: `Hello whats\nup`, + }, + { + desc: "fit the container", + + width: 240, + res: "Hello whats up", + }, + { + desc: "push the word if its equal to max width", + width: 50, + res: `Hello\nwhats\nup`, + }, + ].forEach((data) => { + it(`should ${data.desc}`, () => { + const res = wrapText(text, font, data.width); + expect(res).toEqual(data.res); + }); + }); + }); + + describe("When text contain new lines", () => { + const text = `Hello\n whats up`; + [ + { + desc: "break all words when width of each word is less than container width", + width: 70, + res: `Hello\n whats\nup`, + }, + { + desc: "break all characters when width of each character is less than container width", + width: 15, + res: `H\ne\nl\nl\no\n\nw\nh\na\nt\ns\nu\np`, + }, + { + desc: "break words as per the width", + width: 140, + res: `Hello\n whats up`, + }, + ].forEach((data) => { + it(`should respect new lines and ${data.desc}`, () => { + const res = wrapText(text, font, data.width); + expect(res).toEqual(data.res); + }); + }); + }); + + describe("When text is long", () => { + const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`; + [ + { + desc: "fit characters of long string as per container width", + width: 160, + res: `hellolongtextthi\nsiswhatsupwithyo\nuIamtypingggggan\ndtypinggg break\nit now`, + }, + { + desc: "fit characters of long string as per container width and break words as per the width", + + width: 120, + res: `hellolongtex\ntthisiswhats\nupwithyouIam\ntypingggggan\ndtypinggg\nbreak it now`, + }, + { + desc: "fit the long text when container width is greater than text length and move the rest to next line", + + width: 590, + res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`, + }, + ].forEach((data) => { + it(`should ${data.desc}`, () => { + const res = wrapText(text, font, data.width); + expect(res).toEqual(data.res); + }); + }); + }); + + describe("Test parseTokens", () => { + it("should tokenize latin", () => { + let text = "Excalidraw is a virtual collaborative whiteboard"; + + expect(parseTokens(text)).toEqual([ + "Excalidraw", + " ", + "is", + " ", + "a", + " ", + "virtual", + " ", + "collaborative", + " ", + "whiteboard", + ]); + + text = + "Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects"; + expect(parseTokens(text)).toEqual([ + "Wikipedia", + " ", + "is", + " ", + "hosted", + " ", + "by", + " ", + "Wikimedia-", + " ", + "Foundation,", + " ", + "a", + " ", + "non-", + "profit", + " ", + "organization", + " ", + "that", + " ", + "also", + " ", + "hosts", + " ", + "a", + " ", + "range-", + "of", + " ", + "other", + " ", + "projects", + ]); + }); + + it("should not tokenize number", () => { + const text = "99,100.99"; + const tokens = parseTokens(text); + expect(tokens).toEqual(["99,100.99"]); + }); + + it("should tokenize joined emojis", () => { + const text = `😬🌍🗺🔥☂️👩🏽🦰👨👩👧👦👩🏾🔬🏳️🌈🧔♀️🧑🤝🧑🙅🏽♂️✅0️⃣🇨🇿🦅`; + const tokens = parseTokens(text); + + expect(tokens).toEqual([ + "😬", + "🌍", + "🗺", + "🔥", + "☂️", + "👩🏽🦰", + "👨👩👧👦", + "👩🏾🔬", + "🏳️🌈", + "🧔♀️", + "🧑🤝🧑", + "🙅🏽♂️", + "✅", + "0️⃣", + "🇨🇿", + "🦅", + ]); + }); + + it("should tokenize emojis mixed with mixed text", () => { + const text = `😬a🌍b🗺c🔥d☂️《👩🏽🦰》👨👩👧👦德👩🏾🔬こ🏳️🌈안🧔♀️g🧑🤝🧑h🙅🏽♂️e✅f0️⃣g🇨🇿10🦅#hash`; + const tokens = parseTokens(text); + + expect(tokens).toEqual([ + "😬", + "a", + "🌍", + "b", + "🗺", + "c", + "🔥", + "d", + "☂️", + "《", + "👩🏽🦰", + "》", + "👨👩👧👦", + "德", + "👩🏾🔬", + "こ", + "🏳️🌈", + "안", + "🧔♀️", + "g", + "🧑🤝🧑", + "h", + "🙅🏽♂️", + "e", + "✅", + "f0️⃣g", // bummer, but ok, as we traded kecaps not breaking (less common) for hash and numbers not breaking (more common) + "🇨🇿", + "10", // nice! do not break the number, as it's by default matched by \p{Emoji} + "🦅", + "#hash", // nice! do not break the hash, as it's by default matched by \p{Emoji} + ]); + }); + + it("should tokenize decomposed chars into their composed variants", () => { + // each input character is in a decomposed form + const text = "čでäぴέ다й한"; + expect(text.normalize("NFC").length).toEqual(8); + expect(text).toEqual(text.normalize("NFD")); + + const tokens = parseTokens(text); + expect(tokens.length).toEqual(8); + expect(tokens).toEqual(["č", "で", "ä", "ぴ", "έ", "다", "й", "한"]); + }); + + it("should tokenize artificial CJK", () => { + const text = `《道德經》醫-醫こんにちは世界!안녕하세요세계;요』,다.다...원/달(((다)))[[1]]〚({((한))>)〛(「た」)た…[Hello] \t World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`; + // [ + // '《道', '德', '經》', '醫-', + // '醫', 'こ', 'ん', 'に', + // 'ち', 'は', '世', '界!', + // '안', '녕', '하', '세', + // '요', '세', '계;', '요』,', + // '다.', '다...', '원/', '달', + // '(((다)))', '[[1]]', '〚({((한))>)〛', '(「た」)', + // 'た…', '[Hello]', ' ', '\t', + // ' ', 'World?', 'ニ', 'ュ', + // 'ー', 'ヨ', 'ー', 'ク・', + // '¥3700.55', 'す。', '090-', '1234-', + // '5678', '¥1,000〜', '$5,000', '「素', + // '晴', 'ら', 'し', 'い!」', + // '〔重', '要〕', '#', '1:', + // 'Taro', '君', '30%', 'は、', + // '(た', 'な', 'ば', 'た)', + // '〰', '¥110±', '¥570', 'で', + // '20℃〜', '9:30〜', '10:00', '【一', + // '番】' + // ] + const tokens = parseTokens(text); + + // Latin + expect(tokens).toContain("[[1]]"); + expect(tokens).toContain("[Hello]"); + expect(tokens).toContain("World?"); + expect(tokens).toContain("Taro"); + + // Chinese + expect(tokens).toContain("《道"); + expect(tokens).toContain("德"); + expect(tokens).toContain("經》"); + expect(tokens).toContain("醫-"); + expect(tokens).toContain("醫"); + + // Japanese + expect(tokens).toContain("こ"); + expect(tokens).toContain("ん"); + expect(tokens).toContain("に"); + expect(tokens).toContain("ち"); + expect(tokens).toContain("は"); + expect(tokens).toContain("世"); + expect(tokens).toContain("ク・"); + expect(tokens).toContain("界!"); + expect(tokens).toContain("た…"); + expect(tokens).toContain("す。"); + expect(tokens).toContain("ュ"); + expect(tokens).toContain("「素"); + expect(tokens).toContain("晴"); + expect(tokens).toContain("ら"); + expect(tokens).toContain("し"); + expect(tokens).toContain("い!」"); + expect(tokens).toContain("君"); + expect(tokens).toContain("は、"); + expect(tokens).toContain("(た"); + expect(tokens).toContain("な"); + expect(tokens).toContain("ば"); + expect(tokens).toContain("た)"); + expect(tokens).toContain("で"); + expect(tokens).toContain("【一"); + expect(tokens).toContain("番】"); + + // Check for Korean + expect(tokens).toContain("안"); + expect(tokens).toContain("녕"); + expect(tokens).toContain("하"); + expect(tokens).toContain("세"); + expect(tokens).toContain("요"); + expect(tokens).toContain("세"); + expect(tokens).toContain("계;"); + expect(tokens).toContain("요』,"); + expect(tokens).toContain("다."); + expect(tokens).toContain("다..."); + expect(tokens).toContain("원/"); + expect(tokens).toContain("달"); + expect(tokens).toContain("(((다)))"); + expect(tokens).toContain("〚({((한))>)〛"); + expect(tokens).toContain("(「た」)"); + + // Numbers and units + expect(tokens).toContain("¥3700.55"); + expect(tokens).toContain("090-"); + expect(tokens).toContain("1234-"); + expect(tokens).toContain("5678"); + expect(tokens).toContain("¥1,000〜"); + expect(tokens).toContain("$5,000"); + expect(tokens).toContain("1:"); + expect(tokens).toContain("30%"); + expect(tokens).toContain("¥110±"); + expect(tokens).toContain("20℃〜"); + expect(tokens).toContain("9:30〜"); + expect(tokens).toContain("10:00"); + + // Punctuation and symbols + expect(tokens).toContain(" "); + expect(tokens).toContain("\t"); + expect(tokens).toContain(" "); + expect(tokens).toContain("ニ"); + expect(tokens).toContain("ー"); + expect(tokens).toContain("ヨ"); + expect(tokens).toContain("〰"); + expect(tokens).toContain("#"); + }); + }); +}); diff --git a/packages/excalidraw/element/textWrapping.ts b/packages/excalidraw/element/textWrapping.ts new file mode 100644 index 0000000..1913f6e --- /dev/null +++ b/packages/excalidraw/element/textWrapping.ts @@ -0,0 +1,568 @@ +import { ENV } from "../constants"; +import { charWidth, getLineWidth } from "./textMeasurements"; +import type { FontString } from "./types"; + +let cachedCjkRegex: RegExp | undefined; +let cachedLineBreakRegex: RegExp | undefined; +let cachedEmojiRegex: RegExp | undefined; + +/** + * Test if a given text contains any CJK characters (including symbols, punctuation, etc,). + */ +export const containsCJK = (text: string) => { + if (!cachedCjkRegex) { + cachedCjkRegex = Regex.class(...Object.values(CJK)); + } + + return cachedCjkRegex.test(text); +}; + +const getLineBreakRegex = () => { + if (!cachedLineBreakRegex) { + try { + cachedLineBreakRegex = getLineBreakRegexAdvanced(); + } catch { + cachedLineBreakRegex = getLineBreakRegexSimple(); + } + } + + return cachedLineBreakRegex; +}; + +const getEmojiRegex = () => { + if (!cachedEmojiRegex) { + cachedEmojiRegex = getEmojiRegexUnicode(); + } + + return cachedEmojiRegex; +}; + +/** + * Common symbols used across different languages. + */ +const COMMON = { + /** + * Natural breaking points for any grammars. + * + * Hello world + * ↑ BREAK ALWAYS " " → ["Hello", " ", "world"] + * Hello-world + * ↑ BREAK AFTER "-" → ["Hello-", "world"] + */ + WHITESPACE: /\s/u, + HYPHEN: /-/u, + /** + * Generally do not break, unless closed symbol is followed by an opening symbol. + * + * Also, western punctation is often used in modern Korean and expects to be treated + * similarly to the CJK opening and closing symbols. + * + * Hello(한글)→ ["Hello", "(한", "글)"] + * ↑ BREAK BEFORE "(" + * ↑ BREAK AFTER ")" + */ + OPENING: /<\(\[\{/u, + CLOSING: />\)\]\}.,:;!\?…\//u, +}; + +/** + * Characters and symbols used in Chinese, Japanese and Korean. + */ +const CJK = { + /** + * Every CJK breaks before and after, unless it's paired with an opening or closing symbol. + * + * Does not include every possible char used in CJK texts, such as currency, parentheses or punctuation. + */ + CHAR: /\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}`'^〃〰〆#&*+-ー/\=|¦〒¬ ̄/u, + /** + * Opening and closing CJK punctuation breaks before and after all such characters (in case of many), + * and creates pairs with neighboring characters. + * + * Hello た。→ ["Hello", "た。"] + * ↑ DON'T BREAK "た。" + * * Hello「た」 World → ["Hello", "「た」", "World"] + * ↑ DON'T BREAK "「た" + * ↑ DON'T BREAK "た" + * ↑ BREAK BEFORE "「" + * ↑ BREAK AFTER "」" + */ + // eslint-disable-next-line prettier/prettier + OPENING:/([{〈《⦅「「『【〖〔〘〚<〝/u, + CLOSING: /)]}〉》⦆」」』】〗〕〙〛>。.,、〟‥?!:;・〜〞/u, + /** + * Currency symbols break before, not after + * + * Price¥100 → ["Price", "¥100"] + * ↑ BREAK BEFORE "¥" + */ + CURRENCY: /¥₩£¢$/u, +}; + +const EMOJI = { + FLAG: /\p{RI}\p{RI}/u, + JOINER: + /(?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?/u, + ZWJ: /\u200D/u, + ANY: /[\p{Emoji}]/u, + MOST: /[\p{Extended_Pictographic}\p{Emoji_Presentation}]/u, +}; + +/** + * Simple fallback for browsers (mainly Safari < 16.4) that don't support "Lookbehind assertion". + * + * Browser support as of 10/2024: + * - 91% Lookbehind assertion https://caniuse.com/mdn-javascript_regular_expressions_lookbehind_assertion + * - 94% Unicode character class escape https://caniuse.com/mdn-javascript_regular_expressions_unicode_character_class_escape + * + * Does not include advanced CJK breaking rules, but covers most of the core cases, especially for latin. + */ +const getLineBreakRegexSimple = () => + Regex.or( + getEmojiRegex(), + Break.On(COMMON.HYPHEN, COMMON.WHITESPACE, CJK.CHAR), + ); + +/** + * Specifies the line breaking rules based for alphabetic-based languages, + * Chinese, Japanese, Korean and Emojis. + * + * "Hello-world" → ["Hello-", "world"] + * "Hello 「世界。」🌎🗺" → ["Hello", " ", "「世", "界。」", "🌎", "🗺"] + */ +const getLineBreakRegexAdvanced = () => + Regex.or( + // Unicode-defined regex for (multi-codepoint) Emojis + getEmojiRegex(), + // Rules for whitespace and hyphen + Break.Before(COMMON.WHITESPACE).Build(), + Break.After(COMMON.WHITESPACE, COMMON.HYPHEN).Build(), + // Rules for CJK (chars, symbols, currency) + Break.Before(CJK.CHAR, CJK.CURRENCY) + .NotPrecededBy(COMMON.OPENING, CJK.OPENING) + .Build(), + Break.After(CJK.CHAR) + .NotFollowedBy(COMMON.HYPHEN, COMMON.CLOSING, CJK.CLOSING) + .Build(), + // Rules for opening and closing punctuation + Break.BeforeMany(CJK.OPENING).NotPrecededBy(COMMON.OPENING).Build(), + Break.AfterMany(CJK.CLOSING).NotFollowedBy(COMMON.CLOSING).Build(), + Break.AfterMany(COMMON.CLOSING).FollowedBy(COMMON.OPENING).Build(), + ); + +/** + * Matches various emoji types. + * + * 1. basic emojis (😀, 🌍) + * 2. flags (🇨🇿) + * 3. multi-codepoint emojis: + * - skin tones (👍🏽) + * - variation selectors (☂️) + * - keycaps (1️⃣) + * - tag sequences (🏴) + * - emoji sequences (👨👩👧👦, 👩🚀, 🏳️🌈) + * + * Unicode points: + * - \uFE0F: presentation selector + * - \u20E3: enclosing keycap + * - \u200D: zero width joiner + * - \u{E0020}-\u{E007E}: tags + * - \u{E007F}: cancel tag + * + * @see https://unicode.org/reports/tr51/#EBNF_and_Regex, with changes: + * - replaced \p{Emoji} with [\p{Extended_Pictographic}\p{Emoji_Presentation}], see more in `should tokenize emojis mixed with mixed text` test + * - replaced \p{Emod} with \p{Emoji_Modifier} as some engines do not understand the abbreviation (i.e. https://devina.io/redos-checker) + */ +const getEmojiRegexUnicode = () => + Regex.group( + Regex.or( + EMOJI.FLAG, + Regex.and( + EMOJI.MOST, + EMOJI.JOINER, + Regex.build( + `(?:${EMOJI.ZWJ.source}(?:${EMOJI.FLAG.source}|${EMOJI.ANY.source}${EMOJI.JOINER.source}))*`, + ), + ), + ), + ); + +/** + * Regex utilities for unicode character classes. + */ +const Regex = { + /** + * Builds a regex from a string. + */ + build: (regex: string): RegExp => new RegExp(regex, "u"), + /** + * Joins regexes into a single string. + */ + join: (...regexes: RegExp[]): string => regexes.map((x) => x.source).join(""), + /** + * Joins regexes into a single regex as with "and" operator. + */ + and: (...regexes: RegExp[]): RegExp => Regex.build(Regex.join(...regexes)), + /** + * Joins regexes into a single regex with "or" operator. + */ + or: (...regexes: RegExp[]): RegExp => + Regex.build(regexes.map((x) => x.source).join("|")), + /** + * Puts regexes into a matching group. + */ + group: (...regexes: RegExp[]): RegExp => + Regex.build(`(${Regex.join(...regexes)})`), + /** + * Puts regexes into a character class. + */ + class: (...regexes: RegExp[]): RegExp => + Regex.build(`[${Regex.join(...regexes)}]`), +}; + +/** + * Human-readable lookahead and lookbehind utilities for defining line break + * opportunities between pairs of character classes. + */ +const Break = { + /** + * Break on the given class of characters. + */ + On: (...regexes: RegExp[]) => { + const joined = Regex.join(...regexes); + return Regex.build(`([${joined}])`); + }, + /** + * Break before the given class of characters. + */ + Before: (...regexes: RegExp[]) => { + const joined = Regex.join(...regexes); + const builder = () => Regex.build(`(?=[${joined}])`); + return Break.Chain(builder) as Omit< + ReturnType<typeof Break.Chain>, + "FollowedBy" + >; + }, + /** + * Break after the given class of characters. + */ + After: (...regexes: RegExp[]) => { + const joined = Regex.join(...regexes); + const builder = () => Regex.build(`(?<=[${joined}])`); + return Break.Chain(builder) as Omit< + ReturnType<typeof Break.Chain>, + "PreceededBy" + >; + }, + /** + * Break before one or multiple characters of the same class. + */ + BeforeMany: (...regexes: RegExp[]) => { + const joined = Regex.join(...regexes); + const builder = () => Regex.build(`(?<![${joined}])(?=[${joined}])`); + return Break.Chain(builder) as Omit< + ReturnType<typeof Break.Chain>, + "FollowedBy" + >; + }, + /** + * Break after one or multiple character from the same class. + */ + AfterMany: (...regexes: RegExp[]) => { + const joined = Regex.join(...regexes); + const builder = () => Regex.build(`(?<=[${joined}])(?![${joined}])`); + return Break.Chain(builder) as Omit< + ReturnType<typeof Break.Chain>, + "PreceededBy" + >; + }, + /** + * Do not break before the given class of characters. + */ + NotBefore: (...regexes: RegExp[]) => { + const joined = Regex.join(...regexes); + const builder = () => Regex.build(`(?![${joined}])`); + return Break.Chain(builder) as Omit< + ReturnType<typeof Break.Chain>, + "NotFollowedBy" + >; + }, + /** + * Do not break after the given class of characters. + */ + NotAfter: (...regexes: RegExp[]) => { + const joined = Regex.join(...regexes); + const builder = () => Regex.build(`(?<![${joined}])`); + return Break.Chain(builder) as Omit< + ReturnType<typeof Break.Chain>, + "NotPrecededBy" + >; + }, + Chain: (rootBuilder: () => RegExp) => ({ + /** + * Build the root regex. + */ + Build: rootBuilder, + /** + * Specify additional class of characters that should precede the root regex. + */ + PreceededBy: (...regexes: RegExp[]) => { + const root = rootBuilder(); + const preceeded = Break.After(...regexes).Build(); + const builder = () => Regex.and(preceeded, root); + return Break.Chain(builder) as Omit< + ReturnType<typeof Break.Chain>, + "PreceededBy" + >; + }, + /** + * Specify additional class of characters that should follow the root regex. + */ + FollowedBy: (...regexes: RegExp[]) => { + const root = rootBuilder(); + const followed = Break.Before(...regexes).Build(); + const builder = () => Regex.and(root, followed); + return Break.Chain(builder) as Omit< + ReturnType<typeof Break.Chain>, + "FollowedBy" + >; + }, + /** + * Specify additional class of characters that should not precede the root regex. + */ + NotPrecededBy: (...regexes: RegExp[]) => { + const root = rootBuilder(); + const notPreceeded = Break.NotAfter(...regexes).Build(); + const builder = () => Regex.and(notPreceeded, root); + return Break.Chain(builder) as Omit< + ReturnType<typeof Break.Chain>, + "NotPrecededBy" + >; + }, + /** + * Specify additional class of characters that should not follow the root regex. + */ + NotFollowedBy: (...regexes: RegExp[]) => { + const root = rootBuilder(); + const notFollowed = Break.NotBefore(...regexes).Build(); + const builder = () => Regex.and(root, notFollowed); + return Break.Chain(builder) as Omit< + ReturnType<typeof Break.Chain>, + "NotFollowedBy" + >; + }, + }), +}; + +/** + * Breaks the line into the tokens based on the found line break opporutnities. + */ +export const parseTokens = (line: string) => { + const breakLineRegex = getLineBreakRegex(); + + // normalizing to single-codepoint composed chars due to canonical equivalence + // of multi-codepoint versions for chars like č, で (~ so that we don't break a line in between c and ˇ) + // filtering due to multi-codepoint chars like 👨👩👧👦, 👩🏽🦰 + return line.normalize("NFC").split(breakLineRegex).filter(Boolean); +}; + +/** + * Wraps the original text into the lines based on the given width. + */ +export const wrapText = ( + text: string, + font: FontString, + maxWidth: number, +): string => { + // if maxWidth is not finite or NaN which can happen in case of bugs in + // computation, we need to make sure we don't continue as we'll end up + // in an infinite loop + if (!Number.isFinite(maxWidth) || maxWidth < 0) { + return text; + } + + const lines: Array<string> = []; + const originalLines = text.split("\n"); + + for (const originalLine of originalLines) { + const currentLineWidth = getLineWidth(originalLine, font); + + if (currentLineWidth <= maxWidth) { + lines.push(originalLine); + continue; + } + + const wrappedLine = wrapLine(originalLine, font, maxWidth); + lines.push(...wrappedLine); + } + + return lines.join("\n"); +}; + +/** + * Wraps the original line into the lines based on the given width. + */ +const wrapLine = ( + line: string, + font: FontString, + maxWidth: number, +): string[] => { + const lines: Array<string> = []; + const tokens = parseTokens(line); + const tokenIterator = tokens[Symbol.iterator](); + + let currentLine = ""; + let currentLineWidth = 0; + + let iterator = tokenIterator.next(); + + while (!iterator.done) { + const token = iterator.value; + const testLine = currentLine + token; + + // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here + const testLineWidth = isSingleCharacter(token) + ? currentLineWidth + charWidth.calculate(token, font) + : getLineWidth(testLine, font); + + // build up the current line, skipping length check for possibly trailing whitespaces + if (/\s/.test(token) || testLineWidth <= maxWidth) { + currentLine = testLine; + currentLineWidth = testLineWidth; + iterator = tokenIterator.next(); + continue; + } + + // current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped + if (!currentLine) { + const wrappedWord = wrapWord(token, font, maxWidth); + const trailingLine = wrappedWord[wrappedWord.length - 1] ?? ""; + const precedingLines = wrappedWord.slice(0, -1); + + lines.push(...precedingLines); + + // trailing line of the wrapped word might still be joined with next token/s + currentLine = trailingLine; + currentLineWidth = getLineWidth(trailingLine, font); + iterator = tokenIterator.next(); + } else { + // push & reset, but don't iterate on the next token, as we didn't use it yet! + lines.push(currentLine.trimEnd()); + + // purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above + currentLine = ""; + currentLineWidth = 0; + } + } + + // iterator done, push the trailing line if exists + if (currentLine) { + const trailingLine = trimLine(currentLine, font, maxWidth); + lines.push(trailingLine); + } + + return lines; +}; + +/** + * Wraps the word into the lines based on the given width. + */ +const wrapWord = ( + word: string, + font: FontString, + maxWidth: number, +): Array<string> => { + // multi-codepoint emojis are already broken apart and shouldn't be broken further + if (getEmojiRegex().test(word)) { + return [word]; + } + + satisfiesWordInvariant(word); + + const lines: Array<string> = []; + const chars = Array.from(word); + + let currentLine = ""; + let currentLineWidth = 0; + + for (const char of chars) { + const _charWidth = charWidth.calculate(char, font); + const testLineWidth = currentLineWidth + _charWidth; + + if (testLineWidth <= maxWidth) { + currentLine = currentLine + char; + currentLineWidth = testLineWidth; + continue; + } + + if (currentLine) { + lines.push(currentLine); + } + + currentLine = char; + currentLineWidth = _charWidth; + } + + if (currentLine) { + lines.push(currentLine); + } + + return lines; +}; + +/** + * Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`. + */ +const trimLine = (line: string, font: FontString, maxWidth: number) => { + const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth; + + if (!shouldTrimWhitespaces) { + return line; + } + + // defensively default to `trimeEnd` in case the regex does not match + let [, trimmedLine, whitespaces] = line.match(/^(.+?)(\s+)$/) ?? [ + line, + line.trimEnd(), + "", + ]; + + let trimmedLineWidth = getLineWidth(trimmedLine, font); + + for (const whitespace of Array.from(whitespaces)) { + const _charWidth = charWidth.calculate(whitespace, font); + const testLineWidth = trimmedLineWidth + _charWidth; + + if (testLineWidth > maxWidth) { + break; + } + + trimmedLine = trimmedLine + whitespace; + trimmedLineWidth = testLineWidth; + } + + return trimmedLine; +}; + +/** + * Check if the given string is a single character. + * + * Handles multi-byte chars (é, 中) and purposefully does not handle multi-codepoint char (👨👩👧👦, 👩🏽🦰). + */ +const isSingleCharacter = (maybeSingleCharacter: string) => { + return ( + maybeSingleCharacter.codePointAt(0) !== undefined && + maybeSingleCharacter.codePointAt(1) === undefined + ); +}; + +/** + * Invariant for the word wrapping algorithm. + */ +const satisfiesWordInvariant = (word: string) => { + if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { + if (/\s/.test(word)) { + throw new Error("Word should not contain any whitespaces!"); + } + } +}; diff --git a/packages/excalidraw/element/textWysiwyg.test.tsx b/packages/excalidraw/element/textWysiwyg.test.tsx new file mode 100644 index 0000000..0842e30 --- /dev/null +++ b/packages/excalidraw/element/textWysiwyg.test.tsx @@ -0,0 +1,1565 @@ +import React from "react"; +import { Excalidraw } from "../index"; +import { + GlobalTestState, + render, + screen, + unmountComponent, +} from "../tests/test-utils"; +import { Keyboard, Pointer, UI } from "../tests/helpers/ui"; +import { CODES, KEYS } from "../keys"; +import { + fireEvent, + mockBoundingClientRect, + restoreOriginalGetBoundingClientRect, +} from "../tests/test-utils"; +import { queryByText } from "@testing-library/react"; + +import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants"; +import type { + ExcalidrawTextElement, + ExcalidrawTextElementWithContainer, +} from "./types"; +import { API } from "../tests/helpers/api"; +import { getOriginalContainerHeightFromCache } from "./containerCache"; +import { getTextEditor, updateTextEditor } from "../tests/queries/dom"; +import { pointFrom } from "@excalidraw/math"; + +unmountComponent(); + +const tab = " "; +const mouse = new Pointer("mouse"); + +const textEditorSelector = ".excalidraw-textEditorContainer > textarea"; + +describe("textWysiwyg", () => { + describe("start text editing", () => { + const { h } = window; + beforeEach(async () => { + await render(<Excalidraw handleKeyboardGlobally={true} />); + API.setElements([]); + }); + + it("should prefer editing selected text element (non-bindable container present)", async () => { + const line = API.createElement({ + type: "line", + width: 100, + height: 0, + points: [pointFrom(0, 0), pointFrom(100, 0)], + }); + const textSize = 20; + const text = API.createElement({ + type: "text", + text: "ola", + x: line.width / 2 - textSize / 2, + y: -textSize / 2, + width: textSize, + height: textSize, + }); + API.setElements([text, line]); + + API.setSelectedElements([text]); + + Keyboard.keyPress(KEYS.ENTER); + + expect(h.state.editingTextElement?.id).toBe(text.id); + expect( + (h.state.editingTextElement as ExcalidrawTextElement).containerId, + ).toBe(null); + }); + + it("should prefer editing selected text element (bindable container present)", async () => { + const container = API.createElement({ + type: "rectangle", + width: 100, + boundElements: [], + }); + const textSize = 20; + + const boundText = API.createElement({ + type: "text", + text: "ola", + x: container.width / 2 - textSize / 2, + y: container.height / 2 - textSize / 2, + width: textSize, + height: textSize, + containerId: container.id, + }); + + const boundText2 = API.createElement({ + type: "text", + text: "ola", + x: container.width / 2 - textSize / 2, + y: container.height / 2 - textSize / 2, + width: textSize, + height: textSize, + containerId: container.id, + }); + + API.setElements([container, boundText, boundText2]); + + API.updateElement(container, { + boundElements: [{ type: "text", id: boundText.id }], + }); + + API.setSelectedElements([boundText2]); + + Keyboard.keyPress(KEYS.ENTER); + + expect(h.state.editingTextElement?.id).toBe(boundText2.id); + }); + + it("should not create bound text on ENTER if text exists at container center", () => { + const container = API.createElement({ + type: "rectangle", + width: 100, + }); + const textSize = 20; + const text = API.createElement({ + type: "text", + text: "ola", + x: container.width / 2 - textSize / 2, + y: container.height / 2 - textSize / 2, + width: textSize, + height: textSize, + containerId: container.id, + }); + API.updateElement(container, { + boundElements: [{ type: "text", id: text.id }], + }); + + API.setElements([container, text]); + + API.setSelectedElements([container]); + + Keyboard.keyPress(KEYS.ENTER); + + expect(h.state.editingTextElement?.id).toBe(text.id); + }); + + it("should edit existing bound text on ENTER even if higher z-index unbound text exists at container center", () => { + const container = API.createElement({ + type: "rectangle", + width: 100, + boundElements: [], + }); + const textSize = 20; + + const boundText = API.createElement({ + type: "text", + text: "ola", + x: container.width / 2 - textSize / 2, + y: container.height / 2 - textSize / 2, + width: textSize, + height: textSize, + containerId: container.id, + }); + + const boundText2 = API.createElement({ + type: "text", + text: "ola", + x: container.width / 2 - textSize / 2, + y: container.height / 2 - textSize / 2, + width: textSize, + height: textSize, + containerId: container.id, + }); + + API.setElements([container, boundText, boundText2]); + + API.updateElement(container, { + boundElements: [{ type: "text", id: boundText.id }], + }); + + API.setSelectedElements([container]); + + Keyboard.keyPress(KEYS.ENTER); + + expect(h.state.editingTextElement?.id).toBe(boundText.id); + }); + + it("should edit text under cursor when clicked with text tool", async () => { + const text = API.createElement({ + type: "text", + text: "ola", + x: 60, + y: 0, + width: 100, + height: 100, + }); + + API.setElements([text]); + UI.clickTool("text"); + + mouse.clickAt(text.x + 50, text.y + 50); + + const editor = await getTextEditor(textEditorSelector, false); + + expect(editor).not.toBe(null); + expect(h.state.editingTextElement?.id).toBe(text.id); + expect(h.elements.length).toBe(1); + }); + + it("should edit text under cursor when double-clicked with selection tool", async () => { + const text = API.createElement({ + type: "text", + text: "ola", + x: 60, + y: 0, + width: 100, + height: 100, + }); + + API.setElements([text]); + UI.clickTool("selection"); + + mouse.doubleClickAt(text.x + 50, text.y + 50); + + const editor = await getTextEditor(textEditorSelector, false); + + expect(editor).not.toBe(null); + expect(h.state.editingTextElement?.id).toBe(text.id); + expect(h.elements.length).toBe(1); + }); + + // FIXME too flaky. No one knows why. + it.skip("should bump the version of a labeled arrow when the label is updated", async () => { + const arrow = UI.createElement("arrow", { + width: 300, + height: 0, + }); + await UI.editText(arrow, "Hello"); + const { version } = arrow; + + await UI.editText(arrow, "Hello\nworld!"); + + expect(arrow.version).toEqual(version + 1); + }); + }); + + describe("Test text wrapping", () => { + const { h } = window; + const dimensions = { height: 400, width: 800 }; + + beforeAll(() => { + mockBoundingClientRect(dimensions); + }); + + beforeEach(async () => { + await render(<Excalidraw handleKeyboardGlobally={true} />); + // @ts-ignore + h.app.refreshViewportBreakpoints(); + // @ts-ignore + h.app.refreshEditorBreakpoints(); + + API.setElements([]); + }); + + afterAll(() => { + restoreOriginalGetBoundingClientRect(); + }); + + it("should keep width when editing a wrapped text", async () => { + const text = API.createElement({ + type: "text", + text: "Excalidraw\nEditor", + }); + + API.setElements([text]); + + const prevWidth = text.width; + const prevHeight = text.height; + const prevText = text.text; + + // text is wrapped + UI.resize(text, "e", [-20, 0]); + expect(text.width).not.toEqual(prevWidth); + expect(text.height).not.toEqual(prevHeight); + expect(text.text).not.toEqual(prevText); + expect(text.autoResize).toBe(false); + + const wrappedWidth = text.width; + const wrappedHeight = text.height; + const wrappedText = text.text; + + // edit text + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + const editor = await getTextEditor(textEditorSelector); + expect(editor).not.toBe(null); + expect(h.state.editingTextElement?.id).toBe(text.id); + expect(h.elements.length).toBe(1); + + const nextText = `${wrappedText} is great!`; + updateTextEditor(editor, nextText); + Keyboard.exitTextEditor(editor); + + expect(h.elements[0].width).toEqual(wrappedWidth); + expect(h.elements[0].height).toBeGreaterThan(wrappedHeight); + + // remove all texts and then add it back editing + updateTextEditor(editor, ""); + updateTextEditor(editor, nextText); + Keyboard.exitTextEditor(editor); + + expect(h.elements[0].width).toEqual(wrappedWidth); + }); + + it("should restore original text after unwrapping a wrapped text", async () => { + const originalText = "Excalidraw\neditor\nis great!"; + const text = API.createElement({ + type: "text", + text: originalText, + }); + API.setElements([text]); + + // wrap + UI.resize(text, "e", [-40, 0]); + // enter text editing mode + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + const editor = await getTextEditor(textEditorSelector); + Keyboard.exitTextEditor(editor); + // restore after unwrapping + UI.resize(text, "e", [40, 0]); + expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText); + + // wrap again and add a new line + UI.resize(text, "e", [-30, 0]); + const wrappedText = text.text; + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + updateTextEditor(editor, `${wrappedText}\nA new line!`); + Keyboard.exitTextEditor(editor); + // remove the newly added line + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + updateTextEditor(editor, wrappedText); + Keyboard.exitTextEditor(editor); + // unwrap + UI.resize(text, "e", [30, 0]); + // expect the text to be restored the same + expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText); + }); + }); + + describe("Test container-unbound text", () => { + const { h } = window; + const dimensions = { height: 400, width: 800 }; + + let textarea: HTMLTextAreaElement; + let textElement: ExcalidrawTextElement; + + beforeAll(() => { + mockBoundingClientRect(dimensions); + }); + + beforeEach(async () => { + await render(<Excalidraw handleKeyboardGlobally={true} />); + // @ts-ignore + h.app.refreshViewportBreakpoints(); + // @ts-ignore + h.app.refreshEditorBreakpoints(); + + textElement = UI.createElement("text"); + + mouse.clickOn(textElement); + textarea = await getTextEditor(textEditorSelector, true); + }); + + afterAll(() => { + restoreOriginalGetBoundingClientRect(); + }); + + it("should add a tab at the start of the first line", () => { + textarea.value = "Line#1\nLine#2"; + // cursor: "|Line#1\nLine#2" + textarea.selectionStart = 0; + textarea.selectionEnd = 0; + fireEvent.keyDown(textarea, { key: KEYS.TAB }); + + expect(textarea.value).toEqual(`${tab}Line#1\nLine#2`); + // cursor: " |Line#1\nLine#2" + expect(textarea.selectionStart).toEqual(4); + expect(textarea.selectionEnd).toEqual(4); + }); + + it("should add a tab at the start of the second line", () => { + textarea.value = "Line#1\nLine#2"; + // cursor: "Line#1\nLin|e#2" + textarea.selectionStart = 10; + textarea.selectionEnd = 10; + + fireEvent.keyDown(textarea, { key: KEYS.TAB }); + + expect(textarea.value).toEqual(`Line#1\n${tab}Line#2`); + + // cursor: "Line#1\n Lin|e#2" + expect(textarea.selectionStart).toEqual(14); + expect(textarea.selectionEnd).toEqual(14); + }); + + it("should add a tab at the start of the first and second line", () => { + textarea.value = "Line#1\nLine#2\nLine#3"; + // cursor: "Li|ne#1\nLi|ne#2\nLine#3" + textarea.selectionStart = 2; + textarea.selectionEnd = 9; + + fireEvent.keyDown(textarea, { key: KEYS.TAB }); + + expect(textarea.value).toEqual(`${tab}Line#1\n${tab}Line#2\nLine#3`); + + // cursor: " Li|ne#1\n Li|ne#2\nLine#3" + expect(textarea.selectionStart).toEqual(6); + expect(textarea.selectionEnd).toEqual(17); + }); + + it("should remove a tab at the start of the first line", () => { + textarea.value = `${tab}Line#1\nLine#2`; + // cursor: "| Line#1\nLine#2" + textarea.selectionStart = 0; + textarea.selectionEnd = 0; + + fireEvent.keyDown(textarea, { + key: KEYS.TAB, + shiftKey: true, + }); + + expect(textarea.value).toEqual(`Line#1\nLine#2`); + + // cursor: "|Line#1\nLine#2" + expect(textarea.selectionStart).toEqual(0); + expect(textarea.selectionEnd).toEqual(0); + }); + + it("should remove a tab at the start of the second line", () => { + // cursor: "Line#1\n Lin|e#2" + textarea.value = `Line#1\n${tab}Line#2`; + textarea.selectionStart = 15; + textarea.selectionEnd = 15; + + fireEvent.keyDown(textarea, { + key: KEYS.TAB, + shiftKey: true, + }); + + expect(textarea.value).toEqual(`Line#1\nLine#2`); + // cursor: "Line#1\nLin|e#2" + expect(textarea.selectionStart).toEqual(11); + expect(textarea.selectionEnd).toEqual(11); + }); + + it("should remove a tab at the start of the first and second line", () => { + // cursor: " Li|ne#1\n Li|ne#2\nLine#3" + textarea.value = `${tab}Line#1\n${tab}Line#2\nLine#3`; + textarea.selectionStart = 6; + textarea.selectionEnd = 17; + + fireEvent.keyDown(textarea, { + key: KEYS.TAB, + shiftKey: true, + }); + + expect(textarea.value).toEqual(`Line#1\nLine#2\nLine#3`); + // cursor: "Li|ne#1\nLi|ne#2\nLine#3" + expect(textarea.selectionStart).toEqual(2); + expect(textarea.selectionEnd).toEqual(9); + }); + + it("should remove a tab at the start of the second line and cursor stay on this line", () => { + // cursor: "Line#1\n | Line#2" + textarea.value = `Line#1\n${tab}Line#2`; + textarea.selectionStart = 9; + textarea.selectionEnd = 9; + fireEvent.keyDown(textarea, { + key: KEYS.TAB, + shiftKey: true, + }); + + // cursor: "Line#1\n|Line#2" + expect(textarea.selectionStart).toEqual(7); + }); + + it("should remove partial tabs", () => { + // cursor: "Line#1\n Line#|2" + textarea.value = `Line#1\n Line#2`; + textarea.selectionStart = 15; + textarea.selectionEnd = 15; + fireEvent.keyDown(textarea, { + key: KEYS.TAB, + shiftKey: true, + }); + + expect(textarea.value).toEqual(`Line#1\nLine#2`); + }); + + it("should remove nothing", () => { + // cursor: "Line#1\n Li|ne#2" + textarea.value = `Line#1\nLine#2`; + textarea.selectionStart = 9; + textarea.selectionEnd = 9; + fireEvent.keyDown(textarea, { + key: KEYS.TAB, + shiftKey: true, + }); + + expect(textarea.value).toEqual(`Line#1\nLine#2`); + }); + + it("should resize text via shortcuts while in wysiwyg", () => { + textarea.value = "abc def"; + const origFontSize = textElement.fontSize; + fireEvent.keyDown(textarea, { + key: KEYS.CHEVRON_RIGHT, + ctrlKey: true, + shiftKey: true, + }); + expect(textElement.fontSize).toBe(origFontSize * 1.1); + + fireEvent.keyDown(textarea, { + key: KEYS.CHEVRON_LEFT, + ctrlKey: true, + shiftKey: true, + }); + expect(textElement.fontSize).toBe(origFontSize); + }); + + it("zooming via keyboard should zoom canvas", () => { + expect(h.state.zoom.value).toBe(1); + fireEvent.keyDown(textarea, { + code: CODES.MINUS, + ctrlKey: true, + }); + expect(h.state.zoom.value).toBe(0.9); + fireEvent.keyDown(textarea, { + code: CODES.NUM_SUBTRACT, + ctrlKey: true, + }); + expect(h.state.zoom.value).toBe(0.8); + fireEvent.keyDown(textarea, { + code: CODES.NUM_ADD, + ctrlKey: true, + }); + expect(h.state.zoom.value).toBe(0.9); + fireEvent.keyDown(textarea, { + code: CODES.EQUAL, + ctrlKey: true, + }); + expect(h.state.zoom.value).toBe(1); + }); + + it("text should never go beyond max width", async () => { + UI.clickTool("text"); + mouse.click(0, 0); + + textarea = await getTextEditor(textEditorSelector, true); + updateTextEditor( + textarea, + "Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!", + ); + Keyboard.exitTextEditor(textarea); + + expect(textarea.style.width).toBe("792px"); + expect(h.elements[0].width).toBe(1000); + }); + }); + + describe("Test container-bound text", () => { + let rectangle: any; + const { h } = window; + + beforeEach(async () => { + await render(<Excalidraw handleKeyboardGlobally={true} />); + API.setElements([]); + + rectangle = UI.createElement("rectangle", { + x: 10, + y: 20, + width: 90, + height: 75, + }); + }); + + it("should bind text to container when double clicked inside filled container", async () => { + const rectangle = API.createElement({ + type: "rectangle", + x: 10, + y: 20, + width: 90, + height: 75, + backgroundColor: "red", + }); + API.setElements([rectangle]); + + expect(h.elements.length).toBe(1); + expect(h.elements[0].id).toBe(rectangle.id); + + mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10); + expect(h.elements.length).toBe(2); + + const text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.type).toBe("text"); + expect(text.containerId).toBe(rectangle.id); + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + mouse.down(); + const editor = await getTextEditor(textEditorSelector, true); + + updateTextEditor(editor, "Hello World!"); + + Keyboard.exitTextEditor(editor); + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + }); + + it("should set the text element angle to same as container angle when binding to rotated container", async () => { + const rectangle = API.createElement({ + type: "rectangle", + width: 90, + height: 75, + angle: 45, + }); + API.setElements([rectangle]); + mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10); + const text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.type).toBe("text"); + expect(text.containerId).toBe(rectangle.id); + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + expect(text.angle).toBe(rectangle.angle); + mouse.down(); + const editor = await getTextEditor(textEditorSelector, true); + + updateTextEditor(editor, "Hello World!"); + + Keyboard.exitTextEditor(editor); + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + }); + + it("should compute the container height correctly and not throw error when height is updated while editing the text", async () => { + const diamond = API.createElement({ + type: "diamond", + x: 10, + y: 20, + width: 90, + height: 75, + }); + API.setElements([diamond]); + + expect(h.elements.length).toBe(1); + expect(h.elements[0].id).toBe(diamond.id); + + API.setSelectedElements([diamond]); + Keyboard.keyPress(KEYS.ENTER); + + const editor = await getTextEditor(textEditorSelector, true); + + const value = new Array(1000).fill("1").join("\n"); + + // Pasting large text to simulate height increase + expect(() => + fireEvent.input(editor, { target: { value } }), + ).not.toThrow(); + + expect(diamond.height).toBe(50020); + + // Clearing text to simulate height decrease + expect(() => updateTextEditor(editor, "")).not.toThrow(); + + expect(diamond.height).toBe(70); + }); + + it("should bind text to container when double clicked on center of transparent container", async () => { + const rectangle = API.createElement({ + type: "rectangle", + x: 10, + y: 20, + width: 90, + height: 75, + backgroundColor: "transparent", + }); + API.setElements([rectangle]); + + mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10); + expect(h.elements.length).toBe(2); + let text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.type).toBe("text"); + expect(text.containerId).toBe(null); + mouse.down(); + let editor = await getTextEditor(textEditorSelector, true); + Keyboard.exitTextEditor(editor); + + mouse.doubleClickAt( + rectangle.x + rectangle.width / 2, + rectangle.y + rectangle.height / 2, + ); + expect(h.elements.length).toBe(3); + + text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.type).toBe("text"); + expect(text.containerId).toBe(rectangle.id); + + mouse.down(); + editor = await getTextEditor(textEditorSelector, true); + + updateTextEditor(editor, "Hello World!"); + Keyboard.exitTextEditor(editor); + + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + }); + + it("should bind text to container when clicked on container and enter pressed", async () => { + expect(h.elements.length).toBe(1); + expect(h.elements[0].id).toBe(rectangle.id); + + Keyboard.keyPress(KEYS.ENTER); + + expect(h.elements.length).toBe(2); + + const text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.type).toBe("text"); + expect(text.containerId).toBe(rectangle.id); + const editor = await getTextEditor(textEditorSelector, true); + + updateTextEditor(editor, "Hello World!"); + Keyboard.exitTextEditor(editor); + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + }); + + it("should bind text to container when double clicked on container stroke", async () => { + const rectangle = API.createElement({ + type: "rectangle", + x: 10, + y: 20, + width: 90, + height: 75, + strokeWidth: 4, + }); + API.setElements([rectangle]); + + expect(h.elements.length).toBe(1); + expect(h.elements[0].id).toBe(rectangle.id); + + mouse.doubleClickAt(rectangle.x + 2, rectangle.y + 2); + expect(h.elements.length).toBe(2); + + const text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.type).toBe("text"); + expect(text.containerId).toBe(rectangle.id); + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + mouse.down(); + const editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello World!"); + + Keyboard.exitTextEditor(editor); + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + }); + + it("shouldn't bind to non-text-bindable containers", async () => { + const freedraw = API.createElement({ + type: "freedraw", + width: 100, + height: 0, + }); + API.setElements([freedraw]); + + UI.clickTool("text"); + + mouse.clickAt( + freedraw.x + freedraw.width / 2, + freedraw.y + freedraw.height / 2, + ); + + const editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello World!"); + Keyboard.exitTextEditor(editor); + + expect(freedraw.boundElements).toBe(null); + expect(h.elements[1].type).toBe("text"); + expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null); + }); + + ["freedraw", "line"].forEach((type: any) => { + it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => { + API.setElements([]); + const element = UI.createElement(type, { + width: 100, + height: 50, + }); + API.setSelectedElements([element]); + Keyboard.keyPress(KEYS.ENTER); + expect(h.elements.length).toBe(1); + }); + }); + + it("should'nt bind text to container when not double clicked on center", async () => { + expect(h.elements.length).toBe(1); + expect(h.elements[0].id).toBe(rectangle.id); + + // clicking somewhere on top left + mouse.doubleClickAt(rectangle.x + 20, rectangle.y + 20); + expect(h.elements.length).toBe(2); + + const text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.type).toBe("text"); + expect(text.containerId).toBe(null); + mouse.down(); + const editor = await getTextEditor(textEditorSelector, true); + + updateTextEditor(editor, "Hello World!"); + + Keyboard.exitTextEditor(editor); + expect(rectangle.boundElements).toBe(null); + }); + + it("should bind text to container when triggered via context menu", async () => { + expect(h.elements.length).toBe(1); + expect(h.elements[0].id).toBe(rectangle.id); + + UI.clickTool("text"); + mouse.clickAt(20, 30); + const editor = await getTextEditor(textEditorSelector, true); + + updateTextEditor( + editor, + "Excalidraw is an opensource virtual collaborative whiteboard", + ); + expect(h.elements.length).toBe(2); + expect(h.elements[1].type).toBe("text"); + + API.setSelectedElements([h.elements[0], h.elements[1]]); + fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { + button: 2, + clientX: 20, + clientY: 30, + }); + const contextMenu = document.querySelector(".context-menu"); + fireEvent.click( + queryByText(contextMenu as HTMLElement, "Bind text to the container")!, + ); + const text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(rectangle.boundElements).toStrictEqual([ + { id: h.elements[1].id, type: "text" }, + ]); + expect(text.containerId).toBe(rectangle.id); + expect(text.verticalAlign).toBe(VERTICAL_ALIGN.MIDDLE); + expect(text.textAlign).toBe(TEXT_ALIGN.CENTER); + expect(text.x).toBe( + h.elements[0].x + h.elements[0].width / 2 - text.width / 2, + ); + expect(text.y).toBe( + h.elements[0].y + h.elements[0].height / 2 - text.height / 2, + ); + }); + + it("should update font family correctly on undo/redo by selecting bounded text when font family was updated", async () => { + expect(h.elements.length).toBe(1); + + mouse.doubleClickAt( + rectangle.x + rectangle.width / 2, + rectangle.y + rectangle.height / 2, + ); + + const text = h.elements[1] as ExcalidrawTextElementWithContainer; + const editor = await getTextEditor(textEditorSelector, true); + + updateTextEditor(editor, "Hello World!"); + + Keyboard.exitTextEditor(editor); + + expect(await getTextEditor(textEditorSelector, false)).toBe(null); + + expect(h.state.editingTextElement).toBe(null); + + expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont); + + fireEvent.click(screen.getByTitle(/code/i)); + + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, + ).toEqual(FONT_FAMILY["Comic Shanns"]); + + //undo + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.Z); + }); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, + ).toEqual(FONT_FAMILY.Excalifont); + + //redo + Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { + Keyboard.keyPress(KEYS.Z); + }); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, + ).toEqual(FONT_FAMILY["Comic Shanns"]); + }); + + it("should wrap text and vertcially center align once text submitted", async () => { + expect(h.elements.length).toBe(1); + + Keyboard.keyDown(KEYS.ENTER); + let text = h.elements[1] as ExcalidrawTextElementWithContainer; + let editor = await getTextEditor(textEditorSelector, true); + + updateTextEditor(editor, "Hello World!"); + + Keyboard.exitTextEditor(editor); + text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.text).toBe("Hello\nWorld!"); + expect(text.originalText).toBe("Hello World!"); + expect(text.y).toBe( + rectangle.y + h.elements[0].height / 2 - text.height / 2, + ); + expect(text.x).toBe(25); + expect(text.height).toBe(50); + expect(text.width).toBe(60); + + // Edit and text by removing second line and it should + // still vertically align correctly + mouse.select(rectangle); + Keyboard.keyPress(KEYS.ENTER); + + editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello"); + + Keyboard.exitTextEditor(editor); + text = h.elements[1] as ExcalidrawTextElementWithContainer; + + expect(text.text).toBe("Hello"); + expect(text.originalText).toBe("Hello"); + expect(text.height).toBe(25); + expect(text.width).toBe(50); + expect(text.y).toBe( + rectangle.y + h.elements[0].height / 2 - text.height / 2, + ); + expect(text.x).toBe(30); + }); + + it("should unbind bound text when unbind action from context menu is triggered", async () => { + expect(h.elements.length).toBe(1); + expect(h.elements[0].id).toBe(rectangle.id); + + Keyboard.keyPress(KEYS.ENTER); + + expect(h.elements.length).toBe(2); + + const text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.containerId).toBe(rectangle.id); + + const editor = await getTextEditor(textEditorSelector, true); + + updateTextEditor(editor, "Hello World!"); + Keyboard.exitTextEditor(editor); + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + mouse.reset(); + UI.clickTool("selection"); + mouse.clickAt(10, 20); + mouse.down(); + mouse.up(); + fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { + button: 2, + clientX: 20, + clientY: 30, + }); + const contextMenu = document.querySelector(".context-menu"); + fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!); + expect(h.elements[0].boundElements).toEqual([]); + expect((h.elements[1] as ExcalidrawTextElement).containerId).toEqual( + null, + ); + }); + + it("shouldn't bind to container if container has bound text", async () => { + expect(h.elements.length).toBe(1); + + Keyboard.keyPress(KEYS.ENTER); + + expect(h.elements.length).toBe(2); + + // Bind first text + const text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.containerId).toBe(rectangle.id); + const editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello World!"); + Keyboard.exitTextEditor(editor); + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + + mouse.select(rectangle); + Keyboard.keyPress(KEYS.ENTER); + expect(h.elements.length).toBe(2); + + expect(rectangle.boundElements).toStrictEqual([ + { id: h.elements[1].id, type: "text" }, + ]); + expect(text.containerId).toBe(rectangle.id); + }); + + it("should respect text alignment when resizing", async () => { + Keyboard.keyPress(KEYS.ENTER); + + let editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello"); + Keyboard.exitTextEditor(editor); + + // should center align horizontally and vertically by default + UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); + expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` + [ + 85, + "5.00000", + ] + `); + + mouse.select(rectangle); + Keyboard.keyPress(KEYS.ENTER); + + editor = await getTextEditor(textEditorSelector, true); + + editor.select(); + + fireEvent.click(screen.getByTitle("Left")); + fireEvent.click(screen.getByTitle("Align bottom")); + Keyboard.exitTextEditor(editor); + + // should left align horizontally and bottom vertically after resize + UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); + expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` + [ + 15, + 65, + ] + `); + + mouse.select(rectangle); + Keyboard.keyPress(KEYS.ENTER); + editor = await getTextEditor(textEditorSelector, true); + + editor.select(); + + fireEvent.click(screen.getByTitle("Right")); + fireEvent.click(screen.getByTitle("Align top")); + + Keyboard.exitTextEditor(editor); + + // should right align horizontally and top vertically after resize + UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); + expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` + [ + "375.00000", + "-535.00000", + ] + `); + }); + + it("should always bind to selected container and insert it in correct position", async () => { + const rectangle2 = UI.createElement("rectangle", { + x: 5, + y: 10, + width: 120, + height: 100, + }); + + API.setSelectedElements([rectangle]); + Keyboard.keyPress(KEYS.ENTER); + + expect(h.elements.length).toBe(3); + expect(h.elements[1].type).toBe("text"); + const text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.type).toBe("text"); + expect(text.containerId).toBe(rectangle.id); + mouse.down(); + const editor = await getTextEditor(textEditorSelector, true); + + updateTextEditor(editor, "Hello World!"); + + Keyboard.exitTextEditor(editor); + expect(rectangle2.boundElements).toBeNull(); + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + }); + + it("should scale font size correctly when resizing using shift", async () => { + Keyboard.keyPress(KEYS.ENTER); + + const editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello"); + Keyboard.exitTextEditor(editor); + const textElement = h.elements[1] as ExcalidrawTextElement; + expect(rectangle.width).toBe(90); + expect(rectangle.height).toBe(75); + expect(textElement.fontSize).toBe(20); + + UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], { + shift: true, + }); + expect(rectangle.width).toBe(200); + expect(rectangle.height).toBe(166.66666666666669); + expect(textElement.fontSize).toBe(47.5); + }); + + it("should bind text correctly when container duplicated with alt-drag", async () => { + Keyboard.keyPress(KEYS.ENTER); + + const editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello"); + Keyboard.exitTextEditor(editor); + expect(h.elements.length).toBe(2); + + mouse.select(rectangle); + Keyboard.withModifierKeys({ alt: true }, () => { + mouse.down(rectangle.x + 10, rectangle.y + 10); + mouse.up(rectangle.x + 10, rectangle.y + 10); + }); + expect(h.elements.length).toBe(4); + const duplicatedRectangle = h.elements[0]; + const duplicatedText = h + .elements[1] as ExcalidrawTextElementWithContainer; + const originalRect = h.elements[2]; + const originalText = h.elements[3] as ExcalidrawTextElementWithContainer; + expect(originalRect.boundElements).toStrictEqual([ + { id: originalText.id, type: "text" }, + ]); + + expect(originalText.containerId).toBe(originalRect.id); + + expect(duplicatedRectangle.boundElements).toStrictEqual([ + { id: duplicatedText.id, type: "text" }, + ]); + + expect(duplicatedText.containerId).toBe(duplicatedRectangle.id); + }); + + it("undo should work", async () => { + Keyboard.keyPress(KEYS.ENTER); + const editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello"); + Keyboard.exitTextEditor(editor); + expect(rectangle.boundElements).toStrictEqual([ + { id: h.elements[1].id, type: "text" }, + ]); + let text = h.elements[1] as ExcalidrawTextElementWithContainer; + const originalRectX = rectangle.x; + const originalRectY = rectangle.y; + const originalTextX = text.x; + const originalTextY = text.y; + mouse.select(rectangle); + mouse.downAt(rectangle.x, rectangle.y); + mouse.moveTo(rectangle.x + 100, rectangle.y + 50); + mouse.up(rectangle.x + 100, rectangle.y + 50); + expect(rectangle.x).toBe(80); + expect(rectangle.y).toBe(-40); + expect(text.x).toBe(85); + expect(text.y).toBe(-35); + + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.Z); + }); + expect(rectangle.x).toBe(originalRectX); + expect(rectangle.y).toBe(originalRectY); + text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.x).toBe(originalTextX); + expect(text.y).toBe(originalTextY); + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + expect(text.containerId).toBe(rectangle.id); + }); + + it("should not allow bound text with only whitespaces", async () => { + Keyboard.keyPress(KEYS.ENTER); + const editor = await getTextEditor(textEditorSelector, true); + + updateTextEditor(editor, " "); + Keyboard.exitTextEditor(editor); + expect(rectangle.boundElements).toStrictEqual([]); + expect(h.elements[1].isDeleted).toBe(true); + }); + + it("should restore original container height and clear cache once text is unbind", async () => { + const container = API.createElement({ + type: "rectangle", + height: 75, + width: 90, + }); + const originalRectHeight = container.height; + expect(container.height).toBe(originalRectHeight); + + const text = API.createElement({ + type: "text", + text: "Online whiteboard collaboration made easy", + }); + + API.setElements([container, text]); + API.setSelectedElements([container, text]); + fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { + button: 2, + clientX: 20, + clientY: 30, + }); + let contextMenu = document.querySelector(".context-menu"); + + fireEvent.click( + queryByText(contextMenu as HTMLElement, "Bind text to the container")!, + ); + + expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe( + "Online\nwhiteboa\nrd\ncollabor\nation\nmade\neasy", + ); + fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { + button: 2, + clientX: 20, + clientY: 30, + }); + contextMenu = document.querySelector(".context-menu"); + fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!); + expect(h.elements[0].boundElements).toEqual([]); + expect(getOriginalContainerHeightFromCache(container.id)).toBe(null); + + expect(container.height).toBe(originalRectHeight); + }); + + it("should reset the container height cache when resizing", async () => { + Keyboard.keyPress(KEYS.ENTER); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); + let editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello"); + Keyboard.exitTextEditor(editor); + + UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); + expect(rectangle.height).toBeCloseTo(155, 8); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null); + + mouse.select(rectangle); + Keyboard.keyPress(KEYS.ENTER); + + editor = await getTextEditor(textEditorSelector, true); + + Keyboard.exitTextEditor(editor); + expect(rectangle.height).toBeCloseTo(155, 8); + // cache updated again + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBeCloseTo( + 155, + 8, + ); + }); + + it("should reset the container height cache when font properties updated", async () => { + Keyboard.keyPress(KEYS.ENTER); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); + + const editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello World!"); + Keyboard.exitTextEditor(editor); + + mouse.select(rectangle); + Keyboard.keyPress(KEYS.ENTER); + + fireEvent.click(screen.getByTitle(/code/i)); + + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, + ).toEqual(FONT_FAMILY["Comic Shanns"]); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); + + fireEvent.click(screen.getByTitle(/Very large/i)); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize, + ).toEqual(36); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(100); + }); + + it("should update line height when font family updated", async () => { + Keyboard.keyPress(KEYS.ENTER); + expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); + + const editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello World!"); + Keyboard.exitTextEditor(editor); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, + ).toEqual(1.25); + + mouse.select(rectangle); + Keyboard.keyPress(KEYS.ENTER); + + fireEvent.click(screen.getByTitle(/code/i)); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, + ).toEqual(FONT_FAMILY["Comic Shanns"]); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, + ).toEqual(1.25); + + fireEvent.click(screen.getByTitle(/normal/i)); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, + ).toEqual(FONT_FAMILY.Nunito); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, + ).toEqual(1.35); + }); + + describe("should align correctly", () => { + let editor: HTMLTextAreaElement; + + beforeEach(async () => { + Keyboard.keyPress(KEYS.ENTER); + editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello"); + Keyboard.exitTextEditor(editor); + mouse.select(rectangle); + Keyboard.keyPress(KEYS.ENTER); + editor = await getTextEditor(textEditorSelector, true); + editor.select(); + }); + + it("when top left", async () => { + fireEvent.click(screen.getByTitle("Left")); + fireEvent.click(screen.getByTitle("Align top")); + expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` + [ + 15, + 25, + ] + `); + }); + + it("when top center", async () => { + fireEvent.click(screen.getByTitle("Center")); + fireEvent.click(screen.getByTitle("Align top")); + expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` + [ + 30, + 25, + ] + `); + }); + + it("when top right", async () => { + fireEvent.click(screen.getByTitle("Right")); + fireEvent.click(screen.getByTitle("Align top")); + + expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` + [ + 45, + 25, + ] + `); + }); + + it("when center left", async () => { + fireEvent.click(screen.getByTitle("Center vertically")); + fireEvent.click(screen.getByTitle("Left")); + expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` + [ + 15, + 45, + ] + `); + }); + + it("when center center", async () => { + fireEvent.click(screen.getByTitle("Center")); + fireEvent.click(screen.getByTitle("Center vertically")); + + expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` + [ + 30, + 45, + ] + `); + }); + + it("when center right", async () => { + fireEvent.click(screen.getByTitle("Right")); + fireEvent.click(screen.getByTitle("Center vertically")); + + expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` + [ + 45, + 45, + ] + `); + }); + + it("when bottom left", async () => { + fireEvent.click(screen.getByTitle("Left")); + fireEvent.click(screen.getByTitle("Align bottom")); + + expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` + [ + 15, + 65, + ] + `); + }); + + it("when bottom center", async () => { + fireEvent.click(screen.getByTitle("Center")); + fireEvent.click(screen.getByTitle("Align bottom")); + expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` + [ + 30, + 65, + ] + `); + }); + + it("when bottom right", async () => { + fireEvent.click(screen.getByTitle("Right")); + fireEvent.click(screen.getByTitle("Align bottom")); + expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` + [ + 45, + 65, + ] + `); + }); + }); + + it("should wrap text in a container when wrap text in container triggered from context menu", async () => { + UI.clickTool("text"); + mouse.clickAt(20, 30); + const editor = await getTextEditor(textEditorSelector, true); + + updateTextEditor( + editor, + "Excalidraw is an opensource virtual collaborative whiteboard", + ); + + editor.select(); + fireEvent.click(screen.getByTitle("Left")); + + Keyboard.exitTextEditor(editor); + + const textElement = h.elements[1] as ExcalidrawTextElement; + expect(textElement.width).toBe(600); + expect(textElement.height).toBe(25); + expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT); + expect((textElement as ExcalidrawTextElement).text).toBe( + "Excalidraw is an opensource virtual collaborative whiteboard", + ); + + API.setSelectedElements([textElement]); + + fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { + button: 2, + clientX: 20, + clientY: 30, + }); + + const contextMenu = document.querySelector(".context-menu"); + fireEvent.click( + queryByText(contextMenu as HTMLElement, "Wrap text in a container")!, + ); + expect(h.elements.length).toBe(3); + + expect(h.elements[1]).toEqual( + expect.objectContaining({ + angle: 0, + backgroundColor: "transparent", + boundElements: [ + { + id: h.elements[2].id, + type: "text", + }, + ], + fillStyle: "solid", + groupIds: [], + height: 35, + isDeleted: false, + link: null, + locked: false, + opacity: 100, + roughness: 1, + roundness: { + type: 3, + }, + strokeColor: "#1e1e1e", + strokeStyle: "solid", + strokeWidth: 2, + type: "rectangle", + updated: 1, + version: 2, + width: 610, + x: 15, + y: 25, + }), + ); + expect(h.elements[2] as ExcalidrawTextElement).toEqual( + expect.objectContaining({ + text: "Excalidraw is an opensource virtual collaborative whiteboard", + verticalAlign: VERTICAL_ALIGN.MIDDLE, + textAlign: TEXT_ALIGN.CENTER, + boundElements: null, + }), + ); + }); + + it("shouldn't bind to container if container has bound text not centered and text tool is used", async () => { + expect(h.elements.length).toBe(1); + + Keyboard.keyPress(KEYS.ENTER); + + expect(h.elements.length).toBe(2); + + // Bind first text + let text = h.elements[1] as ExcalidrawTextElementWithContainer; + expect(text.containerId).toBe(rectangle.id); + let editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Hello!"); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign, + ).toBe(VERTICAL_ALIGN.MIDDLE); + + fireEvent.click(screen.getByTitle("Align bottom")); + + Keyboard.exitTextEditor(editor); + + expect(rectangle.boundElements).toStrictEqual([ + { id: text.id, type: "text" }, + ]); + expect( + (h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign, + ).toBe(VERTICAL_ALIGN.BOTTOM); + + // Attempt to Bind 2nd text using text tool + UI.clickTool("text"); + mouse.clickAt( + rectangle.x + rectangle.width / 2, + rectangle.y + rectangle.height / 2, + ); + editor = await getTextEditor(textEditorSelector, true); + updateTextEditor(editor, "Excalidraw"); + Keyboard.exitTextEditor(editor); + + expect(h.elements.length).toBe(3); + expect(rectangle.boundElements).toStrictEqual([ + { id: h.elements[1].id, type: "text" }, + ]); + text = h.elements[2] as ExcalidrawTextElementWithContainer; + expect(text.containerId).toBe(null); + expect(text.text).toBe("Excalidraw"); + }); + }); +}); diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx new file mode 100644 index 0000000..a757086 --- /dev/null +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -0,0 +1,730 @@ +import { CODES, KEYS } from "../keys"; +import { + isWritableElement, + getFontString, + getFontFamilyString, + isTestEnv, +} from "../utils"; +import Scene from "../scene/Scene"; +import { + isArrowElement, + isBoundToContainer, + isTextElement, +} from "./typeChecks"; +import { CLASSES, POINTER_BUTTON } from "../constants"; +import type { + ExcalidrawElement, + ExcalidrawLinearElement, + ExcalidrawTextElementWithContainer, + ExcalidrawTextElement, +} from "./types"; +import type { AppState } from "../types"; +import { bumpVersion, mutateElement } from "./mutateElement"; +import { + getBoundTextElementId, + getContainerElement, + getTextElementAngle, + redrawTextBoundingBox, + getBoundTextMaxHeight, + getBoundTextMaxWidth, + computeContainerDimensionForBoundText, + computeBoundTextPosition, + getBoundTextElement, +} from "./textElement"; +import { wrapText } from "./textWrapping"; +import { + actionDecreaseFontSize, + actionIncreaseFontSize, +} from "../actions/actionProperties"; +import { + actionResetZoom, + actionZoomIn, + actionZoomOut, +} from "../actions/actionCanvas"; +import type App from "../components/App"; +import { LinearElementEditor } from "./linearElementEditor"; +import { parseClipboard } from "../clipboard"; +import { + originalContainerCache, + updateOriginalContainerCache, +} from "./containerCache"; +import { getTextWidth } from "./textMeasurements"; +import { normalizeText } from "./textMeasurements"; + +const getTransform = ( + width: number, + height: number, + angle: number, + appState: AppState, + maxWidth: number, + maxHeight: number, +) => { + const { zoom } = appState; + const degree = (180 * angle) / Math.PI; + let translateX = (width * (zoom.value - 1)) / 2; + let translateY = (height * (zoom.value - 1)) / 2; + if (width > maxWidth && zoom.value !== 1) { + translateX = (maxWidth * (zoom.value - 1)) / 2; + } + if (height > maxHeight && zoom.value !== 1) { + translateY = (maxHeight * (zoom.value - 1)) / 2; + } + return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; +}; + +export const textWysiwyg = ({ + id, + onChange, + onSubmit, + getViewportCoords, + element, + canvas, + excalidrawContainer, + app, + autoSelect = true, +}: { + id: ExcalidrawElement["id"]; + /** + * textWysiwyg only deals with `originalText` + * + * Note: `text`, which can be wrapped and therefore different from `originalText`, + * is derived from `originalText` + */ + onChange?: (nextOriginalText: string) => void; + onSubmit: (data: { viaKeyboard: boolean; nextOriginalText: string }) => void; + getViewportCoords: (x: number, y: number) => [number, number]; + element: ExcalidrawTextElement; + canvas: HTMLCanvasElement; + excalidrawContainer: HTMLDivElement | null; + app: App; + autoSelect?: boolean; +}) => { + const textPropertiesUpdated = ( + updatedTextElement: ExcalidrawTextElement, + editable: HTMLTextAreaElement, + ) => { + if (!editable.style.fontFamily || !editable.style.fontSize) { + return false; + } + const currentFont = editable.style.fontFamily.replace(/"/g, ""); + if ( + getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !== + currentFont + ) { + return true; + } + if (`${updatedTextElement.fontSize}px` !== editable.style.fontSize) { + return true; + } + return false; + }; + + const updateWysiwygStyle = () => { + const appState = app.state; + const updatedTextElement = + Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id); + + if (!updatedTextElement) { + return; + } + const { textAlign, verticalAlign } = updatedTextElement; + const elementsMap = app.scene.getNonDeletedElementsMap(); + if (updatedTextElement && isTextElement(updatedTextElement)) { + let coordX = updatedTextElement.x; + let coordY = updatedTextElement.y; + const container = getContainerElement( + updatedTextElement, + app.scene.getNonDeletedElementsMap(), + ); + + let width = updatedTextElement.width; + + // set to element height by default since that's + // what is going to be used for unbounded text + let height = updatedTextElement.height; + + let maxWidth = updatedTextElement.width; + let maxHeight = updatedTextElement.height; + + if (container && updatedTextElement.containerId) { + if (isArrowElement(container)) { + const boundTextCoords = + LinearElementEditor.getBoundTextElementPosition( + container, + updatedTextElement as ExcalidrawTextElementWithContainer, + elementsMap, + ); + coordX = boundTextCoords.x; + coordY = boundTextCoords.y; + } + const propertiesUpdated = textPropertiesUpdated( + updatedTextElement, + editable, + ); + + let originalContainerData; + if (propertiesUpdated) { + originalContainerData = updateOriginalContainerCache( + container.id, + container.height, + ); + } else { + originalContainerData = originalContainerCache[container.id]; + if (!originalContainerData) { + originalContainerData = updateOriginalContainerCache( + container.id, + container.height, + ); + } + } + + maxWidth = getBoundTextMaxWidth(container, updatedTextElement); + + maxHeight = getBoundTextMaxHeight( + container, + updatedTextElement as ExcalidrawTextElementWithContainer, + ); + + // autogrow container height if text exceeds + if (!isArrowElement(container) && height > maxHeight) { + const targetContainerHeight = computeContainerDimensionForBoundText( + height, + container.type, + ); + + mutateElement(container, { height: targetContainerHeight }); + return; + } else if ( + // autoshrink container height until original container height + // is reached when text is removed + !isArrowElement(container) && + container.height > originalContainerData.height && + height < maxHeight + ) { + const targetContainerHeight = computeContainerDimensionForBoundText( + height, + container.type, + ); + mutateElement(container, { height: targetContainerHeight }); + } else { + const { y } = computeBoundTextPosition( + container, + updatedTextElement as ExcalidrawTextElementWithContainer, + elementsMap, + ); + coordY = y; + } + } + const [viewportX, viewportY] = getViewportCoords(coordX, coordY); + const initialSelectionStart = editable.selectionStart; + const initialSelectionEnd = editable.selectionEnd; + const initialLength = editable.value.length; + + // restore cursor position after value updated so it doesn't + // go to the end of text when container auto expanded + if ( + initialSelectionStart === initialSelectionEnd && + initialSelectionEnd !== initialLength + ) { + // get diff between length and selection end and shift + // the cursor by "diff" times to position correctly + const diff = initialLength - initialSelectionEnd; + editable.selectionStart = editable.value.length - diff; + editable.selectionEnd = editable.value.length - diff; + } + + if (!container) { + maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; + width = Math.min(width, maxWidth); + } else { + width += 0.5; + } + + // add 5% buffer otherwise it causes wysiwyg to jump + height *= 1.05; + + const font = getFontString(updatedTextElement); + + // Make sure text editor height doesn't go beyond viewport + const editorMaxHeight = + (appState.height - viewportY) / appState.zoom.value; + Object.assign(editable.style, { + font, + // must be defined *after* font ¯\_(ツ)_/¯ + lineHeight: updatedTextElement.lineHeight, + width: `${width}px`, + height: `${height}px`, + left: `${viewportX}px`, + top: `${viewportY}px`, + transform: getTransform( + width, + height, + getTextElementAngle(updatedTextElement, container), + appState, + maxWidth, + editorMaxHeight, + ), + textAlign, + verticalAlign, + color: updatedTextElement.strokeColor, + opacity: updatedTextElement.opacity / 100, + filter: "var(--theme-filter)", + maxHeight: `${editorMaxHeight}px`, + }); + editable.scrollTop = 0; + // For some reason updating font attribute doesn't set font family + // hence updating font family explicitly for test environment + if (isTestEnv()) { + editable.style.fontFamily = getFontFamilyString(updatedTextElement); + } + + mutateElement(updatedTextElement, { x: coordX, y: coordY }); + } + }; + + const editable = document.createElement("textarea"); + + editable.dir = "auto"; + editable.tabIndex = 0; + editable.dataset.type = "wysiwyg"; + // prevent line wrapping on Safari + editable.wrap = "off"; + editable.classList.add("excalidraw-wysiwyg"); + + let whiteSpace = "pre"; + let wordBreak = "normal"; + + if (isBoundToContainer(element) || !element.autoResize) { + whiteSpace = "pre-wrap"; + wordBreak = "break-word"; + } + Object.assign(editable.style, { + position: "absolute", + display: "inline-block", + minHeight: "1em", + backfaceVisibility: "hidden", + margin: 0, + padding: 0, + border: 0, + outline: 0, + resize: "none", + background: "transparent", + overflow: "hidden", + // must be specified because in dark mode canvas creates a stacking context + zIndex: "var(--zIndex-wysiwyg)", + wordBreak, + // prevent line wrapping (`whitespace: nowrap` doesn't work on FF) + whiteSpace, + overflowWrap: "break-word", + boxSizing: "content-box", + }); + editable.value = element.originalText; + updateWysiwygStyle(); + + if (onChange) { + editable.onpaste = async (event) => { + const clipboardData = await parseClipboard(event, true); + if (!clipboardData.text) { + return; + } + const data = normalizeText(clipboardData.text); + if (!data) { + return; + } + const container = getContainerElement( + element, + app.scene.getNonDeletedElementsMap(), + ); + + const font = getFontString({ + fontSize: app.state.currentItemFontSize, + fontFamily: app.state.currentItemFontFamily, + }); + if (container) { + const boundTextElement = getBoundTextElement( + container, + app.scene.getNonDeletedElementsMap(), + ); + const wrappedText = wrapText( + `${editable.value}${data}`, + font, + getBoundTextMaxWidth(container, boundTextElement), + ); + const width = getTextWidth(wrappedText, font); + editable.style.width = `${width}px`; + } + }; + + editable.oninput = () => { + const normalized = normalizeText(editable.value); + if (editable.value !== normalized) { + const selectionStart = editable.selectionStart; + editable.value = normalized; + // put the cursor at some position close to where it was before + // normalization (otherwise it'll end up at the end of the text) + editable.selectionStart = selectionStart; + editable.selectionEnd = selectionStart; + } + onChange(editable.value); + }; + } + + editable.onkeydown = (event) => { + if (!event.shiftKey && actionZoomIn.keyTest(event)) { + event.preventDefault(); + app.actionManager.executeAction(actionZoomIn); + updateWysiwygStyle(); + } else if (!event.shiftKey && actionZoomOut.keyTest(event)) { + event.preventDefault(); + app.actionManager.executeAction(actionZoomOut); + updateWysiwygStyle(); + } else if (!event.shiftKey && actionResetZoom.keyTest(event)) { + event.preventDefault(); + app.actionManager.executeAction(actionResetZoom); + updateWysiwygStyle(); + } else if (actionDecreaseFontSize.keyTest(event)) { + app.actionManager.executeAction(actionDecreaseFontSize); + } else if (actionIncreaseFontSize.keyTest(event)) { + app.actionManager.executeAction(actionIncreaseFontSize); + } else if (event.key === KEYS.ESCAPE) { + event.preventDefault(); + submittedViaKeyboard = true; + handleSubmit(); + } else if (event.key === KEYS.ENTER && event[KEYS.CTRL_OR_CMD]) { + event.preventDefault(); + if (event.isComposing || event.keyCode === 229) { + return; + } + submittedViaKeyboard = true; + handleSubmit(); + } else if ( + event.key === KEYS.TAB || + (event[KEYS.CTRL_OR_CMD] && + (event.code === CODES.BRACKET_LEFT || + event.code === CODES.BRACKET_RIGHT)) + ) { + event.preventDefault(); + if (event.isComposing) { + return; + } else if (event.shiftKey || event.code === CODES.BRACKET_LEFT) { + outdent(); + } else { + indent(); + } + // We must send an input event to resize the element + editable.dispatchEvent(new Event("input")); + } + }; + + const TAB_SIZE = 4; + const TAB = " ".repeat(TAB_SIZE); + const RE_LEADING_TAB = new RegExp(`^ {1,${TAB_SIZE}}`); + const indent = () => { + const { selectionStart, selectionEnd } = editable; + const linesStartIndices = getSelectedLinesStartIndices(); + + let value = editable.value; + linesStartIndices.forEach((startIndex: number) => { + const startValue = value.slice(0, startIndex); + const endValue = value.slice(startIndex); + + value = `${startValue}${TAB}${endValue}`; + }); + + editable.value = value; + + editable.selectionStart = selectionStart + TAB_SIZE; + editable.selectionEnd = selectionEnd + TAB_SIZE * linesStartIndices.length; + }; + + const outdent = () => { + const { selectionStart, selectionEnd } = editable; + const linesStartIndices = getSelectedLinesStartIndices(); + const removedTabs: number[] = []; + + let value = editable.value; + linesStartIndices.forEach((startIndex) => { + const tabMatch = value + .slice(startIndex, startIndex + TAB_SIZE) + .match(RE_LEADING_TAB); + + if (tabMatch) { + const startValue = value.slice(0, startIndex); + const endValue = value.slice(startIndex + tabMatch[0].length); + + // Delete a tab from the line + value = `${startValue}${endValue}`; + removedTabs.push(startIndex); + } + }); + + editable.value = value; + + if (removedTabs.length) { + if (selectionStart > removedTabs[removedTabs.length - 1]) { + editable.selectionStart = Math.max( + selectionStart - TAB_SIZE, + removedTabs[removedTabs.length - 1], + ); + } else { + // If the cursor is before the first tab removed, ex: + // Line| #1 + // Line #2 + // Lin|e #3 + // we should reset the selectionStart to his initial value. + editable.selectionStart = selectionStart; + } + editable.selectionEnd = Math.max( + editable.selectionStart, + selectionEnd - TAB_SIZE * removedTabs.length, + ); + } + }; + + /** + * @returns indices of start positions of selected lines, in reverse order + */ + const getSelectedLinesStartIndices = () => { + let { selectionStart, selectionEnd, value } = editable; + + // chars before selectionStart on the same line + const startOffset = value.slice(0, selectionStart).match(/[^\n]*$/)![0] + .length; + // put caret at the start of the line + selectionStart = selectionStart - startOffset; + + const selected = value.slice(selectionStart, selectionEnd); + + return selected + .split("\n") + .reduce( + (startIndices, line, idx, lines) => + startIndices.concat( + idx + ? // curr line index is prev line's start + prev line's length + \n + startIndices[idx - 1] + lines[idx - 1].length + 1 + : // first selected line + selectionStart, + ), + [] as number[], + ) + .reverse(); + }; + + const stopEvent = (event: Event) => { + if (event.target instanceof HTMLCanvasElement) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + // using a state variable instead of passing it to the handleSubmit callback + // so that we don't need to create separate a callback for event handlers + let submittedViaKeyboard = false; + const handleSubmit = () => { + // prevent double submit + if (isDestroyed) { + return; + } + isDestroyed = true; + // cleanup must be run before onSubmit otherwise when app blurs the wysiwyg + // it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the + // wysiwyg on update + cleanup(); + const updateElement = Scene.getScene(element)?.getElement( + element.id, + ) as ExcalidrawTextElement; + if (!updateElement) { + return; + } + const container = getContainerElement( + updateElement, + app.scene.getNonDeletedElementsMap(), + ); + + if (container) { + if (editable.value.trim()) { + const boundTextElementId = getBoundTextElementId(container); + if (!boundTextElementId || boundTextElementId !== element.id) { + mutateElement(container, { + boundElements: (container.boundElements || []).concat({ + type: "text", + id: element.id, + }), + }); + } else if (isArrowElement(container)) { + // updating an arrow label may change bounds, prevent stale cache: + bumpVersion(container); + } + } else { + mutateElement(container, { + boundElements: container.boundElements?.filter( + (ele) => + !isTextElement( + ele as ExcalidrawTextElement | ExcalidrawLinearElement, + ), + ), + }); + } + redrawTextBoundingBox( + updateElement, + container, + app.scene.getNonDeletedElementsMap(), + ); + } + + onSubmit({ + viaKeyboard: submittedViaKeyboard, + nextOriginalText: editable.value, + }); + }; + + const cleanup = () => { + // remove events to ensure they don't late-fire + editable.onblur = null; + editable.oninput = null; + editable.onkeydown = null; + + if (observer) { + observer.disconnect(); + } + + window.removeEventListener("resize", updateWysiwygStyle); + window.removeEventListener("wheel", stopEvent, true); + window.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("pointerup", bindBlurEvent); + window.removeEventListener("blur", handleSubmit); + window.removeEventListener("beforeunload", handleSubmit); + unbindUpdate(); + unbindOnScroll(); + + editable.remove(); + }; + + const bindBlurEvent = (event?: MouseEvent) => { + window.removeEventListener("pointerup", bindBlurEvent); + // Deferred so that the pointerdown that initiates the wysiwyg doesn't + // trigger the blur on ensuing pointerup. + // Also to handle cases such as picking a color which would trigger a blur + // in that same tick. + const target = event?.target; + + const isPropertiesTrigger = + target instanceof HTMLElement && + target.classList.contains("properties-trigger"); + + setTimeout(() => { + editable.onblur = handleSubmit; + + // case: clicking on the same property → no change → no update → no focus + if (!isPropertiesTrigger) { + editable.focus(); + } + }); + }; + + const temporarilyDisableSubmit = () => { + editable.onblur = null; + window.addEventListener("pointerup", bindBlurEvent); + // handle edge-case where pointerup doesn't fire e.g. due to user + // alt-tabbing away + window.addEventListener("blur", handleSubmit); + }; + + // prevent blur when changing properties from the menu + const onPointerDown = (event: MouseEvent) => { + const target = event?.target; + + // panning canvas + if (event.button === POINTER_BUTTON.WHEEL) { + // trying to pan by clicking inside text area itself -> handle here + if (target instanceof HTMLTextAreaElement) { + event.preventDefault(); + app.handleCanvasPanUsingWheelOrSpaceDrag(event); + } + temporarilyDisableSubmit(); + return; + } + + const isPropertiesTrigger = + target instanceof HTMLElement && + target.classList.contains("properties-trigger"); + + if ( + ((event.target instanceof HTMLElement || + event.target instanceof SVGElement) && + event.target.closest( + `.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`, + ) && + !isWritableElement(event.target)) || + isPropertiesTrigger + ) { + temporarilyDisableSubmit(); + } else if ( + event.target instanceof HTMLCanvasElement && + // Vitest simply ignores stopPropagation, capture-mode, or rAF + // so without introducing crazier hacks, nothing we can do + !isTestEnv() + ) { + // On mobile, blur event doesn't seem to always fire correctly, + // so we want to also submit on pointerdown outside the wysiwyg. + // Done in the next frame to prevent pointerdown from creating a new text + // immediately (if tools locked) so that users on mobile have chance + // to submit first (to hide virtual keyboard). + // Note: revisit if we want to differ this behavior on Desktop + requestAnimationFrame(() => { + handleSubmit(); + }); + } + }; + + // handle updates of textElement properties of editing element + const unbindUpdate = app.scene.onUpdate(() => { + updateWysiwygStyle(); + const isPopupOpened = !!document.activeElement?.closest( + ".properties-content", + ); + if (!isPopupOpened) { + editable.focus(); + } + }); + + const unbindOnScroll = app.onScrollChangeEmitter.on(() => { + updateWysiwygStyle(); + }); + + // --------------------------------------------------------------------------- + + let isDestroyed = false; + + if (autoSelect) { + // select on init (focusing is done separately inside the bindBlurEvent() + // because we need it to happen *after* the blur event from `pointerdown`) + editable.select(); + } + bindBlurEvent(); + + // reposition wysiwyg in case of canvas is resized. Using ResizeObserver + // is preferred so we catch changes from host, where window may not resize. + let observer: ResizeObserver | null = null; + if (canvas && "ResizeObserver" in window) { + observer = new window.ResizeObserver(() => { + updateWysiwygStyle(); + }); + observer.observe(canvas); + } else { + window.addEventListener("resize", updateWysiwygStyle); + } + + editable.onpointerdown = (event) => event.stopPropagation(); + + // rAF (+ capture to by doubly sure) so we don't catch te pointerdown that + // triggered the wysiwyg + requestAnimationFrame(() => { + window.addEventListener("pointerdown", onPointerDown, { capture: true }); + }); + window.addEventListener("beforeunload", handleSubmit); + excalidrawContainer + ?.querySelector(".excalidraw-textEditorContainer")! + .appendChild(editable); +}; diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts new file mode 100644 index 0000000..d34eb32 --- /dev/null +++ b/packages/excalidraw/element/transformHandles.ts @@ -0,0 +1,341 @@ +import type { + ElementsMap, + ExcalidrawElement, + NonDeletedExcalidrawElement, + PointerType, +} from "./types"; + +import type { Bounds } from "./bounds"; +import { getElementAbsoluteCoords } from "./bounds"; +import type { Device, InteractiveCanvasAppState, Zoom } from "../types"; +import { + isElbowArrow, + isFrameLikeElement, + isImageElement, + isLinearElement, +} from "./typeChecks"; +import { + DEFAULT_TRANSFORM_HANDLE_SPACING, + isAndroid, + isIOS, +} from "../constants"; +import type { Radians } from "@excalidraw/math"; +import { pointFrom, pointRotateRads } from "@excalidraw/math"; + +export type TransformHandleDirection = + | "n" + | "s" + | "w" + | "e" + | "nw" + | "ne" + | "sw" + | "se"; + +export type TransformHandleType = TransformHandleDirection | "rotation"; + +export type TransformHandle = Bounds; +export type TransformHandles = Partial<{ + [T in TransformHandleType]: TransformHandle; +}>; +export type MaybeTransformHandleType = TransformHandleType | false; + +const transformHandleSizes: { [k in PointerType]: number } = { + mouse: 8, + pen: 16, + touch: 28, +}; + +const ROTATION_RESIZE_HANDLE_GAP = 16; + +export const DEFAULT_OMIT_SIDES = { + e: true, + s: true, + n: true, + w: true, +}; + +export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = { + e: true, + s: true, + n: true, + w: true, +}; + +export const OMIT_SIDES_FOR_FRAME = { + e: true, + s: true, + n: true, + w: true, + rotation: true, +}; + +const OMIT_SIDES_FOR_LINE_SLASH = { + e: true, + s: true, + n: true, + w: true, + nw: true, + se: true, +}; + +const OMIT_SIDES_FOR_LINE_BACKSLASH = { + e: true, + s: true, + n: true, + w: true, +}; + +const generateTransformHandle = ( + x: number, + y: number, + width: number, + height: number, + cx: number, + cy: number, + angle: Radians, +): TransformHandle => { + const [xx, yy] = pointRotateRads( + pointFrom(x + width / 2, y + height / 2), + pointFrom(cx, cy), + angle, + ); + return [xx - width / 2, yy - height / 2, width, height]; +}; + +export const canResizeFromSides = (device: Device) => { + if (device.viewport.isMobile) { + return false; + } + + if (device.isTouchScreen && (isAndroid || isIOS)) { + return false; + } + + return true; +}; + +export const getOmitSidesForDevice = (device: Device) => { + if (canResizeFromSides(device)) { + return DEFAULT_OMIT_SIDES; + } + + return {}; +}; + +export const getTransformHandlesFromCoords = ( + [x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number], + angle: Radians, + zoom: Zoom, + pointerType: PointerType, + omitSides: { [T in TransformHandleType]?: boolean } = {}, + margin = 4, + spacing = DEFAULT_TRANSFORM_HANDLE_SPACING, +): TransformHandles => { + const size = transformHandleSizes[pointerType]; + const handleWidth = size / zoom.value; + const handleHeight = size / zoom.value; + + const handleMarginX = size / zoom.value; + const handleMarginY = size / zoom.value; + + const width = x2 - x1; + const height = y2 - y1; + const dashedLineMargin = margin / zoom.value; + const centeringOffset = (size - spacing * 2) / (2 * zoom.value); + + const transformHandles: TransformHandles = { + nw: omitSides.nw + ? undefined + : generateTransformHandle( + x1 - dashedLineMargin - handleMarginX + centeringOffset, + y1 - dashedLineMargin - handleMarginY + centeringOffset, + handleWidth, + handleHeight, + cx, + cy, + angle, + ), + ne: omitSides.ne + ? undefined + : generateTransformHandle( + x2 + dashedLineMargin - centeringOffset, + y1 - dashedLineMargin - handleMarginY + centeringOffset, + handleWidth, + handleHeight, + cx, + cy, + angle, + ), + sw: omitSides.sw + ? undefined + : generateTransformHandle( + x1 - dashedLineMargin - handleMarginX + centeringOffset, + y2 + dashedLineMargin - centeringOffset, + handleWidth, + handleHeight, + cx, + cy, + angle, + ), + se: omitSides.se + ? undefined + : generateTransformHandle( + x2 + dashedLineMargin - centeringOffset, + y2 + dashedLineMargin - centeringOffset, + handleWidth, + handleHeight, + cx, + cy, + angle, + ), + rotation: omitSides.rotation + ? undefined + : generateTransformHandle( + x1 + width / 2 - handleWidth / 2, + y1 - + dashedLineMargin - + handleMarginY + + centeringOffset - + ROTATION_RESIZE_HANDLE_GAP / zoom.value, + handleWidth, + handleHeight, + cx, + cy, + angle, + ), + }; + + // We only want to show height handles (all cardinal directions) above a certain size + // Note: we render using "mouse" size so we should also use "mouse" size for this check + const minimumSizeForEightHandles = + (5 * transformHandleSizes.mouse) / zoom.value; + if (Math.abs(width) > minimumSizeForEightHandles) { + if (!omitSides.n) { + transformHandles.n = generateTransformHandle( + x1 + width / 2 - handleWidth / 2, + y1 - dashedLineMargin - handleMarginY + centeringOffset, + handleWidth, + handleHeight, + cx, + cy, + angle, + ); + } + if (!omitSides.s) { + transformHandles.s = generateTransformHandle( + x1 + width / 2 - handleWidth / 2, + y2 + dashedLineMargin - centeringOffset, + handleWidth, + handleHeight, + cx, + cy, + angle, + ); + } + } + if (Math.abs(height) > minimumSizeForEightHandles) { + if (!omitSides.w) { + transformHandles.w = generateTransformHandle( + x1 - dashedLineMargin - handleMarginX + centeringOffset, + y1 + height / 2 - handleHeight / 2, + handleWidth, + handleHeight, + cx, + cy, + angle, + ); + } + if (!omitSides.e) { + transformHandles.e = generateTransformHandle( + x2 + dashedLineMargin - centeringOffset, + y1 + height / 2 - handleHeight / 2, + handleWidth, + handleHeight, + cx, + cy, + angle, + ); + } + } + + return transformHandles; +}; + +export const getTransformHandles = ( + element: ExcalidrawElement, + zoom: Zoom, + elementsMap: ElementsMap, + pointerType: PointerType = "mouse", + omitSides: { [T in TransformHandleType]?: boolean } = DEFAULT_OMIT_SIDES, +): TransformHandles => { + // so that when locked element is selected (especially when you toggle lock + // via keyboard) the locked element is visually distinct, indicating + // you can't move/resize + if ( + element.locked || + // Elbow arrows cannot be rotated + isElbowArrow(element) + ) { + return {}; + } + + if (element.type === "freedraw" || isLinearElement(element)) { + if (element.points.length === 2) { + // only check the last point because starting point is always (0,0) + const [, p1] = element.points; + if (p1[0] === 0 || p1[1] === 0) { + omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH; + } else if (p1[0] > 0 && p1[1] < 0) { + omitSides = OMIT_SIDES_FOR_LINE_SLASH; + } else if (p1[0] > 0 && p1[1] > 0) { + omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH; + } else if (p1[0] < 0 && p1[1] > 0) { + omitSides = OMIT_SIDES_FOR_LINE_SLASH; + } else if (p1[0] < 0 && p1[1] < 0) { + omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH; + } + } + } else if (isFrameLikeElement(element)) { + omitSides = { + ...omitSides, + rotation: true, + }; + } + const margin = isLinearElement(element) + ? DEFAULT_TRANSFORM_HANDLE_SPACING + 8 + : isImageElement(element) + ? 0 + : DEFAULT_TRANSFORM_HANDLE_SPACING; + return getTransformHandlesFromCoords( + getElementAbsoluteCoords(element, elementsMap, true), + element.angle, + zoom, + pointerType, + omitSides, + margin, + isImageElement(element) ? 0 : undefined, + ); +}; + +export const shouldShowBoundingBox = ( + elements: readonly NonDeletedExcalidrawElement[], + appState: InteractiveCanvasAppState, +) => { + if (appState.editingLinearElement) { + return false; + } + if (elements.length > 1) { + return true; + } + const element = elements[0]; + if (isElbowArrow(element)) { + // Elbow arrows cannot be resized as single selected elements + return false; + } + if (!isLinearElement(element)) { + return true; + } + + return element.points.length > 2; +}; diff --git a/packages/excalidraw/element/typeChecks.test.ts b/packages/excalidraw/element/typeChecks.test.ts new file mode 100644 index 0000000..60eb9e2 --- /dev/null +++ b/packages/excalidraw/element/typeChecks.test.ts @@ -0,0 +1,66 @@ +import { API } from "../tests/helpers/api"; +import { hasBoundTextElement } from "./typeChecks"; + +describe("Test TypeChecks", () => { + describe("Test hasBoundTextElement", () => { + it("should return true for text bindable containers with bound text", () => { + expect( + hasBoundTextElement( + API.createElement({ + type: "rectangle", + boundElements: [{ type: "text", id: "text-id" }], + }), + ), + ).toBeTruthy(); + + expect( + hasBoundTextElement( + API.createElement({ + type: "ellipse", + boundElements: [{ type: "text", id: "text-id" }], + }), + ), + ).toBeTruthy(); + + expect( + hasBoundTextElement( + API.createElement({ + type: "arrow", + boundElements: [{ type: "text", id: "text-id" }], + }), + ), + ).toBeTruthy(); + }); + + it("should return false for text bindable containers without bound text", () => { + expect( + hasBoundTextElement( + API.createElement({ + type: "freedraw", + boundElements: [{ type: "arrow", id: "arrow-id" }], + }), + ), + ).toBeFalsy(); + }); + + it("should return false for non text bindable containers", () => { + expect( + hasBoundTextElement( + API.createElement({ + type: "freedraw", + boundElements: [{ type: "text", id: "text-id" }], + }), + ), + ).toBeFalsy(); + }); + + expect( + hasBoundTextElement( + API.createElement({ + type: "image", + boundElements: [{ type: "text", id: "text-id" }], + }), + ), + ).toBeFalsy(); + }); +}); diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts new file mode 100644 index 0000000..6bb4269 --- /dev/null +++ b/packages/excalidraw/element/typeChecks.ts @@ -0,0 +1,338 @@ +import { ROUNDNESS } from "../constants"; +import type { ElementOrToolType } from "../types"; +import type { MarkNonNullable } from "../utility-types"; +import { assertNever } from "../utils"; +import type { Bounds } from "./bounds"; +import type { + ExcalidrawElement, + ExcalidrawTextElement, + ExcalidrawEmbeddableElement, + ExcalidrawLinearElement, + ExcalidrawBindableElement, + ExcalidrawFreeDrawElement, + InitializedExcalidrawImageElement, + ExcalidrawImageElement, + ExcalidrawTextElementWithContainer, + ExcalidrawTextContainer, + ExcalidrawFrameElement, + RoundnessType, + ExcalidrawFrameLikeElement, + ExcalidrawElementType, + ExcalidrawIframeElement, + ExcalidrawIframeLikeElement, + ExcalidrawMagicFrameElement, + ExcalidrawArrowElement, + ExcalidrawElbowArrowElement, + PointBinding, + FixedPointBinding, + ExcalidrawFlowchartNodeElement, +} from "./types"; + +export const isInitializedImageElement = ( + element: ExcalidrawElement | null, +): element is InitializedExcalidrawImageElement => { + return !!element && element.type === "image" && !!element.fileId; +}; + +export const isImageElement = ( + element: ExcalidrawElement | null, +): element is ExcalidrawImageElement => { + return !!element && element.type === "image"; +}; + +export const isEmbeddableElement = ( + element: ExcalidrawElement | null | undefined, +): element is ExcalidrawEmbeddableElement => { + return !!element && element.type === "embeddable"; +}; + +export const isIframeElement = ( + element: ExcalidrawElement | null, +): element is ExcalidrawIframeElement => { + return !!element && element.type === "iframe"; +}; + +export const isIframeLikeElement = ( + element: ExcalidrawElement | null, +): element is ExcalidrawIframeLikeElement => { + return ( + !!element && (element.type === "iframe" || element.type === "embeddable") + ); +}; + +export const isTextElement = ( + element: ExcalidrawElement | null, +): element is ExcalidrawTextElement => { + return element != null && element.type === "text"; +}; + +export const isFrameElement = ( + element: ExcalidrawElement | null, +): element is ExcalidrawFrameElement => { + return element != null && element.type === "frame"; +}; + +export const isMagicFrameElement = ( + element: ExcalidrawElement | null, +): element is ExcalidrawMagicFrameElement => { + return element != null && element.type === "magicframe"; +}; + +export const isFrameLikeElement = ( + element: ExcalidrawElement | null, +): element is ExcalidrawFrameLikeElement => { + return ( + element != null && + (element.type === "frame" || element.type === "magicframe") + ); +}; + +export const isFreeDrawElement = ( + element?: ExcalidrawElement | null, +): element is ExcalidrawFreeDrawElement => { + return element != null && isFreeDrawElementType(element.type); +}; + +export const isFreeDrawElementType = ( + elementType: ExcalidrawElementType, +): boolean => { + return elementType === "freedraw"; +}; + +export const isLinearElement = ( + element?: ExcalidrawElement | null, +): element is ExcalidrawLinearElement => { + return element != null && isLinearElementType(element.type); +}; + +export const isArrowElement = ( + element?: ExcalidrawElement | null, +): element is ExcalidrawArrowElement => { + return element != null && element.type === "arrow"; +}; + +export const isElbowArrow = ( + element?: ExcalidrawElement, +): element is ExcalidrawElbowArrowElement => { + return isArrowElement(element) && element.elbowed; +}; + +export const isLinearElementType = ( + elementType: ElementOrToolType, +): boolean => { + return ( + elementType === "arrow" || elementType === "line" // || elementType === "freedraw" + ); +}; + +export const isBindingElement = ( + element?: ExcalidrawElement | null, + includeLocked = true, +): element is ExcalidrawLinearElement => { + return ( + element != null && + (!element.locked || includeLocked === true) && + isBindingElementType(element.type) + ); +}; + +export const isBindingElementType = ( + elementType: ElementOrToolType, +): boolean => { + return elementType === "arrow"; +}; + +export const isBindableElement = ( + element: ExcalidrawElement | null | undefined, + includeLocked = true, +): element is ExcalidrawBindableElement => { + return ( + element != null && + (!element.locked || includeLocked === true) && + (element.type === "rectangle" || + element.type === "diamond" || + element.type === "ellipse" || + element.type === "image" || + element.type === "iframe" || + element.type === "embeddable" || + element.type === "frame" || + element.type === "magicframe" || + (element.type === "text" && !element.containerId)) + ); +}; + +export const isRectanguloidElement = ( + element?: ExcalidrawElement | null, +): element is ExcalidrawBindableElement => { + return ( + element != null && + (element.type === "rectangle" || + element.type === "diamond" || + element.type === "image" || + element.type === "iframe" || + element.type === "embeddable" || + element.type === "frame" || + element.type === "magicframe" || + (element.type === "text" && !element.containerId)) + ); +}; + +// TODO: Remove this when proper distance calculation is introduced +// @see binding.ts:distanceToBindableElement() +export const isRectangularElement = ( + element?: ExcalidrawElement | null, +): element is ExcalidrawBindableElement => { + return ( + element != null && + (element.type === "rectangle" || + element.type === "image" || + element.type === "text" || + element.type === "iframe" || + element.type === "embeddable" || + element.type === "frame" || + element.type === "magicframe" || + element.type === "freedraw") + ); +}; + +export const isTextBindableContainer = ( + element: ExcalidrawElement | null, + includeLocked = true, +): element is ExcalidrawTextContainer => { + return ( + element != null && + (!element.locked || includeLocked === true) && + (element.type === "rectangle" || + element.type === "diamond" || + element.type === "ellipse" || + isArrowElement(element)) + ); +}; + +export const isExcalidrawElement = ( + element: any, +): element is ExcalidrawElement => { + const type: ExcalidrawElementType | undefined = element?.type; + if (!type) { + return false; + } + switch (type) { + case "text": + case "diamond": + case "rectangle": + case "iframe": + case "embeddable": + case "ellipse": + case "arrow": + case "freedraw": + case "line": + case "frame": + case "magicframe": + case "image": + case "selection": { + return true; + } + default: { + assertNever(type, null); + return false; + } + } +}; + +export const isFlowchartNodeElement = ( + element: ExcalidrawElement, +): element is ExcalidrawFlowchartNodeElement => { + return ( + element.type === "rectangle" || + element.type === "ellipse" || + element.type === "diamond" + ); +}; + +export const hasBoundTextElement = ( + element: ExcalidrawElement | null, +): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => { + return ( + isTextBindableContainer(element) && + !!element.boundElements?.some(({ type }) => type === "text") + ); +}; + +export const isBoundToContainer = ( + element: ExcalidrawElement | null, +): element is ExcalidrawTextElementWithContainer => { + return ( + element !== null && + "containerId" in element && + element.containerId !== null && + isTextElement(element) + ); +}; + +export const isUsingAdaptiveRadius = (type: string) => + type === "rectangle" || + type === "embeddable" || + type === "iframe" || + type === "image"; + +export const isUsingProportionalRadius = (type: string) => + type === "line" || type === "arrow" || type === "diamond"; + +export const canApplyRoundnessTypeToElement = ( + roundnessType: RoundnessType, + element: ExcalidrawElement, +) => { + if ( + (roundnessType === ROUNDNESS.ADAPTIVE_RADIUS || + // if legacy roundness, it can be applied to elements that currently + // use adaptive radius + roundnessType === ROUNDNESS.LEGACY) && + isUsingAdaptiveRadius(element.type) + ) { + return true; + } + if ( + roundnessType === ROUNDNESS.PROPORTIONAL_RADIUS && + isUsingProportionalRadius(element.type) + ) { + return true; + } + + return false; +}; + +export const getDefaultRoundnessTypeForElement = ( + element: ExcalidrawElement, +) => { + if (isUsingProportionalRadius(element.type)) { + return { + type: ROUNDNESS.PROPORTIONAL_RADIUS, + }; + } + + if (isUsingAdaptiveRadius(element.type)) { + return { + type: ROUNDNESS.ADAPTIVE_RADIUS, + }; + } + + return null; +}; + +export const isFixedPointBinding = ( + binding: PointBinding | FixedPointBinding, +): binding is FixedPointBinding => { + return ( + Object.hasOwn(binding, "fixedPoint") && + (binding as FixedPointBinding).fixedPoint != null + ); +}; + +// TODO: Move this to @excalidraw/math +export const isBounds = (box: unknown): box is Bounds => + Array.isArray(box) && + box.length === 4 && + typeof box[0] === "number" && + typeof box[1] === "number" && + typeof box[2] === "number" && + typeof box[3] === "number"; diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts new file mode 100644 index 0000000..5965867 --- /dev/null +++ b/packages/excalidraw/element/types.ts @@ -0,0 +1,412 @@ +import type { LocalPoint, Radians } from "@excalidraw/math"; +import type { + FONT_FAMILY, + ROUNDNESS, + TEXT_ALIGN, + THEME, + VERTICAL_ALIGN, +} from "../constants"; +import type { + MakeBrand, + MarkNonNullable, + Merge, + ValueOf, +} from "../utility-types"; + +export type ChartType = "bar" | "line"; +export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; +export type FontFamilyKeys = keyof typeof FONT_FAMILY; +export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys]; +export type Theme = typeof THEME[keyof typeof THEME]; +export type FontString = string & { _brand: "fontString" }; +export type GroupId = string; +export type PointerType = "mouse" | "pen" | "touch"; +export type StrokeRoundness = "round" | "sharp"; +export type RoundnessType = ValueOf<typeof ROUNDNESS>; +export type StrokeStyle = "solid" | "dashed" | "dotted"; +export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN]; + +type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN; +export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys]; +export type FractionalIndex = string & { _brand: "franctionalIndex" }; + +export type BoundElement = Readonly<{ + id: ExcalidrawLinearElement["id"]; + type: "arrow" | "text"; +}>; + +type _ExcalidrawElementBase = Readonly<{ + id: string; + x: number; + y: number; + strokeColor: string; + backgroundColor: string; + fillStyle: FillStyle; + strokeWidth: number; + strokeStyle: StrokeStyle; + roundness: null | { type: RoundnessType; value?: number }; + roughness: number; + opacity: number; + width: number; + height: number; + angle: Radians; + /** Random integer used to seed shape generation so that the roughjs shape + doesn't differ across renders. */ + seed: number; + /** Integer that is sequentially incremented on each change. Used to reconcile + elements during collaboration or when saving to server. */ + version: number; + /** Random integer that is regenerated on each change. + Used for deterministic reconciliation of updates during collaboration, + in case the versions (see above) are identical. */ + versionNonce: number; + /** String in a fractional form defined by https://github.com/rocicorp/fractional-indexing. + Used for ordering in multiplayer scenarios, such as during reconciliation or undo / redo. + Always kept in sync with the array order by `syncMovedIndices` and `syncInvalidIndices`. + Could be null, i.e. for new elements which were not yet assigned to the scene. */ + index: FractionalIndex | null; + isDeleted: boolean; + /** List of groups the element belongs to. + Ordered from deepest to shallowest. */ + groupIds: readonly GroupId[]; + frameId: string | null; + /** other elements that are bound to this element */ + boundElements: readonly BoundElement[] | null; + /** epoch (ms) timestamp of last element update */ + updated: number; + link: string | null; + locked: boolean; + customData?: Record<string, any>; +}>; + +export type ExcalidrawSelectionElement = _ExcalidrawElementBase & { + type: "selection"; +}; + +export type ExcalidrawRectangleElement = _ExcalidrawElementBase & { + type: "rectangle"; +}; + +export type ExcalidrawDiamondElement = _ExcalidrawElementBase & { + type: "diamond"; +}; + +export type ExcalidrawEllipseElement = _ExcalidrawElementBase & { + type: "ellipse"; +}; + +export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase & + Readonly<{ + type: "embeddable"; + }>; + +export type MagicGenerationData = + | { + status: "pending"; + } + | { status: "done"; html: string } + | { + status: "error"; + message?: string; + code: "ERR_GENERATION_INTERRUPTED" | string; + }; + +export type ExcalidrawIframeElement = _ExcalidrawElementBase & + Readonly<{ + type: "iframe"; + // TODO move later to AI-specific frame + customData?: { generationData?: MagicGenerationData }; + }>; + +export type ExcalidrawIframeLikeElement = + | ExcalidrawIframeElement + | ExcalidrawEmbeddableElement; + +export type IframeData = + | { + intrinsicSize: { w: number; h: number }; + error?: Error; + sandbox?: { allowSameOrigin?: boolean }; + } & ( + | { type: "video" | "generic"; link: string } + | { type: "document"; srcdoc: (theme: Theme) => string } + ); + +export type ImageCrop = { + x: number; + y: number; + width: number; + height: number; + naturalWidth: number; + naturalHeight: number; +}; + +export type ExcalidrawImageElement = _ExcalidrawElementBase & + Readonly<{ + type: "image"; + fileId: FileId | null; + /** whether respective file is persisted */ + status: "pending" | "saved" | "error"; + /** X and Y scale factors <-1, 1>, used for image axis flipping */ + scale: [number, number]; + /** whether an element is cropped */ + crop: ImageCrop | null; + }>; + +export type InitializedExcalidrawImageElement = MarkNonNullable< + ExcalidrawImageElement, + "fileId" +>; + +export type ExcalidrawFrameElement = _ExcalidrawElementBase & { + type: "frame"; + name: string | null; +}; + +export type ExcalidrawMagicFrameElement = _ExcalidrawElementBase & { + type: "magicframe"; + name: string | null; +}; + +export type ExcalidrawFrameLikeElement = + | ExcalidrawFrameElement + | ExcalidrawMagicFrameElement; + +/** + * These are elements that don't have any additional properties. + */ +export type ExcalidrawGenericElement = + | ExcalidrawSelectionElement + | ExcalidrawRectangleElement + | ExcalidrawDiamondElement + | ExcalidrawEllipseElement; + +export type ExcalidrawFlowchartNodeElement = + | ExcalidrawRectangleElement + | ExcalidrawDiamondElement + | ExcalidrawEllipseElement; + +export type ExcalidrawRectanguloidElement = + | ExcalidrawRectangleElement + | ExcalidrawImageElement + | ExcalidrawTextElement + | ExcalidrawFreeDrawElement + | ExcalidrawIframeLikeElement + | ExcalidrawFrameLikeElement + | ExcalidrawEmbeddableElement; + +/** + * ExcalidrawElement should be JSON serializable and (eventually) contain + * no computed data. The list of all ExcalidrawElements should be shareable + * between peers and contain no state local to the peer. + */ +export type ExcalidrawElement = + | ExcalidrawGenericElement + | ExcalidrawTextElement + | ExcalidrawLinearElement + | ExcalidrawArrowElement + | ExcalidrawFreeDrawElement + | ExcalidrawImageElement + | ExcalidrawFrameElement + | ExcalidrawMagicFrameElement + | ExcalidrawIframeElement + | ExcalidrawEmbeddableElement; + +export type ExcalidrawNonSelectionElement = Exclude< + ExcalidrawElement, + ExcalidrawSelectionElement +>; + +export type Ordered<TElement extends ExcalidrawElement> = TElement & { + index: FractionalIndex; +}; + +export type OrderedExcalidrawElement = Ordered<ExcalidrawElement>; + +export type NonDeleted<TElement extends ExcalidrawElement> = TElement & { + isDeleted: boolean; +}; + +export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>; + +export type ExcalidrawTextElement = _ExcalidrawElementBase & + Readonly<{ + type: "text"; + fontSize: number; + fontFamily: FontFamilyValues; + text: string; + textAlign: TextAlign; + verticalAlign: VerticalAlign; + containerId: ExcalidrawGenericElement["id"] | null; + originalText: string; + /** + * If `true` the width will fit the text. If `false`, the text will + * wrap to fit the width. + * + * @default true + */ + autoResize: boolean; + /** + * Unitless line height (aligned to W3C). To get line height in px, multiply + * with font size (using `getLineHeightInPx` helper). + */ + lineHeight: number & { _brand: "unitlessLineHeight" }; + }>; + +export type ExcalidrawBindableElement = + | ExcalidrawRectangleElement + | ExcalidrawDiamondElement + | ExcalidrawEllipseElement + | ExcalidrawTextElement + | ExcalidrawImageElement + | ExcalidrawIframeElement + | ExcalidrawEmbeddableElement + | ExcalidrawFrameElement + | ExcalidrawMagicFrameElement; + +export type ExcalidrawTextContainer = + | ExcalidrawRectangleElement + | ExcalidrawDiamondElement + | ExcalidrawEllipseElement + | ExcalidrawArrowElement; + +export type ExcalidrawTextElementWithContainer = { + containerId: ExcalidrawTextContainer["id"]; +} & ExcalidrawTextElement; + +export type FixedPoint = [number, number]; + +export type PointBinding = { + elementId: ExcalidrawBindableElement["id"]; + focus: number; + gap: number; +}; + +export type FixedPointBinding = Merge< + PointBinding, + { + // Represents the fixed point binding information in form of a vertical and + // horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio + // gives the user selected fixed point by multiplying the bound element width + // with fixedPoint[0] and the bound element height with fixedPoint[1] to get the + // bound element-local point coordinate. + fixedPoint: FixedPoint; + } +>; + +export type Arrowhead = + | "arrow" + | "bar" + | "dot" // legacy. Do not use for new elements. + | "circle" + | "circle_outline" + | "triangle" + | "triangle_outline" + | "diamond" + | "diamond_outline" + | "crowfoot_one" + | "crowfoot_many" + | "crowfoot_one_or_many"; + +export type ExcalidrawLinearElement = _ExcalidrawElementBase & + Readonly<{ + type: "line" | "arrow"; + points: readonly LocalPoint[]; + lastCommittedPoint: LocalPoint | null; + startBinding: PointBinding | null; + endBinding: PointBinding | null; + startArrowhead: Arrowhead | null; + endArrowhead: Arrowhead | null; + }>; + +export type FixedSegment = { + start: LocalPoint; + end: LocalPoint; + index: number; +}; + +export type ExcalidrawArrowElement = ExcalidrawLinearElement & + Readonly<{ + type: "arrow"; + elbowed: boolean; + }>; + +export type ExcalidrawElbowArrowElement = Merge< + ExcalidrawArrowElement, + { + elbowed: true; + startBinding: FixedPointBinding | null; + endBinding: FixedPointBinding | null; + fixedSegments: readonly FixedSegment[] | null; + /** + * Marks that the 3rd point should be used as the 2nd point of the arrow in + * order to temporarily hide the first segment of the arrow without losing + * the data from the points array. It allows creating the expected arrow + * path when the arrow with fixed segments is bound on a horizontal side and + * moved to a vertical and vica versa. + */ + startIsSpecial: boolean | null; + /** + * Marks that the 3rd point backwards from the end should be used as the 2nd + * point of the arrow in order to temporarily hide the last segment of the + * arrow without losing the data from the points array. It allows creating + * the expected arrow path when the arrow with fixed segments is bound on a + * horizontal side and moved to a vertical and vica versa. + */ + endIsSpecial: boolean | null; + } +>; + +export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & + Readonly<{ + type: "freedraw"; + points: readonly LocalPoint[]; + pressures: readonly number[]; + simulatePressure: boolean; + lastCommittedPoint: LocalPoint | null; + }>; + +export type FileId = string & { _brand: "FileId" }; + +export type ExcalidrawElementType = ExcalidrawElement["type"]; + +/** + * Map of excalidraw elements. + * Unspecified whether deleted or non-deleted. + * Can be a subset of Scene elements. + */ +export type ElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement>; + +/** + * Map of non-deleted elements. + * Can be a subset of Scene elements. + */ +export type NonDeletedElementsMap = Map< + ExcalidrawElement["id"], + NonDeletedExcalidrawElement +> & + MakeBrand<"NonDeletedElementsMap">; + +/** + * Map of all excalidraw Scene elements, including deleted. + * Not a subset. Use this type when you need access to current Scene elements. + */ +export type SceneElementsMap = Map< + ExcalidrawElement["id"], + Ordered<ExcalidrawElement> +> & + MakeBrand<"SceneElementsMap">; + +/** + * Map of all non-deleted Scene elements. + * Not a subset. Use this type when you need access to current Scene elements. + */ +export type NonDeletedSceneElementsMap = Map< + ExcalidrawElement["id"], + Ordered<NonDeletedExcalidrawElement> +> & + MakeBrand<"NonDeletedSceneElementsMap">; + +export type ElementsMapOrArray = + | readonly ExcalidrawElement[] + | Readonly<ElementsMap>; diff --git a/packages/excalidraw/element/utils.ts b/packages/excalidraw/element/utils.ts new file mode 100644 index 0000000..d85cd78 --- /dev/null +++ b/packages/excalidraw/element/utils.ts @@ -0,0 +1,355 @@ +import { getDiamondPoints } from "."; +import type { Curve, LineSegment } from "@excalidraw/math"; +import { + curve, + lineSegment, + pointFrom, + pointFromVector, + rectangle, + vectorFromPoint, + vectorNormalize, + vectorScale, + type GlobalPoint, +} from "@excalidraw/math"; +import { getCornerRadius } from "../shapes"; +import type { + ExcalidrawDiamondElement, + ExcalidrawRectanguloidElement, +} from "./types"; + +/** + * Get the building components of a rectanguloid element in the form of + * line segments and curves. + * + * @param element Target rectanguloid element + * @param offset Optional offset to expand the rectanguloid shape + * @returns Tuple of line segments (0) and curves (1) + */ +export function deconstructRectanguloidElement( + element: ExcalidrawRectanguloidElement, + offset: number = 0, +): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { + const roundness = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + + if (roundness <= 0) { + const r = rectangle( + pointFrom(element.x - offset, element.y - offset), + pointFrom( + element.x + element.width + offset, + element.y + element.height + offset, + ), + ); + + const top = lineSegment<GlobalPoint>( + pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]), + pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]), + ); + const right = lineSegment<GlobalPoint>( + pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness), + pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness), + ); + const bottom = lineSegment<GlobalPoint>( + pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]), + pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]), + ); + const left = lineSegment<GlobalPoint>( + pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness), + pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness), + ); + const sides = [top, right, bottom, left]; + + return [sides, []]; + } + + const center = pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + const r = rectangle( + pointFrom(element.x, element.y), + pointFrom(element.x + element.width, element.y + element.height), + ); + + const top = lineSegment<GlobalPoint>( + pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]), + pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]), + ); + const right = lineSegment<GlobalPoint>( + pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness), + pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness), + ); + const bottom = lineSegment<GlobalPoint>( + pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]), + pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]), + ); + const left = lineSegment<GlobalPoint>( + pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness), + pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness), + ); + + const offsets = [ + vectorScale( + vectorNormalize( + vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center), + ), + offset, + ), // TOP LEFT + vectorScale( + vectorNormalize( + vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center), + ), + offset, + ), //TOP RIGHT + vectorScale( + vectorNormalize( + vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center), + ), + offset, + ), // BOTTOM RIGHT + vectorScale( + vectorNormalize( + vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center), + ), + offset, + ), // BOTTOM LEFT + ]; + + const corners = [ + curve( + pointFromVector(offsets[0], left[1]), + pointFromVector( + offsets[0], + pointFrom<GlobalPoint>( + left[1][0] + (2 / 3) * (r[0][0] - left[1][0]), + left[1][1] + (2 / 3) * (r[0][1] - left[1][1]), + ), + ), + pointFromVector( + offsets[0], + pointFrom<GlobalPoint>( + top[0][0] + (2 / 3) * (r[0][0] - top[0][0]), + top[0][1] + (2 / 3) * (r[0][1] - top[0][1]), + ), + ), + pointFromVector(offsets[0], top[0]), + ), // TOP LEFT + curve( + pointFromVector(offsets[1], top[1]), + pointFromVector( + offsets[1], + pointFrom<GlobalPoint>( + top[1][0] + (2 / 3) * (r[1][0] - top[1][0]), + top[1][1] + (2 / 3) * (r[0][1] - top[1][1]), + ), + ), + pointFromVector( + offsets[1], + pointFrom<GlobalPoint>( + right[0][0] + (2 / 3) * (r[1][0] - right[0][0]), + right[0][1] + (2 / 3) * (r[0][1] - right[0][1]), + ), + ), + pointFromVector(offsets[1], right[0]), + ), // TOP RIGHT + curve( + pointFromVector(offsets[2], right[1]), + pointFromVector( + offsets[2], + pointFrom<GlobalPoint>( + right[1][0] + (2 / 3) * (r[1][0] - right[1][0]), + right[1][1] + (2 / 3) * (r[1][1] - right[1][1]), + ), + ), + pointFromVector( + offsets[2], + pointFrom<GlobalPoint>( + bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]), + bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]), + ), + ), + pointFromVector(offsets[2], bottom[1]), + ), // BOTTOM RIGHT + curve( + pointFromVector(offsets[3], bottom[0]), + pointFromVector( + offsets[3], + pointFrom<GlobalPoint>( + bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]), + bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]), + ), + ), + pointFromVector( + offsets[3], + pointFrom<GlobalPoint>( + left[0][0] + (2 / 3) * (r[0][0] - left[0][0]), + left[0][1] + (2 / 3) * (r[1][1] - left[0][1]), + ), + ), + pointFromVector(offsets[3], left[0]), + ), // BOTTOM LEFT + ]; + + const sides = [ + lineSegment<GlobalPoint>(corners[0][3], corners[1][0]), + lineSegment<GlobalPoint>(corners[1][3], corners[2][0]), + lineSegment<GlobalPoint>(corners[2][3], corners[3][0]), + lineSegment<GlobalPoint>(corners[3][3], corners[0][0]), + ]; + + return [sides, corners]; +} + +/** + * Get the 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 segments (0) and curves (1) + */ +export function deconstructDiamondElement( + element: ExcalidrawDiamondElement, + offset: number = 0, +): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { + const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = + getDiamondPoints(element); + const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element); + const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element); + + if (element.roundness?.type == null) { + const [top, right, bottom, left]: GlobalPoint[] = [ + pointFrom(element.x + topX, element.y + topY - offset), + pointFrom(element.x + rightX + offset, element.y + rightY), + pointFrom(element.x + bottomX, element.y + bottomY + offset), + pointFrom(element.x + leftX - offset, element.y + leftY), + ]; + + // Create the line segment parts of the diamond + // NOTE: Horizontal and vertical seems to be flipped here + const topRight = lineSegment<GlobalPoint>( + pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius), + pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius), + ); + const bottomRight = lineSegment<GlobalPoint>( + pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius), + pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius), + ); + const bottomLeft = lineSegment<GlobalPoint>( + pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius), + pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius), + ); + const topLeft = lineSegment<GlobalPoint>( + pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius), + pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius), + ); + + return [[topRight, bottomRight, bottomLeft, topLeft], []]; + } + + const center = pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + const [top, right, bottom, left]: GlobalPoint[] = [ + pointFrom(element.x + topX, element.y + topY), + pointFrom(element.x + rightX, element.y + rightY), + pointFrom(element.x + bottomX, element.y + bottomY), + pointFrom(element.x + leftX, element.y + leftY), + ]; + + const offsets = [ + vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT + vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM + vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT + vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP + ]; + + const corners = [ + curve( + pointFromVector( + offsets[0], + pointFrom<GlobalPoint>( + right[0] - verticalRadius, + right[1] - horizontalRadius, + ), + ), + pointFromVector(offsets[0], right), + pointFromVector(offsets[0], right), + pointFromVector( + offsets[0], + pointFrom<GlobalPoint>( + right[0] - verticalRadius, + right[1] + horizontalRadius, + ), + ), + ), // RIGHT + curve( + pointFromVector( + offsets[1], + pointFrom<GlobalPoint>( + bottom[0] + verticalRadius, + bottom[1] - horizontalRadius, + ), + ), + pointFromVector(offsets[1], bottom), + pointFromVector(offsets[1], bottom), + pointFromVector( + offsets[1], + pointFrom<GlobalPoint>( + bottom[0] - verticalRadius, + bottom[1] - horizontalRadius, + ), + ), + ), // BOTTOM + curve( + pointFromVector( + offsets[2], + pointFrom<GlobalPoint>( + left[0] + verticalRadius, + left[1] + horizontalRadius, + ), + ), + pointFromVector(offsets[2], left), + pointFromVector(offsets[2], left), + pointFromVector( + offsets[2], + pointFrom<GlobalPoint>( + left[0] + verticalRadius, + left[1] - horizontalRadius, + ), + ), + ), // LEFT + curve( + pointFromVector( + offsets[3], + pointFrom<GlobalPoint>( + top[0] - verticalRadius, + top[1] + horizontalRadius, + ), + ), + pointFromVector(offsets[3], top), + pointFromVector(offsets[3], top), + pointFromVector( + offsets[3], + pointFrom<GlobalPoint>( + top[0] + verticalRadius, + top[1] + horizontalRadius, + ), + ), + ), // TOP + ]; + + const sides = [ + lineSegment<GlobalPoint>(corners[0][3], corners[1][0]), + lineSegment<GlobalPoint>(corners[1][3], corners[2][0]), + lineSegment<GlobalPoint>(corners[2][3], corners[3][0]), + lineSegment<GlobalPoint>(corners[3][3], corners[0][0]), + ]; + + return [sides, corners]; +} |
