From 1d1e1ff5aa58d5a5abf1acb966177dd12ed072a9 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sat, 5 Oct 2024 16:51:24 +0200 Subject: [PATCH 001/283] Remove dead code Signed-off-by: Mark Tolmacs --- packages/excalidraw/components/App.tsx | 14 -------- .../excalidraw/element/linearElementEditor.ts | 16 ---------- .../excalidraw/renderer/interactiveScene.ts | 32 +------------------ 3 files changed, 1 insertion(+), 61 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 8a976dd8bfbd..126020989512 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -6010,20 +6010,6 @@ class App extends React.Component { }, }); } - - if ( - !LinearElementEditor.arePointsEqual( - this.state.selectedLinearElement.segmentMidPointHoveredCoords, - segmentMidPointHoveredCoords, - ) - ) { - this.setState({ - selectedLinearElement: { - ...this.state.selectedLinearElement, - segmentMidPointHoveredCoords, - }, - }); - } } else { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); } diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 0d1db77331c2..cb4f533309c5 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -101,7 +101,6 @@ export class LinearElementEditor { | "keep"; public readonly endBindingElement: ExcalidrawBindableElement | null | "keep"; public readonly hoverPointIndex: number; - public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly elbowed: boolean; constructor(element: NonDeleted) { @@ -131,7 +130,6 @@ export class LinearElementEditor { }, }; this.hoverPointIndex = -1; - this.segmentMidPointHoveredCoords = null; this.elbowed = isElbowArrow(element) && element.elbowed; } @@ -586,20 +584,6 @@ export class LinearElementEditor { const threshold = LinearElementEditor.POINT_HANDLE_SIZE / 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); diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 7dc84db9967e..f717365673d5 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -506,37 +506,7 @@ const renderLinearPointHandles = ( ).filter((midPoint): midPoint is GlobalPoint => midPoint !== null); midPoints.forEach((segmentMidPoint) => { - if ( - appState?.selectedLinearElement?.segmentMidPointHoveredCoords && - LinearElementEditor.arePointsEqual( - segmentMidPoint, - appState.selectedLinearElement.segmentMidPointHoveredCoords, - ) - ) { - // The order of renderingSingleLinearPoint and highLight points is different - // inside vs outside editor as hover states are different, - // in editor when hovered the original point is not visible as hover state fully covers it whereas outside the - // editor original point is visible and hover state is just an outer circle. - if (appState.editingLinearElement) { - renderSingleLinearPoint( - context, - appState, - segmentMidPoint, - radius, - false, - ); - highlightPoint(segmentMidPoint, context, appState); - } else { - highlightPoint(segmentMidPoint, context, appState); - renderSingleLinearPoint( - context, - appState, - segmentMidPoint, - radius, - false, - ); - } - } else if (appState.editingLinearElement || points.length === 2) { + if (appState.editingLinearElement || points.length === 2) { renderSingleLinearPoint( context, appState, From 8920dfe5d0f4eea935bb82865efae6fc2e7b4f23 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sat, 5 Oct 2024 17:45:23 +0200 Subject: [PATCH 002/283] Midpoint rendering for elbow arrows Signed-off-by: Mark Tolmacs --- packages/excalidraw/components/App.tsx | 30 +++++++++++++++++++ .../excalidraw/element/linearElementEditor.ts | 17 ++++++----- .../excalidraw/renderer/interactiveScene.ts | 6 +++- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 126020989512..52baa7d4e3ee 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -186,6 +186,7 @@ import type { ExcalidrawNonSelectionElement, ExcalidrawArrowElement, NonDeletedSceneElementsMap, + ExcalidrawElbowArrowElement, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -7709,6 +7710,35 @@ class App extends React.Component { }); return; + } else if ( + isElbowArrow( + LinearElementEditor.getElement( + this.state.selectedLinearElement.elementId, + elementsMap, + )!, + ) + ) { + // pointerCoords + const arrow = LinearElementEditor.getElement( + this.state.selectedLinearElement.elementId, + elementsMap, + ) as ExcalidrawElbowArrowElement; + const { index: segmentIdx } = + this.state.selectedLinearElement.pointerDownState.segmentMidpoint; + const startPoint = arrow.points[segmentIdx! - 1]; + const endPoint = arrow.points[segmentIdx!]; + const nextPoints = Array.from(arrow.points); + if (startPoint[0] === endPoint[0]) { + // HORIZONTAL + nextPoints[segmentIdx! - 1][0] = pointerCoords.x; + nextPoints[segmentIdx!][0] = pointerCoords.x; + } else { + // VERTICAL + nextPoints[segmentIdx! - 1][1] = pointerCoords.x; + nextPoints[segmentIdx!][1] = pointerCoords.x; + } + console.log("???"); + mutateElbowArrow(arrow, elementsMap, nextPoints); } else if ( linearElementEditor.pointerDownState.segmentMidpoint.value !== null && !linearElementEditor.pointerDownState.segmentMidpoint.added diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index cb4f533309c5..17fe1d5761db 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -492,6 +492,7 @@ export class LinearElementEditor { // Since its not needed outside editor unless 2 pointer lines or bound text if ( + !isElbowArrow(element) && !appState.editingLinearElement && element.points.length > 2 && !boundText @@ -577,7 +578,11 @@ export class LinearElementEditor { element, elementsMap, ); - if (points.length >= 3 && !appState.editingLinearElement) { + if ( + points.length >= 3 && + !appState.editingLinearElement && + !isElbowArrow(element) + ) { return null; } @@ -590,7 +595,7 @@ export class LinearElementEditor { while (index < midPoints.length) { if (midPoints[index] !== null) { const distance = pointDistance( - pointFrom(midPoints[index]![0], midPoints[index]![1]), + midPoints[index]!, pointFrom(scenePointer.x, scenePointer.y), ); if (distance <= threshold) { @@ -731,12 +736,8 @@ export class LinearElementEditor { segmentMidpoint, elementsMap, ); - } - if (event.altKey && appState.editingLinearElement) { - if ( - linearElementEditor.lastUncommittedPoint == null && - !isElbowArrow(element) - ) { + } else if (event.altKey && appState.editingLinearElement) { + if (linearElementEditor.lastUncommittedPoint == null) { mutateElement(element, { points: [ ...element.points, diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index f717365673d5..50b5498cdacd 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -506,7 +506,11 @@ const renderLinearPointHandles = ( ).filter((midPoint): midPoint is GlobalPoint => midPoint !== null); midPoints.forEach((segmentMidPoint) => { - if (appState.editingLinearElement || points.length === 2) { + if ( + isElbowArrow(element) || + appState.editingLinearElement || + points.length === 2 + ) { renderSingleLinearPoint( context, appState, From 0eab32d2844597f67273945b51e55372d667d438 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sat, 5 Oct 2024 21:37:05 +0200 Subject: [PATCH 003/283] Wired through the fixed segments --- packages/excalidraw/components/App.tsx | 94 +++++++++++++------ .../excalidraw/element/linearElementEditor.ts | 15 ++- packages/excalidraw/element/routing.ts | 10 +- packages/excalidraw/element/types.ts | 1 + 4 files changed, 83 insertions(+), 37 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 52baa7d4e3ee..33cbfbefe0b1 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7665,6 +7665,8 @@ class App extends React.Component { const linearElementEditor = this.state.editingLinearElement || this.state.selectedLinearElement; + let didDrag = false; + if ( LinearElementEditor.shouldAddMidpoint( this.state.selectedLinearElement, @@ -7716,29 +7718,59 @@ class App extends React.Component { this.state.selectedLinearElement.elementId, elementsMap, )!, - ) + ) && + this.state.selectedLinearElement.pointerDownState.segmentMidpoint + .index ) { - // pointerCoords const arrow = LinearElementEditor.getElement( this.state.selectedLinearElement.elementId, elementsMap, ) as ExcalidrawElbowArrowElement; const { index: segmentIdx } = this.state.selectedLinearElement.pointerDownState.segmentMidpoint; - const startPoint = arrow.points[segmentIdx! - 1]; - const endPoint = arrow.points[segmentIdx!]; - const nextPoints = Array.from(arrow.points); - if (startPoint[0] === endPoint[0]) { - // HORIZONTAL - nextPoints[segmentIdx! - 1][0] = pointerCoords.x; - nextPoints[segmentIdx!][0] = pointerCoords.x; - } else { - // VERTICAL - nextPoints[segmentIdx! - 1][1] = pointerCoords.x; - nextPoints[segmentIdx!][1] = pointerCoords.x; - } - console.log("???"); - mutateElbowArrow(arrow, elementsMap, nextPoints); + const startPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + segmentIdx! - 1, + elementsMap, + ); + const endPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + segmentIdx!, + elementsMap, + ); + const isHorizontal = startPoint[0] === endPoint[0]; + LinearElementEditor.movePoints( + arrow, + [ + { + index: segmentIdx! - 1, + point: LinearElementEditor.pointFromAbsoluteCoords( + arrow, + pointFrom( + isHorizontal ? pointerCoords.x : startPoint[0], + !isHorizontal ? pointerCoords.y : startPoint[1], + ), + elementsMap, + ), + isDragging: true, + }, + { + index: segmentIdx!, + point: LinearElementEditor.pointFromAbsoluteCoords( + arrow, + pointFrom( + isHorizontal ? pointerCoords.x : endPoint[0], + !isHorizontal ? pointerCoords.y : endPoint[1], + ), + elementsMap, + ), + isDragging: true, + }, + ], + elementsMap, + ); + didDrag = true; } else if ( linearElementEditor.pointerDownState.segmentMidpoint.value !== null && !linearElementEditor.pointerDownState.segmentMidpoint.added @@ -7746,20 +7778,21 @@ class App extends React.Component { return; } - const didDrag = LinearElementEditor.handlePointDragging( - event, - this, - pointerCoords.x, - pointerCoords.y, - (element, pointsSceneCoords) => { - this.maybeSuggestBindingsForLinearElementAtCoords( - element, - pointsSceneCoords, - ); - }, - linearElementEditor, - this.scene, - ); + didDrag = + LinearElementEditor.handlePointDragging( + event, + this, + pointerCoords.x, + pointerCoords.y, + (element, pointsSceneCoords) => { + this.maybeSuggestBindingsForLinearElementAtCoords( + element, + pointsSceneCoords, + ); + }, + linearElementEditor, + this.scene, + ) || didDrag; if (didDrag) { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; @@ -7775,6 +7808,7 @@ class App extends React.Component { }, }); } + if (!this.state.selectedLinearElement.isDragging) { this.setState({ selectedLinearElement: { diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 17fe1d5761db..e912fe1e7dc1 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1266,10 +1266,10 @@ export class LinearElementEditor { otherUpdates?: { startBinding?: PointBinding | null; endBinding?: PointBinding | null; + fixedSegments?: number[] | null; }, options?: { changedElements?: Map; - isDragging?: boolean; }, ) { const { points } = element; @@ -1432,6 +1432,7 @@ export class LinearElementEditor { otherUpdates?: { startBinding?: PointBinding | null; endBinding?: PointBinding | null; + fixedSegments?: number[] | null; }, options?: { changedElements?: Map; @@ -1439,24 +1440,28 @@ export class LinearElementEditor { }, ) { if (isElbowArrow(element)) { - const bindings: { + const updates: { startBinding?: FixedPointBinding | null; endBinding?: FixedPointBinding | null; + fixedSegments?: number[] | null; } = {}; if (otherUpdates?.startBinding !== undefined) { - bindings.startBinding = + updates.startBinding = otherUpdates.startBinding !== null && isFixedPointBinding(otherUpdates.startBinding) ? otherUpdates.startBinding : null; } if (otherUpdates?.endBinding !== undefined) { - bindings.endBinding = + updates.endBinding = otherUpdates.endBinding !== null && isFixedPointBinding(otherUpdates.endBinding) ? otherUpdates.endBinding : null; } + if (otherUpdates?.fixedSegments) { + updates.fixedSegments = otherUpdates.fixedSegments.toSorted(); + } const mergedElementsMap = options?.changedElements ? toBrandedType( @@ -1469,7 +1474,7 @@ export class LinearElementEditor { mergedElementsMap, nextPoints, vector(offsetX, offsetY), - bindings, + updates, { isDragging: options?.isDragging, }, diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/routing.ts index c8b1c2d4316c..048a9b245b08 100644 --- a/packages/excalidraw/element/routing.ts +++ b/packages/excalidraw/element/routing.ts @@ -86,14 +86,17 @@ export const mutateElbowArrow = ( elementsMap, nextPoints, offset, + otherUpdates?.fixedSegments, options, ); + if (update) { mutateElement( arrow, { ...otherUpdates, ...update, + //points: nextPoints, angle: 0 as Radians, }, options?.informMutation, @@ -107,13 +110,16 @@ export const updateElbowArrow = ( arrow: ExcalidrawElbowArrowElement, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, nextPoints: readonly LocalPoint[], - offset?: Vector, + offset?: Readonly, + fixSegments?: number[] | null, options?: { isDragging?: boolean; disableBinding?: boolean; - informMutation?: boolean; }, ): ElementUpdate | null => { + const nextFixedSegments = Array.from( + new Set(arrow.fixedSegments).union(new Set(fixSegments ?? [])), + ).toSorted(); const origStartGlobalPoint: GlobalPoint = pointTranslate( pointTranslate( nextPoints[0], diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 5ebf505444a1..31f184599fa1 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -317,6 +317,7 @@ export type ExcalidrawElbowArrowElement = Merge< elbowed: true; startBinding: FixedPointBinding | null; endBinding: FixedPointBinding | null; + fixedSegments: number[] | null; } >; From 900bec0dd22de3fe3314a9a49c13ccf26a59242f Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sun, 6 Oct 2024 20:59:26 +0200 Subject: [PATCH 004/283] Partial segmented arrow generation --- packages/excalidraw/element/binding.ts | 2 +- packages/excalidraw/element/heading.ts | 5 + .../excalidraw/element/linearElementEditor.ts | 2 +- packages/excalidraw/element/routing.ts | 201 +++++++++++++++--- 4 files changed, 183 insertions(+), 27 deletions(-) diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index d0faa4269d1b..ccf92905b76e 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -2259,7 +2259,7 @@ const getGlobalFixedPoints = ( export const getArrowLocalFixedPoints = ( arrow: ExcalidrawElbowArrowElement, elementsMap: ElementsMap, -) => { +): [LocalPoint, LocalPoint] => { const [startPoint, endPoint] = getGlobalFixedPoints(arrow, elementsMap); return [ diff --git a/packages/excalidraw/element/heading.ts b/packages/excalidraw/element/heading.ts index c17a077fcb90..21e95ec02a9a 100644 --- a/packages/excalidraw/element/heading.ts +++ b/packages/excalidraw/element/heading.ts @@ -55,6 +55,11 @@ export const vectorToHeading = (vec: Vector): Heading => { 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. diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index e912fe1e7dc1..cde44fec7dc5 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1460,7 +1460,7 @@ export class LinearElementEditor { : null; } if (otherUpdates?.fixedSegments) { - updates.fixedSegments = otherUpdates.fixedSegments.toSorted(); + updates.fixedSegments = otherUpdates.fixedSegments.sort(); } const mergedElementsMap = options?.changedElements diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/routing.ts index 048a9b245b08..838a4d30c3ea 100644 --- a/packages/excalidraw/element/routing.ts +++ b/packages/excalidraw/element/routing.ts @@ -39,12 +39,18 @@ import { import type { ElementUpdate } from "./mutateElement"; import { mutateElement } from "./mutateElement"; import { isBindableElement, isRectanguloidElement } from "./typeChecks"; +import { + type ExcalidrawElbowArrowElement, + type NonDeletedSceneElementsMap, + type SceneElementsMap, +} from "./types"; import type { - ExcalidrawElbowArrowElement, - NonDeletedSceneElementsMap, - SceneElementsMap, + Arrowhead, + ElementsMap, + ExcalidrawBindableElement, + ExcalidrawElement, + FixedPointBinding, } from "./types"; -import type { ElementsMap, ExcalidrawBindableElement } from "./types"; type GridAddress = [number, number] & { _brand: "gridaddress" }; @@ -84,9 +90,9 @@ export const mutateElbowArrow = ( const update = updateElbowArrow( arrow, elementsMap, - nextPoints, - offset, + [nextPoints[0], nextPoints[nextPoints.length - 1]], otherUpdates?.fixedSegments, + offset, options, ); @@ -109,31 +115,173 @@ export const mutateElbowArrow = ( export const updateElbowArrow = ( arrow: ExcalidrawElbowArrowElement, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - nextPoints: readonly LocalPoint[], - offset?: Readonly, + targetPoints: readonly [LocalPoint, LocalPoint], fixSegments?: number[] | null, + offset?: Readonly, options?: { isDragging?: boolean; disableBinding?: boolean; }, ): ElementUpdate | null => { - const nextFixedSegments = Array.from( - new Set(arrow.fixedSegments).union(new Set(fixSegments ?? [])), - ).toSorted(); - const origStartGlobalPoint: GlobalPoint = pointTranslate( - pointTranslate( - nextPoints[0], - vector(arrow.x, arrow.y), - ), + // Merge a set of arrow points with offet, new points overriding any old points + const nextPoints = Array.from(arrow.points); + nextPoints[0] = pointTranslate(targetPoints[0], offset); + nextPoints[nextPoints.length - 1] = pointTranslate( + targetPoints[targetPoints.length - 1], offset, ); - const origEndGlobalPoint: GlobalPoint = pointTranslate( - pointTranslate( - nextPoints[nextPoints.length - 1], - vector(arrow.x, arrow.y), - ), - offset, + + // Segment index + const nextFixedSegments: number[] = Array.from( + new Set([...(arrow.fixedSegments ?? []), ...(fixSegments ?? [])]), + ).sort(); + + // Determine the arrow parts based on fixed segments + let prevIdx = 0; + const parts = nextFixedSegments.map((segmentIdx) => { + const ret = [prevIdx, segmentIdx]; + prevIdx = segmentIdx - 1; + return ret; + }); + parts.push([prevIdx, nextPoints.length - 1]); + + // Generate the part ends + const temporaryElementsMap = new Map>( + elementsMap, ); + const points = parts.flatMap(([startIdx, endIdx], id) => { + const partGlobalPoints = nextPoints.map((p) => + pointFrom(arrow.x + p[0], arrow.y + p[1]), + ); + const startGlobalCoords = partGlobalPoints[startIdx]; + const endGlobalCoords = partGlobalPoints[endIdx]; + const startDirection = vectorToHeading( + vectorFromPoint(endGlobalCoords, startGlobalCoords), + ); + const endDirection = vectorToHeading( + vectorFromPoint(startGlobalCoords, endGlobalCoords), + ); + if (startIdx !== 0) { + temporaryElementsMap.set(`temp-start-${id}`, { + id: `temp-start-${id}`, + type: "rectangle", + x: startGlobalCoords[0], + y: startGlobalCoords[1], + width: 10, + height: 10, + }); + } + if (endIdx !== nextPoints.length - 1) { + temporaryElementsMap.set(`temp-end-${id}`, { + id: `temp-end-${id}`, + type: "rectangle", + x: endGlobalCoords[0], + y: endGlobalCoords[1], + width: 10, + height: 10, + }); + } + + return ( + routeElbowArrow( + { + x: partGlobalPoints[startIdx][0], + y: partGlobalPoints[startIdx][1], + startArrowhead: startIdx === 0 ? arrow.startArrowhead : null, + endArrowhead: + endIdx === nextPoints.length - 1 ? arrow.endArrowhead : null, + startBinding: + startIdx === 0 + ? arrow.startBinding + : { + elementId: `temp-start-${id}`, + fixedPoint: [ + compareHeading(startDirection, HEADING_LEFT) + ? 0 + : compareHeading(startDirection, HEADING_RIGHT) + ? 1 + : 0.5001, + compareHeading(startDirection, HEADING_UP) + ? 0 + : compareHeading(startDirection, HEADING_DOWN) + ? 1 + : 0.5001, + ], + focus: 0, + gap: 0, + }, + endBinding: + endIdx === 0 + ? arrow.endBinding + : { + elementId: `temp-end-${id}`, + fixedPoint: [ + compareHeading(endDirection, HEADING_LEFT) + ? 0 + : compareHeading(endDirection, HEADING_RIGHT) + ? 1 + : 0.5001, + compareHeading(endDirection, HEADING_UP) + ? 0 + : compareHeading(endDirection, HEADING_DOWN) + ? 1 + : 0.5001, + ], + focus: 0, + gap: 0, + }, + }, + temporaryElementsMap as SceneElementsMap, + [ + pointFrom(0, 0), + pointFrom( + partGlobalPoints[endIdx][0] - partGlobalPoints[startIdx][0], + partGlobalPoints[endIdx][1] - partGlobalPoints[startIdx][1], + ), + ], + options, + ) ?? [] + ); + }); + + return points + ? normalizedArrowElementUpdate(points, 0, 0, nextFixedSegments) + : null; +}; + +/** + * Generate the elbow arrow segments + * + * @param arrow + * @param elementsMap + * @param nextPoints + * @param options + * @returns + */ +const routeElbowArrow = ( + arrow: { + x: number; + y: number; + startBinding: FixedPointBinding | null; + endBinding: FixedPointBinding | null; + startArrowhead: Arrowhead | null; + endArrowhead: Arrowhead | null; + }, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + nextPoints: readonly LocalPoint[], + options?: { + isDragging?: boolean; + disableBinding?: boolean; + }, +): GlobalPoint[] | null => { + 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)); const startElement = arrow.startBinding && @@ -335,7 +483,7 @@ export const updateElbowArrow = ( startDongle && points.unshift(startGlobalPoint); endDongle && points.push(endGlobalPoint); - return normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0); + return simplifyElbowArrowPoints(points); } return null; @@ -938,14 +1086,16 @@ const getBindableElementForId = ( const normalizedArrowElementUpdate = ( global: GlobalPoint[], - externalOffsetX?: number, - externalOffsetY?: number, + externalOffsetX: number, + externalOffsetY: number, + nextFixedSegments: number[], ): { points: LocalPoint[]; x: number; y: number; width: number; height: number; + fixedSegments: number[] | null; } => { const offsetX = global[0][0]; const offsetY = global[0][1]; @@ -961,6 +1111,7 @@ const normalizedArrowElementUpdate = ( points, x: offsetX + (externalOffsetX ?? 0), y: offsetY + (externalOffsetY ?? 0), + fixedSegments: nextFixedSegments.length ? nextFixedSegments : null, ...getSizeFromPoints(points), }; }; From e1717e14cf5c039ccaa8df654880be6307adab9d Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sun, 6 Oct 2024 21:42:34 +0200 Subject: [PATCH 005/283] Fix end binding Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/routing.ts | 2 +- packages/excalidraw/tests/__snapshots__/history.test.tsx.snap | 1 + .../tests/__snapshots__/regressionTests.test.tsx.snap | 4 ---- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/routing.ts index 838a4d30c3ea..39a09e1c712d 100644 --- a/packages/excalidraw/element/routing.ts +++ b/packages/excalidraw/element/routing.ts @@ -211,7 +211,7 @@ export const updateElbowArrow = ( gap: 0, }, endBinding: - endIdx === 0 + endIdx === nextPoints.length - 1 ? arrow.endBinding : { elementId: `temp-end-${id}`, diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index b473240946ec..a4129ed0f94f 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -10879,6 +10879,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "gap": "3.53708", }, "fillStyle": "solid", + "fixedSegments": null, "frameId": null, "groupIds": [], "height": "448.10100", diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index 6e1f5350368f..6dea2680239b 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -8451,7 +8451,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "x": 0, "y": 0, }, - "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, "startBindingElement": "keep", }, @@ -8671,7 +8670,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "x": 0, "y": 0, }, - "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, "startBindingElement": "keep", }, @@ -9081,7 +9079,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "x": 0, "y": 0, }, - "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, "startBindingElement": "keep", }, @@ -9478,7 +9475,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "x": 0, "y": 0, }, - "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, "startBindingElement": "keep", }, From daa9838e8cd58d97e869ef615fbfd8c041a1c00f Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 7 Oct 2024 10:14:17 +0200 Subject: [PATCH 006/283] Disable errorneous fonts.css load attempt in dev mode Signed-off-by: Mark Tolmacs --- excalidraw-app/index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 02f153d8c38e..2447e25c299a 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -130,12 +130,14 @@ <% } %> + <% if (typeof PROD != 'undefined' && PROD == true) { %> + <% } %> From 664f43b615ed69eb2e828f3ed8e95b3252e20134 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 7 Oct 2024 16:02:53 +0200 Subject: [PATCH 007/283] Disable analytics warning in dev mode --- excalidraw-app/index.html | 62 ++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 2447e25c299a..0dde8e362896 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -220,37 +220,39 @@

Excalidraw

- - - + // if iframe + if (window.self !== window.top) { + scriptEle.addEventListener("load", () => { + if (window.sa_pageview) { + window.window.sa_event(action, { + category: "iframe", + label: "embed", + value: window.location.pathname, + }); + } + }); + } + + + <% } %> From 5bd952d190829e9f5d3176c175b99aca2457ef03 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 8 Oct 2024 07:27:42 +0200 Subject: [PATCH 008/283] Refactor with bad mirror flip --- .../actions/actionDeleteSelected.tsx | 4 +- packages/excalidraw/actions/actionFlip.ts | 12 +-- .../excalidraw/actions/actionProperties.tsx | 70 +++++++-------- packages/excalidraw/components/App.tsx | 65 +++++--------- .../excalidraw/element/linearElementEditor.ts | 31 ++++--- packages/excalidraw/element/mutateElement.ts | 43 +++++++++- packages/excalidraw/element/resizeElements.ts | 14 ++- packages/excalidraw/element/routing.test.tsx | 18 ++-- packages/excalidraw/element/routing.ts | 85 ++++--------------- 9 files changed, 149 insertions(+), 193 deletions(-) diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index b354c44404b5..c7e10cfa8660 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -18,14 +18,12 @@ import { import { updateActiveTool } from "../utils"; import { TrashIcon } from "../components/icons"; import { StoreAction } from "../store"; -import { mutateElbowArrow } from "../element/routing"; const deleteSelectedElements = ( elements: readonly ExcalidrawElement[], appState: AppState, app: AppClassProperties, ) => { - const elementsMap = app.scene.getNonDeletedElementsMap(); const framesToBeDeleted = new Set( getSelectedElements( elements.filter((el) => isFrameLikeElement(el)), @@ -52,7 +50,7 @@ const deleteSelectedElements = ( ? null : bound.endBinding, }); - mutateElbowArrow(bound, elementsMap, bound.points); + mutateElement(bound, { points: bound.points }); } }); } diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 6b75b8facd71..20306ddcaebc 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -25,7 +25,6 @@ import { isElbowArrow, isLinearElement, } from "../element/typeChecks"; -import { mutateElbowArrow } from "../element/routing"; import { mutateElement, newElementWith } from "../element/mutateElement"; export const actionFlipHorizontal = register({ @@ -185,16 +184,7 @@ const flipElements = ( }), ); elbowArrows.forEach((element) => - mutateElbowArrow( - element, - elementsMap, - element.points, - undefined, - undefined, - { - informMutation: false, - }, - ), + mutateElement(element, { points: element.points }, false), ); // --------------------------------------------------------------------------- diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 72ff8896b7a5..68fc8a57fc33 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -113,10 +113,9 @@ import { calculateFixedPointForElbowArrowBinding, getHoveredElementForBinding, } from "../element/binding"; -import { mutateElbowArrow } from "../element/routing"; import { LinearElementEditor } from "../element/linearElementEditor"; import type { LocalPoint } from "../../math"; -import { pointFrom, vector } from "../../math"; +import { pointFrom } from "../../math"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; @@ -1646,45 +1645,40 @@ export const actionChangeArrowType = register({ elementsMap, ); - mutateElbowArrow( - newElement, - elementsMap, - [finalStartPoint, finalEndPoint].map( + mutateElement(newElement, { + points: [finalStartPoint, finalEndPoint].map( (p): LocalPoint => pointFrom(p[0] - newElement.x, p[1] - newElement.y), ), - vector(0, 0), - { - ...(startElement && newElement.startBinding - ? { - startBinding: { - // @ts-ignore TS cannot discern check above - ...newElement.startBinding!, - ...calculateFixedPointForElbowArrowBinding( - newElement, - startElement, - "start", - elementsMap, - ), - }, - } - : {}), - ...(endElement && newElement.endBinding - ? { - endBinding: { - // @ts-ignore TS cannot discern check above - ...newElement.endBinding, - ...calculateFixedPointForElbowArrowBinding( - newElement, - endElement, - "end", - elementsMap, - ), - }, - } - : {}), - }, - ); + ...(startElement && newElement.startBinding + ? { + startBinding: { + // @ts-ignore TS cannot discern check above + ...newElement.startBinding!, + ...calculateFixedPointForElbowArrowBinding( + newElement, + startElement, + "start", + elementsMap, + ), + }, + } + : {}), + ...(endElement && newElement.endBinding + ? { + endBinding: { + // @ts-ignore TS cannot discern check above + ...newElement.endBinding, + ...calculateFixedPointForElbowArrowBinding( + newElement, + endElement, + "end", + elementsMap, + ), + }, + } + : {}), + }); } return newElement; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 33cbfbefe0b1..f797f4cf559a 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -438,7 +438,7 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize"; import { getVisibleSceneBounds } from "../element/bounds"; import { isMaybeMermaidDefinition } from "../mermaid"; import NewElementCanvas from "./canvases/NewElementCanvas"; -import { mutateElbowArrow, updateElbowArrow } from "../element/routing"; +import { updateElbowArrowPoints } from "../element/routing"; import { FlowChartCreator, FlowChartNavigator, @@ -446,7 +446,7 @@ import { } from "../element/flowchart"; import { searchItemInFocusAtom } from "./SearchMenu"; import type { LocalPoint, Radians } from "../../math"; -import { pointFrom, pointDistance, vector } from "../../math"; +import { pointFrom, pointDistance } from "../../math"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -2606,6 +2606,7 @@ class App extends React.Component { this.excalidrawContainerRef.current, EVENT.WHEEL, this.handleWheel, + { passive: false }, ), addEventListener( this.excalidrawContainerRef.current, @@ -3124,7 +3125,7 @@ class App extends React.Component { const endBinding = startEndElements[1] ? el.endBinding : null; return { ...el, - ...updateElbowArrow( + ...updateElbowArrowPoints( { ...el, startBinding, @@ -3143,7 +3144,7 @@ class App extends React.Component { ), ), ), - [el.points[0], el.points[el.points.length - 1]], + { points: el.points }, ), }; } @@ -5634,40 +5635,21 @@ class App extends React.Component { if (isPathALoop(points, this.state.zoom.value)) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } - if (isElbowArrow(multiElement)) { - mutateElbowArrow( - multiElement, - this.scene.getNonDeletedElementsMap(), - [ + // update last uncommitted point + mutateElement( + multiElement, + { + points: [ ...points.slice(0, -1), pointFrom( lastCommittedX + dxFromLastCommitted, lastCommittedY + dyFromLastCommitted, ), ], - undefined, - undefined, - { - isDragging: true, - informMutation: false, - }, - ); - } else { - // update last uncommitted point - mutateElement( - multiElement, - { - points: [ - ...points.slice(0, -1), - pointFrom( - lastCommittedX + dxFromLastCommitted, - lastCommittedY + dyFromLastCommitted, - ), - ], - }, - false, - ); - } + }, + false, + true, + ); // in this path, we're mutating multiElement to reflect // how it will be after adding pointer position as the next point @@ -8091,25 +8073,17 @@ class App extends React.Component { }, false, ); - } else if (points.length > 1 && isElbowArrow(newElement)) { - mutateElbowArrow( - newElement, - elementsMap, - [...points.slice(0, -1), pointFrom(dx, dy)], - vector(0, 0), - undefined, - { - isDragging: true, - informMutation: false, - }, - ); - } else if (points.length === 2) { + } else if ( + points.length === 2 || + (points.length > 1 && isElbowArrow(newElement)) + ) { mutateElement( newElement, { points: [...points.slice(0, -1), pointFrom(dx, dy)], }, false, + true, ); } @@ -9609,6 +9583,7 @@ class App extends React.Component { this.interactiveCanvas.addEventListener( EVENT.TOUCH_START, this.onTouchStart, + { passive: false }, ); this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTouchEnd); // ----------------------------------------------------------------------- diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index cde44fec7dc5..7c99f3d1290e 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -32,7 +32,7 @@ import { getHoveredElementForBinding, isBindingEnabled, } from "./binding"; -import { invariant, toBrandedType, tupleToCoors } from "../utils"; +import { invariant, tupleToCoors } from "../utils"; import { isBindingElement, isElbowArrow, @@ -44,7 +44,6 @@ import { DRAGGING_THRESHOLD } from "../constants"; import type { Mutable } from "../utility-types"; import { ShapeCache } from "../scene/ShapeCache"; import type { Store } from "../store"; -import { mutateElbowArrow } from "./routing"; import type Scene from "../scene/Scene"; import type { Radians } from "../../math"; import { @@ -56,6 +55,7 @@ import { type GlobalPoint, type LocalPoint, pointDistance, + pointTranslate, } from "../../math"; import { getBezierCurveLength, @@ -1444,6 +1444,7 @@ export class LinearElementEditor { startBinding?: FixedPointBinding | null; endBinding?: FixedPointBinding | null; fixedSegments?: number[] | null; + points?: LocalPoint[]; } = {}; if (otherUpdates?.startBinding !== undefined) { updates.startBinding = @@ -1463,21 +1464,23 @@ export class LinearElementEditor { updates.fixedSegments = otherUpdates.fixedSegments.sort(); } - const mergedElementsMap = options?.changedElements - ? toBrandedType( - new Map([...elementsMap, ...options.changedElements]), - ) - : elementsMap; + updates.points = Array.from(nextPoints); + updates.points[0] = pointTranslate( + updates.points[0], + vector(offsetX, offsetY), + ); + updates.points[updates.points.length - 1] = pointTranslate( + updates.points[updates.points.length - 1], + vector(offsetX, offsetY), + ); - mutateElbowArrow( + mutateElement( element, - mergedElementsMap, - nextPoints, - vector(offsetX, offsetY), updates, - { - isDragging: options?.isDragging, - }, + true, + options?.isDragging, + false, + options?.changedElements, ); } else { const nextCoords = getElementPointsCoords(element, nextPoints); diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index ef84854f9aba..926116e5247e 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -1,10 +1,17 @@ -import type { ExcalidrawElement } from "./types"; +import type { + ExcalidrawElement, + OrderedExcalidrawElement, + SceneElementsMap, +} from "./types"; import Scene from "../scene/Scene"; import { getSizeFromPoints } from "../points"; import { randomInteger } from "../random"; -import { getUpdatedTimestamp } from "../utils"; +import { getUpdatedTimestamp, toBrandedType } from "../utils"; import type { Mutable } from "../utility-types"; import { ShapeCache } from "../scene/ShapeCache"; +import { isElbowArrow } from "./typeChecks"; +import { updateElbowArrowPoints } from "./routing"; +import type { Radians } from "../../math"; export type ElementUpdate = Omit< Partial, @@ -19,15 +26,43 @@ export const mutateElement = >( element: TElement, updates: ElementUpdate, informMutation = true, + isDragging = false, + disableBinding = false, + changedElements?: Map, ): TElement => { let didChange = false; // casting to any because can't use `in` operator // (see https://github.com/microsoft/TypeScript/issues/21732) - const { points, fileId } = updates as any; + const { fileId } = updates as any; + const { points } = updates as any; if (typeof points !== "undefined") { - updates = { ...getSizeFromPoints(points), ...updates }; + if (isElbowArrow(element)) { + const mergedElementsMap = toBrandedType( + new Map([ + ...(Scene.getScene(element)?.getNonDeletedElementsMap() ?? []), + ...(changedElements ?? []), + ]), + ); + + updates = { + ...updates, + angle: 0 as Radians, + ...updateElbowArrowPoints( + element, + mergedElementsMap, + // @ts-ignore + updates, + { + isDragging, + disableBinding, + }, + ), + }; + } else { + updates = { ...getSizeFromPoints(points), ...updates }; + } } for (const key in updates) { diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 08ca5543f8a3..9f03d8fc6f81 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -12,6 +12,7 @@ import type { ExcalidrawArrowElement, NonDeletedSceneElementsMap, SceneElementsMap, + ExcalidrawElbowArrowElement, } from "./types"; import type { Mutable } from "../utility-types"; import { @@ -53,7 +54,6 @@ import { } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; import { isInGroup } from "../groups"; -import { mutateElbowArrow } from "./routing"; import type { GlobalPoint } from "../../math"; import { pointCenter, @@ -1002,6 +1002,13 @@ export const resizeMultipleElements = ( const { angle } = update; const { width: oldWidth, height: oldHeight } = element; + if (isElbowArrow(element)) { + update.points = getArrowLocalFixedPoints( + { ...element, ...update } as ExcalidrawElbowArrowElement, + elementsMap, + ); + } + mutateElement(element, update, false); updateBoundElements(element, elementsMap, { @@ -1058,8 +1065,9 @@ const rotateMultipleElements = ( ); if (isElbowArrow(element)) { - const points = getArrowLocalFixedPoints(element, elementsMap); - mutateElbowArrow(element, elementsMap, points); + mutateElement(element, { + points: getArrowLocalFixedPoints(element, elementsMap), + }); } else { mutateElement( element, diff --git a/packages/excalidraw/element/routing.test.tsx b/packages/excalidraw/element/routing.test.tsx index fb6b23f286e4..d7c0ad686f30 100644 --- a/packages/excalidraw/element/routing.test.tsx +++ b/packages/excalidraw/element/routing.test.tsx @@ -9,14 +9,14 @@ import { render, } from "../tests/test-utils"; import { bindLinearElement } from "./binding"; -import { Excalidraw } from "../index"; -import { mutateElbowArrow } from "./routing"; +import { Excalidraw, mutateElement } from "../index"; import type { ExcalidrawArrowElement, ExcalidrawBindableElement, ExcalidrawElbowArrowElement, } from "./types"; import { ARROW_TYPE } from "../constants"; +import type { LocalPoint } from "../../math"; import { pointFrom } from "../../math"; const { h } = window; @@ -31,10 +31,12 @@ describe("elbow arrow routing", () => { elbowed: true, }) as ExcalidrawElbowArrowElement; scene.insertElement(arrow); - mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [ - pointFrom(-45 - arrow.x, -100.1 - arrow.y), - pointFrom(45 - arrow.x, 99.9 - arrow.y), - ]); + mutateElement(arrow, { + points: [ + pointFrom(-45 - arrow.x, -100.1 - arrow.y), + pointFrom(45 - arrow.x, 99.9 - arrow.y), + ], + }); expect(arrow.points).toEqual([ [0, 0], [0, 100], @@ -81,7 +83,9 @@ describe("elbow arrow routing", () => { expect(arrow.startBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null); - mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]); + mutateElement(arrow, { + points: [pointFrom(0, 0), pointFrom(90, 200)], + }); expect(arrow.points).toEqual([ [0, 0], diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/routing.ts index 39a09e1c712d..0a34ebf06840 100644 --- a/packages/excalidraw/element/routing.ts +++ b/packages/excalidraw/element/routing.ts @@ -1,4 +1,3 @@ -import type { Radians } from "../../math"; import { pointFrom, pointScaleFromOrigin, @@ -9,7 +8,6 @@ import { vectorScale, type GlobalPoint, type LocalPoint, - type Vector, } from "../../math"; import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; @@ -37,7 +35,6 @@ import { vectorToHeading, } from "./heading"; import type { ElementUpdate } from "./mutateElement"; -import { mutateElement } from "./mutateElement"; import { isBindableElement, isRectanguloidElement } from "./typeChecks"; import { type ExcalidrawElbowArrowElement, @@ -73,67 +70,23 @@ type Grid = { const BASE_PADDING = 40; -export const mutateElbowArrow = ( +export const updateElbowArrowPoints = ( arrow: ExcalidrawElbowArrowElement, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - nextPoints: readonly LocalPoint[], - offset?: Vector, - otherUpdates?: Omit< - ElementUpdate, - "angle" | "x" | "y" | "width" | "height" | "elbowed" | "points" - >, - options?: { - isDragging?: boolean; - informMutation?: boolean; + updates: { + points: readonly LocalPoint[]; + fixedSegments?: number[]; }, -) => { - const update = updateElbowArrow( - arrow, - elementsMap, - [nextPoints[0], nextPoints[nextPoints.length - 1]], - otherUpdates?.fixedSegments, - offset, - options, - ); - - if (update) { - mutateElement( - arrow, - { - ...otherUpdates, - ...update, - //points: nextPoints, - angle: 0 as Radians, - }, - options?.informMutation, - ); - } else { - console.error("Elbow arrow cannot find a route"); - } -}; - -export const updateElbowArrow = ( - arrow: ExcalidrawElbowArrowElement, - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - targetPoints: readonly [LocalPoint, LocalPoint], - fixSegments?: number[] | null, - offset?: Readonly, options?: { isDragging?: boolean; disableBinding?: boolean; }, -): ElementUpdate | null => { - // Merge a set of arrow points with offet, new points overriding any old points - const nextPoints = Array.from(arrow.points); - nextPoints[0] = pointTranslate(targetPoints[0], offset); - nextPoints[nextPoints.length - 1] = pointTranslate( - targetPoints[targetPoints.length - 1], - offset, - ); - +): ElementUpdate => { // Segment index - const nextFixedSegments: number[] = Array.from( - new Set([...(arrow.fixedSegments ?? []), ...(fixSegments ?? [])]), + const nextFixedSegments = ( + updates.fixedSegments ?? + arrow.fixedSegments ?? + [] ).sort(); // Determine the arrow parts based on fixed segments @@ -143,14 +96,14 @@ export const updateElbowArrow = ( prevIdx = segmentIdx - 1; return ret; }); - parts.push([prevIdx, nextPoints.length - 1]); + parts.push([prevIdx, updates.points.length - 1]); // Generate the part ends const temporaryElementsMap = new Map>( elementsMap, ); const points = parts.flatMap(([startIdx, endIdx], id) => { - const partGlobalPoints = nextPoints.map((p) => + const partGlobalPoints = updates.points.map((p) => pointFrom(arrow.x + p[0], arrow.y + p[1]), ); const startGlobalCoords = partGlobalPoints[startIdx]; @@ -171,7 +124,7 @@ export const updateElbowArrow = ( height: 10, }); } - if (endIdx !== nextPoints.length - 1) { + if (endIdx !== updates.points.length - 1) { temporaryElementsMap.set(`temp-end-${id}`, { id: `temp-end-${id}`, type: "rectangle", @@ -189,7 +142,7 @@ export const updateElbowArrow = ( y: partGlobalPoints[startIdx][1], startArrowhead: startIdx === 0 ? arrow.startArrowhead : null, endArrowhead: - endIdx === nextPoints.length - 1 ? arrow.endArrowhead : null, + endIdx === updates.points.length - 1 ? arrow.endArrowhead : null, startBinding: startIdx === 0 ? arrow.startBinding @@ -211,7 +164,7 @@ export const updateElbowArrow = ( gap: 0, }, endBinding: - endIdx === nextPoints.length - 1 + endIdx === updates.points.length - 1 ? arrow.endBinding : { elementId: `temp-end-${id}`, @@ -244,9 +197,7 @@ export const updateElbowArrow = ( ); }); - return points - ? normalizedArrowElementUpdate(points, 0, 0, nextFixedSegments) - : null; + return normalizedArrowElementUpdate(points, nextFixedSegments); }; /** @@ -1086,8 +1037,6 @@ const getBindableElementForId = ( const normalizedArrowElementUpdate = ( global: GlobalPoint[], - externalOffsetX: number, - externalOffsetY: number, nextFixedSegments: number[], ): { points: LocalPoint[]; @@ -1109,8 +1058,8 @@ const normalizedArrowElementUpdate = ( return { points, - x: offsetX + (externalOffsetX ?? 0), - y: offsetY + (externalOffsetY ?? 0), + x: offsetX, + y: offsetY, fixedSegments: nextFixedSegments.length ? nextFixedSegments : null, ...getSizeFromPoints(points), }; From 86388ece5248a413e1b086b7ba9520252c67eb97 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 8 Oct 2024 18:14:08 +0200 Subject: [PATCH 009/283] Working flip and resize Signed-off-by: Mark Tolmacs --- .../excalidraw/actions/actionFlip.test.tsx | 8 +- packages/excalidraw/actions/actionFlip.ts | 1 + packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/App.tsx | 38 +++--- packages/excalidraw/element/binding.ts | 22 ++-- packages/excalidraw/element/mutateElement.ts | 6 +- packages/excalidraw/element/resizeElements.ts | 111 ++++++++++++++++-- .../__snapshots__/contextmenu.test.tsx.snap | 17 +++ .../tests/__snapshots__/history.test.tsx.snap | 58 +++++++++ .../regressionTests.test.tsx.snap | 52 ++++++++ packages/excalidraw/tests/resize.test.tsx | 2 +- packages/excalidraw/types.ts | 1 + .../utils/__snapshots__/export.test.ts.snap | 1 + 13 files changed, 281 insertions(+), 38 deletions(-) diff --git a/packages/excalidraw/actions/actionFlip.test.tsx b/packages/excalidraw/actions/actionFlip.test.tsx index 5ee587b20519..cfcf53f33c22 100644 --- a/packages/excalidraw/actions/actionFlip.test.tsx +++ b/packages/excalidraw/actions/actionFlip.test.tsx @@ -71,12 +71,12 @@ describe("flipping re-centers selection", () => { API.executeAction(actionFlipHorizontal); const rec1 = h.elements.find((el) => el.id === "rec1"); - expect(rec1?.x).toBeCloseTo(100); - expect(rec1?.y).toBeCloseTo(100); + expect(rec1?.x).toBeCloseTo(100, 1); + expect(rec1?.y).toBeCloseTo(100, 1); const rec2 = h.elements.find((el) => el.id === "rec2"); - expect(rec2?.x).toBeCloseTo(220); - expect(rec2?.y).toBeCloseTo(250); + expect(rec2?.x).toBeCloseTo(220, 1); + expect(rec2?.y).toBeCloseTo(250, 1); }); }); diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 20306ddcaebc..34388a0aeff4 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -143,6 +143,7 @@ const flipElements = ( true, flipDirection === "horizontal" ? maxX : minX, flipDirection === "horizontal" ? minY : maxY, + true, ); bindOrUnbindLinearElements( diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index cb80c6cd891a..73f9cce2fc55 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -117,6 +117,7 @@ export const getDefaultAppState = (): Omit< userToFollow: null, followedBy: new Set(), searchMatches: [], + flippedFixedPointBindings: false, }; }; @@ -238,6 +239,7 @@ const APP_STATE_STORAGE_CONF = (< userToFollow: { browser: false, export: false, server: false }, followedBy: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false }, + flippedFixedPointBindings: { browser: false, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index f797f4cf559a..bc12cadaf617 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -6182,6 +6182,7 @@ class App extends React.Component { this.setState({ selectedElementsAreBeingDragged: false, + flippedFixedPointBindings: false, }); if (this.handleDraggingScrollBar(event, pointerDownState)) { @@ -10049,23 +10050,26 @@ class App extends React.Component { }); } - if ( - transformElements( - pointerDownState.originalElements, - transformHandleType, - selectedElements, - this.scene.getElementsMapIncludingDeleted(), - shouldRotateWithDiscreteAngle(event), - shouldResizeFromCenter(event), - selectedElements.some((element) => isImageElement(element)) - ? !shouldMaintainAspectRatio(event) - : shouldMaintainAspectRatio(event), - resizeX, - resizeY, - pointerDownState.resize.center.x, - pointerDownState.resize.center.y, - ) - ) { + const transformed = transformElements( + pointerDownState.originalElements, + transformHandleType, + selectedElements, + this.scene.getElementsMapIncludingDeleted(), + shouldRotateWithDiscreteAngle(event), + shouldResizeFromCenter(event), + selectedElements.some((element) => isImageElement(element)) + ? !shouldMaintainAspectRatio(event) + : shouldMaintainAspectRatio(event), + resizeX, + resizeY, + pointerDownState.resize.center.x, + pointerDownState.resize.center.y, + this.state.flippedFixedPointBindings, + (flippedFixedPointBindings) => + this.setState({ flippedFixedPointBindings }), + ); + + if (transformed) { const suggestedBindings = getSuggestedBindingsForArrows( selectedElements, this.scene.getNonDeletedElementsMap(), diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index ccf92905b76e..91bd336fa559 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -2204,7 +2204,6 @@ export const getGlobalFixedPointForBindableElement = ( element: ExcalidrawBindableElement, ): GlobalPoint => { const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); - return pointRotateRads( pointFrom( element.x + element.width * fixedX, @@ -2218,7 +2217,7 @@ export const getGlobalFixedPointForBindableElement = ( ); }; -const getGlobalFixedPoints = ( +export const getGlobalFixedPoints = ( arrow: ExcalidrawElbowArrowElement, elementsMap: ElementsMap, ): [GlobalPoint, GlobalPoint] => { @@ -2259,13 +2258,22 @@ const getGlobalFixedPoints = ( export const getArrowLocalFixedPoints = ( arrow: ExcalidrawElbowArrowElement, elementsMap: ElementsMap, -): [LocalPoint, LocalPoint] => { +): LocalPoint[] => { const [startPoint, endPoint] = getGlobalFixedPoints(arrow, elementsMap); + const points = Array.from(arrow.points); - return [ - LinearElementEditor.pointFromAbsoluteCoords(arrow, startPoint, elementsMap), - LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap), - ]; + points[0] = LinearElementEditor.pointFromAbsoluteCoords( + arrow, + startPoint, + elementsMap, + ); + points[points.length - 1] = LinearElementEditor.pointFromAbsoluteCoords( + arrow, + endPoint, + elementsMap, + ); + + return points; }; export const normalizeFixedPoint = ( diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index 926116e5247e..01434dcd2508 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -50,7 +50,11 @@ export const mutateElement = >( ...updates, angle: 0 as Radians, ...updateElbowArrowPoints( - element, + { + ...element, + x: updates.x || element.x, + y: updates.y || element.y, + }, mergedElementsMap, // @ts-ignore updates, diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 9f03d8fc6f81..6fcbf33383c0 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -13,6 +13,7 @@ import type { NonDeletedSceneElementsMap, SceneElementsMap, ExcalidrawElbowArrowElement, + FixedPointBinding, } from "./types"; import type { Mutable } from "../utility-types"; import { @@ -33,7 +34,11 @@ import { } from "./typeChecks"; import { mutateElement } from "./mutateElement"; import { getFontString } from "../utils"; -import { getArrowLocalFixedPoints, updateBoundElements } from "./binding"; +import { + getArrowLocalFixedPoints, + getGlobalFixedPoints, + updateBoundElements, +} from "./binding"; import type { MaybeTransformHandleType, TransformHandleDirection, @@ -63,6 +68,7 @@ import { pointRotateRads, type Radians, } from "../../math"; +import { debugDrawPoint } from "../visualdebug"; // Returns true when transform (resizing/rotation) happened export const transformElements = ( @@ -77,6 +83,8 @@ export const transformElements = ( pointerY: number, centerX: number, centerY: number, + flippedFixedPointBindings: boolean = false, + setFlippedFixedPointBindings?: (flippedFixedPointBindings: boolean) => void, ) => { if (selectedElements.length === 1) { const [element] = selectedElements; @@ -139,6 +147,8 @@ export const transformElements = ( shouldMaintainAspectRatio, pointerX, pointerY, + flippedFixedPointBindings, + setFlippedFixedPointBindings, ); return true; } @@ -768,6 +778,8 @@ export const resizeMultipleElements = ( shouldMaintainAspectRatio: boolean, pointerX: number, pointerY: number, + flippedFixedPointBindings: boolean = false, + setFlippedFixedPointBindings?: (flippedFixedPointBindings: boolean) => void, ) => { // map selected elements to the original elements. While it never should // happen that pointerDownState.originalElements won't contain the selected @@ -987,15 +999,34 @@ export const resizeMultipleElements = ( } } - elementsAndUpdates.push({ - element: latest, - update, - }); + // Elbow arrows must be the last to be mutated so the bound + // bound elements have their final position before the arrow + // is recalculated + if (isElbowArrow(latest)) { + elementsAndUpdates.push({ + element: latest, + update, + }); + } else { + elementsAndUpdates.unshift({ + element: latest, + update, + }); + } + } + + let flipFixedPoint = false; + if (!flippedFixedPointBindings && (isFlippedByX || isFlippedByY)) { + flipFixedPoint = true; + setFlippedFixedPointBindings?.(true); + } else if (flippedFixedPointBindings && !isFlippedByX && !isFlippedByY) { + flipFixedPoint = true; + setFlippedFixedPointBindings?.(false); } const elementsToUpdate = elementsAndUpdates.map(({ element }) => element); - for (const { + for (let { element, update: { boundTextFontSize, ...update }, } of elementsAndUpdates) { @@ -1003,10 +1034,74 @@ export const resizeMultipleElements = ( const { width: oldWidth, height: oldHeight } = element; if (isElbowArrow(element)) { - update.points = getArrowLocalFixedPoints( - { ...element, ...update } as ExcalidrawElbowArrowElement, + // If the resize gone into the inverse we need to flip the + // FixedPointBinding sides + if (flipFixedPoint) { + if ( + element.startBinding && + selectedElements.findIndex( + (el) => + // @ts-ignore + el.id === element.startBinding?.elementId || + // @ts-ignore + el.id === update.startBinding?.elementId, + ) !== -1 + ) { + const binding = (update.startBinding || + element.startBinding) as FixedPointBinding; + if (direction !== "n" && direction !== "s") { + binding.fixedPoint[0] = 1 - binding.fixedPoint[0]; + } + if (direction !== "w" && direction !== "e") { + update.startBinding = element.startBinding; + binding.fixedPoint[1] = 1 - binding.fixedPoint[1]; + } + update.startBinding = binding; + } + if ( + element.endBinding && + selectedElements.findIndex( + (el) => + // @ts-ignore + el.id === element.endBinding?.elementId || + // @ts-ignore + el.id === update.endBinding?.elementId, + ) !== -1 + ) { + const binding = (update.endBinding || + element.endBinding) as FixedPointBinding; + if (direction !== "n" && direction !== "s") { + binding.fixedPoint[0] = 1 - binding.fixedPoint[0]; + } + if (direction !== "w" && direction !== "e") { + update.endBinding = element.endBinding; + binding.fixedPoint[1] = 1 - binding.fixedPoint[1]; + } + update.endBinding = binding; + } + } + + const [startPoint, endPoint] = getGlobalFixedPoints( + { + ...element, + ...update, + } as ExcalidrawElbowArrowElement, elementsMap, ); + debugDrawPoint(startPoint); + const points = Array.from(update.points || element.points); + points[0] = pointFrom(0, 0); + points[points.length - 1] = pointFrom( + endPoint[0] - startPoint[0], + endPoint[1] - startPoint[1], + ); + + update = { + ...update, + points, + x: startPoint[0], + y: startPoint[1], + }; } mutateElement(element, update, false); diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 3a5e1406585e..d71eedc3b861 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -823,6 +823,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -1029,6 +1030,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -1245,6 +1247,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -1576,6 +1579,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -1907,6 +1911,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -2123,6 +2128,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -2363,6 +2369,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -2664,6 +2671,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -3033,6 +3041,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -3508,6 +3517,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -3831,6 +3841,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -4154,6 +4165,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -5340,6 +5352,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -6467,6 +6480,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -7402,6 +7416,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -8314,6 +8329,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -9208,6 +9224,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index a4129ed0f94f..b5123134fc96 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -40,6 +40,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -642,6 +643,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -1148,6 +1150,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -1516,6 +1519,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -1885,6 +1889,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -2152,6 +2157,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -2592,6 +2598,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -2891,6 +2898,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -3175,6 +3183,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -3469,6 +3478,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -3755,6 +3765,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -3990,6 +4001,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -4249,6 +4261,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -4522,6 +4535,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -4753,6 +4767,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -4984,6 +4999,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -5213,6 +5229,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -5442,6 +5459,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -5701,6 +5719,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -6032,6 +6051,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -6457,6 +6477,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -6835,6 +6856,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -7154,6 +7176,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -7452,6 +7475,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -7681,6 +7705,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -8036,6 +8061,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -8391,6 +8417,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -8795,6 +8822,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -9082,6 +9110,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -9347,6 +9376,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -9611,6 +9641,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -9842,6 +9873,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -10143,6 +10175,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -10483,6 +10516,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -10718,6 +10752,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -11172,6 +11207,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -11426,6 +11462,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -11665,6 +11702,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -11906,6 +11944,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -12307,6 +12346,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -12554,6 +12594,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -12795,6 +12836,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -13036,6 +13078,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -13283,6 +13326,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -13615,6 +13659,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -13787,6 +13832,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -14075,6 +14121,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -14342,6 +14389,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -14617,6 +14665,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -14778,6 +14827,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -15474,6 +15524,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -16094,6 +16145,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -16714,6 +16766,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -17426,6 +17479,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -18176,6 +18230,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -18650,6 +18705,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -19172,6 +19228,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -19628,6 +19685,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index 6dea2680239b..5debb398cdee 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -40,6 +40,7 @@ exports[`given element A and group of elements B and given both are selected whe "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -452,6 +453,7 @@ exports[`given element A and group of elements B and given both are selected whe "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -855,6 +857,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -1397,6 +1400,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -1598,6 +1602,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -1970,6 +1975,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -2207,6 +2213,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -2384,6 +2391,7 @@ exports[`regression tests > can drag element that covers another element, while "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -2701,6 +2709,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -2944,6 +2953,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -3184,6 +3194,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -3411,6 +3422,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -3664,6 +3676,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -3972,6 +3985,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -4383,6 +4397,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -4663,6 +4678,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -4913,6 +4929,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -5120,6 +5137,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -5316,6 +5334,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -5695,6 +5714,7 @@ exports[`regression tests > drags selected elements from point inside common bou "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -5982,6 +6002,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -6787,6 +6808,7 @@ exports[`regression tests > given a group of selected elements with an element t "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -7114,6 +7136,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -7387,6 +7410,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -7618,6 +7642,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -7852,6 +7877,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -8029,6 +8055,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -8206,6 +8233,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -8383,6 +8411,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -8602,6 +8631,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -8820,6 +8850,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -9011,6 +9042,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -9230,6 +9262,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -9407,6 +9440,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -9625,6 +9659,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -9802,6 +9837,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -9993,6 +10029,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -10170,6 +10207,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -10681,6 +10719,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -10955,6 +10994,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -11078,6 +11118,7 @@ exports[`regression tests > shift click on selected element should deselect it o "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -11274,6 +11315,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -11582,6 +11624,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -11991,6 +12034,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -12601,6 +12645,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -12727,6 +12772,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -13308,6 +13354,7 @@ exports[`regression tests > switches from group of selected elements to another "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -13643,6 +13690,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -13905,6 +13953,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -14028,6 +14077,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -14404,6 +14454,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, @@ -14527,6 +14578,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, diff --git a/packages/excalidraw/tests/resize.test.tsx b/packages/excalidraw/tests/resize.test.tsx index 05f8627a83db..1075a3944d64 100644 --- a/packages/excalidraw/tests/resize.test.tsx +++ b/packages/excalidraw/tests/resize.test.tsx @@ -392,7 +392,7 @@ describe("arrow element", () => { UI.resize([rectangle, arrow], "nw", [300, 350]); - expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144, 2); + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); }); }); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index c4ebd994e0ac..17b698fb6840 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -387,6 +387,7 @@ export interface AppState { /** the socket ids of the users following the current user */ followedBy: Set; searchMatches: readonly SearchMatch[]; + flippedFixedPointBindings: boolean; } type SearchMatch = { diff --git a/packages/utils/__snapshots__/export.test.ts.snap b/packages/utils/__snapshots__/export.test.ts.snap index d21cee36057d..ded051e5f7d7 100644 --- a/packages/utils/__snapshots__/export.test.ts.snap +++ b/packages/utils/__snapshots__/export.test.ts.snap @@ -41,6 +41,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "flippedFixedPointBindings": false, "followedBy": Set {}, "frameRendering": { "clip": true, From 3d91e44c4f8ceb59943bcd6b7ee68595a0e75c09 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 9 Oct 2024 13:25:26 +0200 Subject: [PATCH 010/283] Fix lint in html --- excalidraw-app/index.html | 72 +++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 0dde8e362896..aeb1f92f92ba 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -124,19 +124,17 @@ - <% } else { %> - - <% } %> - - <% if (typeof PROD != 'undefined' && PROD == true) { %> + + <% } else { %> + <% } %> @@ -221,38 +219,38 @@

Excalidraw

<% if (typeof PROD != 'undefined' && PROD == true) { %> - - - + // if iframe + if (window.self !== window.top) { + scriptEle.addEventListener("load", () => { + if (window.sa_pageview) { + window.window.sa_event(action, { + category: "iframe", + label: "embed", + value: window.location.pathname, + }); + } + }); + } + + <% } %> From c66aebf39f102c8f32dbde67c824b814280b33b5 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 9 Oct 2024 15:35:46 +0200 Subject: [PATCH 011/283] Rename routing.ts to elbowarrow.ts to better describe what it does --- packages/excalidraw/components/App.tsx | 2 +- .../element/{routing.test.tsx => elbowarrow.test.tsx} | 0 packages/excalidraw/element/{routing.ts => elbowarrow.ts} | 0 packages/excalidraw/element/mutateElement.ts | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) rename packages/excalidraw/element/{routing.test.tsx => elbowarrow.test.tsx} (100%) rename packages/excalidraw/element/{routing.ts => elbowarrow.ts} (100%) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index bc12cadaf617..e09dbf74ddd5 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -438,7 +438,7 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize"; import { getVisibleSceneBounds } from "../element/bounds"; import { isMaybeMermaidDefinition } from "../mermaid"; import NewElementCanvas from "./canvases/NewElementCanvas"; -import { updateElbowArrowPoints } from "../element/routing"; +import { updateElbowArrowPoints } from "../element/elbowarrow"; import { FlowChartCreator, FlowChartNavigator, diff --git a/packages/excalidraw/element/routing.test.tsx b/packages/excalidraw/element/elbowarrow.test.tsx similarity index 100% rename from packages/excalidraw/element/routing.test.tsx rename to packages/excalidraw/element/elbowarrow.test.tsx diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/elbowarrow.ts similarity index 100% rename from packages/excalidraw/element/routing.ts rename to packages/excalidraw/element/elbowarrow.ts diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index 01434dcd2508..4e72f8e80009 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -10,7 +10,7 @@ import { getUpdatedTimestamp, toBrandedType } from "../utils"; import type { Mutable } from "../utility-types"; import { ShapeCache } from "../scene/ShapeCache"; import { isElbowArrow } from "./typeChecks"; -import { updateElbowArrowPoints } from "./routing"; +import { updateElbowArrowPoints } from "./elbowarrow"; import type { Radians } from "../../math"; export type ElementUpdate = Omit< From 440dd10cee5380c5d5110b472f5ac12eec8c6de6 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 9 Oct 2024 16:21:29 +0200 Subject: [PATCH 012/283] Simplifying linear editor function signatures --- packages/excalidraw/components/App.tsx | 52 ++++----- packages/excalidraw/element/binding.ts | 1 - packages/excalidraw/element/flowchart.ts | 1 - .../excalidraw/element/linearElementEditor.ts | 105 ++++++------------ packages/excalidraw/element/mutateElement.ts | 3 +- .../tests/linearElementEditor.test.tsx | 31 +++--- 6 files changed, 72 insertions(+), 121 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index e09dbf74ddd5..dfea05b22b06 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7723,36 +7723,32 @@ class App extends React.Component { elementsMap, ); const isHorizontal = startPoint[0] === endPoint[0]; - LinearElementEditor.movePoints( - arrow, - [ - { - index: segmentIdx! - 1, - point: LinearElementEditor.pointFromAbsoluteCoords( - arrow, - pointFrom( - isHorizontal ? pointerCoords.x : startPoint[0], - !isHorizontal ? pointerCoords.y : startPoint[1], - ), - elementsMap, + LinearElementEditor.movePoints(arrow, [ + { + index: segmentIdx! - 1, + point: LinearElementEditor.pointFromAbsoluteCoords( + arrow, + pointFrom( + isHorizontal ? pointerCoords.x : startPoint[0], + !isHorizontal ? pointerCoords.y : startPoint[1], ), - isDragging: true, - }, - { - index: segmentIdx!, - point: LinearElementEditor.pointFromAbsoluteCoords( - arrow, - pointFrom( - isHorizontal ? pointerCoords.x : endPoint[0], - !isHorizontal ? pointerCoords.y : endPoint[1], - ), - elementsMap, + elementsMap, + ), + isDragging: true, + }, + { + index: segmentIdx!, + point: LinearElementEditor.pointFromAbsoluteCoords( + arrow, + pointFrom( + isHorizontal ? pointerCoords.x : endPoint[0], + !isHorizontal ? pointerCoords.y : endPoint[1], ), - isDragging: true, - }, - ], - elementsMap, - ); + elementsMap, + ), + isDragging: true, + }, + ]); didDrag = true; } else if ( linearElementEditor.pointerDownState.segmentMidpoint.value !== null && diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 91bd336fa559..5752ac480d5f 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -659,7 +659,6 @@ export const updateBoundElements = ( LinearElementEditor.movePoints( element, updates, - elementsMap, { ...(changedElement.id === element.startBinding?.elementId ? { startBinding: bindings.startBinding } diff --git a/packages/excalidraw/element/flowchart.ts b/packages/excalidraw/element/flowchart.ts index 8c14bc01a7b3..851cc8fd04a1 100644 --- a/packages/excalidraw/element/flowchart.ts +++ b/packages/excalidraw/element/flowchart.ts @@ -460,7 +460,6 @@ const createBindingArrow = ( point: bindingArrow.points[1], }, ], - elementsMap as NonDeletedSceneElementsMap, undefined, { changedElements, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 7c99f3d1290e..c848925ea118 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -289,20 +289,16 @@ export class LinearElementEditor { event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), ); - LinearElementEditor.movePoints( - element, - [ - { - index: selectedIndex, - point: pointFrom( - width + referencePoint[0], - height + referencePoint[1], - ), - isDragging: selectedIndex === lastClickedPoint, - }, - ], - elementsMap, - ); + LinearElementEditor.movePoints(element, [ + { + index: selectedIndex, + point: pointFrom( + width + referencePoint[0], + height + referencePoint[1], + ), + isDragging: selectedIndex === lastClickedPoint, + }, + ]); } else { const newDraggingPointPosition = LinearElementEditor.createPointAt( element, @@ -337,7 +333,6 @@ export class LinearElementEditor { isDragging: pointIndex === lastClickedPoint, }; }), - elementsMap, ); } @@ -420,19 +415,15 @@ export class LinearElementEditor { 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], - }, - ], - elementsMap, - ); + LinearElementEditor.movePoints(element, [ + { + index: selectedPoint, + point: + selectedPoint === 0 + ? element.points[element.points.length - 1] + : element.points[0], + }, + ]); } const bindingElement = isBindingEnabled(appState) @@ -934,22 +925,14 @@ export class LinearElementEditor { } if (lastPoint === lastUncommittedPoint) { - LinearElementEditor.movePoints( - element, - [ - { - index: element.points.length - 1, - point: newPoint, - }, - ], - elementsMap, - ); + LinearElementEditor.movePoints(element, [ + { + index: element.points.length - 1, + point: newPoint, + }, + ]); } else { - LinearElementEditor.addPoints( - element, - [{ point: newPoint }], - elementsMap, - ); + LinearElementEditor.addPoints(element, [{ point: newPoint }]); } return { ...appState.editingLinearElement, @@ -1178,16 +1161,12 @@ export class LinearElementEditor { // 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), - }, - ], - elementsMap, - ); + LinearElementEditor.movePoints(element, [ + { + index: element.points.length - 1, + point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30), + }, + ]); } return { @@ -1232,37 +1211,23 @@ export class LinearElementEditor { return acc; }, []); - LinearElementEditor._updatePoints( - element, - nextPoints, - offsetX, - offsetY, - elementsMap, - ); + LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); } static addPoints( element: NonDeleted, targetPoints: { point: LocalPoint }[], - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, ) { const offsetX = 0; const offsetY = 0; const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)]; - LinearElementEditor._updatePoints( - element, - nextPoints, - offsetX, - offsetY, - elementsMap, - ); + LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); } static movePoints( element: NonDeleted, targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[], - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, otherUpdates?: { startBinding?: PointBinding | null; endBinding?: PointBinding | null; @@ -1313,7 +1278,6 @@ export class LinearElementEditor { nextPoints, offsetX, offsetY, - elementsMap, otherUpdates, { isDragging: targetPoints.reduce( @@ -1428,7 +1392,6 @@ export class LinearElementEditor { nextPoints: readonly LocalPoint[], offsetX: number, offsetY: number, - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, otherUpdates?: { startBinding?: PointBinding | null; endBinding?: PointBinding | null; diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index 4e72f8e80009..395b98035603 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -34,8 +34,7 @@ export const mutateElement = >( // casting to any because can't use `in` operator // (see https://github.com/microsoft/TypeScript/issues/21732) - const { fileId } = updates as any; - const { points } = updates as any; + const { points, fileId } = updates as any; if (typeof points !== "undefined") { if (isElbowArrow(element)) { diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index 5df260d1d577..64c37f923bcb 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -5,7 +5,6 @@ import type { ExcalidrawLinearElement, ExcalidrawTextElementWithContainer, FontString, - SceneElementsMap, } from "../element/types"; import { Excalidraw, mutateElement } from "../index"; import { reseed } from "../random"; @@ -1354,23 +1353,19 @@ describe("Test Linear Elements", () => { const [origStartX, origStartY] = [line.x, line.y]; act(() => { - LinearElementEditor.movePoints( - line, - [ - { - index: 0, - point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10), - }, - { - index: line.points.length - 1, - point: pointFrom( - line.points[line.points.length - 1][0] - 10, - line.points[line.points.length - 1][1] - 10, - ), - }, - ], - new Map() as SceneElementsMap, - ); + LinearElementEditor.movePoints(line, [ + { + index: 0, + point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10), + }, + { + index: line.points.length - 1, + point: pointFrom( + line.points[line.points.length - 1][0] - 10, + line.points[line.points.length - 1][1] - 10, + ), + }, + ]); }); expect(line.x).toBe(origStartX + 10); expect(line.y).toBe(origStartY + 10); From 23266105d850abfd846c64b14eba0df9054abbe3 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 9 Oct 2024 20:15:40 +0200 Subject: [PATCH 013/283] Fixed segment identification --- .../excalidraw/element/linearElementEditor.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index c848925ea118..a0530643609a 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1273,6 +1273,21 @@ export class LinearElementEditor { return offsetX || offsetY ? pointFrom(p[0] - offsetX, p[1] - offsetY) : p; }); + if (isElbowArrow(element)) { + otherUpdates = otherUpdates || {}; + otherUpdates.fixedSegments = Array.from( + new Set([ + ...(element.fixedSegments ?? []), + // The segments being fixed are always the + // sub-ranges (minus their first idx) + ...targetPoints + .map((target) => target.index) + .sort() + .filter((i, _, a) => a.findIndex((t) => t === i - 1) > -1), + ]), + ).sort(); + } + LinearElementEditor._updatePoints( element, nextPoints, From 663cd7477a8906e60347bbdab7591963ab5e342c Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 9 Oct 2024 22:34:40 +0200 Subject: [PATCH 014/283] Fixing segments --- packages/excalidraw/components/App.tsx | 1 + packages/excalidraw/data/restore.ts | 1 + packages/excalidraw/element/elbowarrow.ts | 165 ++++++++-------------- packages/excalidraw/element/newElement.ts | 2 + packages/excalidraw/element/types.ts | 1 + 5 files changed, 66 insertions(+), 104 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index dfea05b22b06..97a412a342c0 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7343,6 +7343,7 @@ class App extends React.Component { locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow, + fixedSegments: this.state.currentItemArrowType === ARROW_TYPE.elbow ? [] : null }) : newLinearElement({ type: elementType, diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index a8d966e585b8..aa4e43c82af7 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -315,6 +315,7 @@ const restoreElement = ( x, y, elbowed: (element as ExcalidrawArrowElement).elbowed, + fixedSegments: (element as ExcalidrawArrowElement).fixedSegments, ...getSizeFromPoints(points), }); } diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 0a34ebf06840..7595495be03a 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -13,6 +13,7 @@ import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; +import { debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -45,7 +46,6 @@ import type { Arrowhead, ElementsMap, ExcalidrawBindableElement, - ExcalidrawElement, FixedPointBinding, } from "./types"; @@ -83,11 +83,7 @@ export const updateElbowArrowPoints = ( }, ): ElementUpdate => { // Segment index - const nextFixedSegments = ( - updates.fixedSegments ?? - arrow.fixedSegments ?? - [] - ).sort(); + const nextFixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? []; // Determine the arrow parts based on fixed segments let prevIdx = 0; @@ -98,106 +94,68 @@ export const updateElbowArrowPoints = ( }); parts.push([prevIdx, updates.points.length - 1]); - // Generate the part ends - const temporaryElementsMap = new Map>( - elementsMap, - ); - const points = parts.flatMap(([startIdx, endIdx], id) => { - const partGlobalPoints = updates.points.map((p) => - pointFrom(arrow.x + p[0], arrow.y + p[1]), - ); - const startGlobalCoords = partGlobalPoints[startIdx]; - const endGlobalCoords = partGlobalPoints[endIdx]; - const startDirection = vectorToHeading( - vectorFromPoint(endGlobalCoords, startGlobalCoords), - ); - const endDirection = vectorToHeading( - vectorFromPoint(startGlobalCoords, endGlobalCoords), - ); - if (startIdx !== 0) { - temporaryElementsMap.set(`temp-start-${id}`, { - id: `temp-start-${id}`, - type: "rectangle", - x: startGlobalCoords[0], - y: startGlobalCoords[1], - width: 10, - height: 10, - }); - } - if (endIdx !== updates.points.length - 1) { - temporaryElementsMap.set(`temp-end-${id}`, { - id: `temp-end-${id}`, - type: "rectangle", - x: endGlobalCoords[0], - y: endGlobalCoords[1], - width: 10, - height: 10, - }); - } - - return ( - routeElbowArrow( + const points = Array.from(updates.points); + const unified = parts + .map(([startIdx, endIdx], id) => { + if (startIdx !== 0) { + points[startIdx] = arrow.points[startIdx]; + } + if (endIdx !== points.length - 1) { + points[endIdx] = arrow.points[endIdx]; + } + debugDrawPoint( + pointFrom( + arrow.x + points[startIdx][0], + arrow.y + points[startIdx][1], + ), { - x: partGlobalPoints[startIdx][0], - y: partGlobalPoints[startIdx][1], - startArrowhead: startIdx === 0 ? arrow.startArrowhead : null, - endArrowhead: - endIdx === updates.points.length - 1 ? arrow.endArrowhead : null, - startBinding: - startIdx === 0 - ? arrow.startBinding - : { - elementId: `temp-start-${id}`, - fixedPoint: [ - compareHeading(startDirection, HEADING_LEFT) - ? 0 - : compareHeading(startDirection, HEADING_RIGHT) - ? 1 - : 0.5001, - compareHeading(startDirection, HEADING_UP) - ? 0 - : compareHeading(startDirection, HEADING_DOWN) - ? 1 - : 0.5001, - ], - focus: 0, - gap: 0, - }, - endBinding: - endIdx === updates.points.length - 1 - ? arrow.endBinding - : { - elementId: `temp-end-${id}`, - fixedPoint: [ - compareHeading(endDirection, HEADING_LEFT) - ? 0 - : compareHeading(endDirection, HEADING_RIGHT) - ? 1 - : 0.5001, - compareHeading(endDirection, HEADING_UP) - ? 0 - : compareHeading(endDirection, HEADING_DOWN) - ? 1 - : 0.5001, - ], - focus: 0, - gap: 0, - }, + color: id === 0 ? "green" : "red", }, - temporaryElementsMap as SceneElementsMap, - [ - pointFrom(0, 0), - pointFrom( - partGlobalPoints[endIdx][0] - partGlobalPoints[startIdx][0], - partGlobalPoints[endIdx][1] - partGlobalPoints[startIdx][1], - ), - ], - options, - ) ?? [] - ); - }); + ); + debugDrawPoint( + pointFrom( + arrow.x + points[endIdx][0], + arrow.y + points[endIdx][1], + ), + { + color: id === 0 ? "green" : "red", + }, + ); + return ( + routeElbowArrow( + { + x: arrow.x + points[startIdx][0], + y: arrow.y + points[startIdx][1], + startArrowhead: startIdx === 0 ? arrow.startArrowhead : null, + endArrowhead: + endIdx === points.length - 1 ? arrow.endArrowhead : null, + startBinding: startIdx === 0 ? arrow.startBinding : null, + endBinding: endIdx === points.length - 1 ? arrow.endBinding : null, + }, + elementsMap, + [ + pointFrom(0, 0), + pointFrom( + points[endIdx][0] - points[startIdx][0], + points[endIdx][1] - points[startIdx][1], + ), + ], + options, + ) ?? [] + ); + }) + .flatMap((segment, idx, segments) => { + if (idx === 0) { + return segment.slice(0, -1); + } + if (idx === segments.length - 1) { + return segment.slice(1); + } + + return segment.slice(1, -1); + }); - return normalizedArrowElementUpdate(points, nextFixedSegments); + return normalizedArrowElementUpdate(unified, nextFixedSegments); }; /** @@ -233,7 +191,6 @@ const routeElbowArrow = ( LocalPoint, GlobalPoint >(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y)); - const startElement = arrow.startBinding && getBindableElementForId(arrow.startBinding.elementId, elementsMap); diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index aa02cc1453cf..c6670d9b27f2 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -457,6 +457,7 @@ export const newArrowElement = ( endArrowhead?: Arrowhead | null; points?: ExcalidrawArrowElement["points"]; elbowed?: boolean; + fixedSegments?: number[] | null; } & ElementConstructorOpts, ): NonDeleted => { return { @@ -468,6 +469,7 @@ export const newArrowElement = ( startArrowhead: opts.startArrowhead || null, endArrowhead: opts.endArrowhead || null, elbowed: opts.elbowed || false, + fixedSegments: opts.fixedSegments || [], }; }; diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 31f184599fa1..9395ecd03d72 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -309,6 +309,7 @@ export type ExcalidrawArrowElement = ExcalidrawLinearElement & Readonly<{ type: "arrow"; elbowed: boolean; + fixedSegments: number[] | null; }>; export type ExcalidrawElbowArrowElement = Merge< From 3263603e061a2e16c55ef465fb51122c5daffe7e Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 11 Oct 2024 20:59:37 +0200 Subject: [PATCH 015/283] Fix lint error Signed-off-by: Mark Tolmacs --- packages/excalidraw/components/App.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 97a412a342c0..d9772cb3e134 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7343,7 +7343,10 @@ class App extends React.Component { locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow, - fixedSegments: this.state.currentItemArrowType === ARROW_TYPE.elbow ? [] : null + fixedSegments: + this.state.currentItemArrowType === ARROW_TYPE.elbow + ? [] + : null, }) : newLinearElement({ type: elementType, From 43f65c888d9a8b34a2f521924437f48432a2cdf2 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sat, 12 Oct 2024 22:13:45 +0200 Subject: [PATCH 016/283] Fix movement and segment normalization --- packages/excalidraw/element/elbowarrow.ts | 59 +++++++++++++---------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 7595495be03a..1c1dea9c85d6 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -97,30 +97,33 @@ export const updateElbowArrowPoints = ( const points = Array.from(updates.points); const unified = parts .map(([startIdx, endIdx], id) => { - if (startIdx !== 0) { + if (startIdx !== 0 && !updates.fixedSegments?.length) { points[startIdx] = arrow.points[startIdx]; + debugDrawPoint( + pointFrom( + arrow.x + points[startIdx][0], + arrow.y + points[startIdx][1], + ), + { + //color: id === 0 ? "green" : "red", + color: "green", + }, + ); } - if (endIdx !== points.length - 1) { + if (endIdx !== points.length - 1 && !updates.fixedSegments?.length) { points[endIdx] = arrow.points[endIdx]; + debugDrawPoint( + pointFrom( + arrow.x + points[endIdx][0], + arrow.y + points[endIdx][1], + ), + { + //color: id === 0 ? "green" : "red", + color: "red", + }, + ); } - debugDrawPoint( - pointFrom( - arrow.x + points[startIdx][0], - arrow.y + points[startIdx][1], - ), - { - color: id === 0 ? "green" : "red", - }, - ); - debugDrawPoint( - pointFrom( - arrow.x + points[endIdx][0], - arrow.y + points[endIdx][1], - ), - { - color: id === 0 ? "green" : "red", - }, - ); + return ( routeElbowArrow( { @@ -145,14 +148,18 @@ export const updateElbowArrowPoints = ( ); }) .flatMap((segment, idx, segments) => { - if (idx === 0) { - return segment.slice(0, -1); - } - if (idx === segments.length - 1) { - return segment.slice(1); + if (segments.length > 1) { + if (idx === 0) { + return segment.slice(0, -1); + } + if (idx === segments.length - 1) { + return segment.slice(1); + } + + return segment.slice(1, -1); } - return segment.slice(1, -1); + return segment; }); return normalizedArrowElementUpdate(unified, nextFixedSegments); From 5c45669f783af4dce4c1e67d6b55932ac5c42ec1 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sun, 13 Oct 2024 14:45:57 +0200 Subject: [PATCH 017/283] Fake mid-element for elbow segments Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 302 +++++++++++++++++----- packages/excalidraw/element/heading.ts | 22 +- 2 files changed, 255 insertions(+), 69 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 1c1dea9c85d6..24ae25a849a3 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -13,7 +13,7 @@ import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; -import { debugDrawPoint } from "../visualdebug"; +import { debugDrawBounds, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -33,9 +33,11 @@ import { HEADING_LEFT, HEADING_RIGHT, HEADING_UP, + headingForPointFromElement, vectorToHeading, } from "./heading"; import type { ElementUpdate } from "./mutateElement"; +import { newElement } from "./newElement"; import { isBindableElement, isRectanguloidElement } from "./typeChecks"; import { type ExcalidrawElbowArrowElement, @@ -46,7 +48,10 @@ import type { Arrowhead, ElementsMap, ExcalidrawBindableElement, + ExcalidrawElement, FixedPointBinding, + FractionalIndex, + Ordered, } from "./types"; type GridAddress = [number, number] & { _brand: "gridaddress" }; @@ -70,6 +75,104 @@ type Grid = { const BASE_PADDING = 40; +const createFakeElement = ( + arrow: ExcalidrawElbowArrowElement, + start: LocalPoint, + end: LocalPoint, +) => + ({ + ...newElement({ + type: "rectangle", + x: (arrow.x + start[0] + arrow.x + end[0]) / 2 - 5, + y: (arrow.y + start[1] + arrow.y + end[1]) / 2 - 5, + width: 10, + height: 10, + }), + index: "DONOTSYNC" as FractionalIndex, + } as Ordered); + +const mapSegmentsToFakeMidElements = ( + arrow: ExcalidrawElbowArrowElement, + segments: number[][], +): { + el: Ordered | null; + startHeading: Heading | null; + endHeading: Heading | null; + startIdx: number; + endIdx: number; +}[] => { + let prevSegment: [number, number] | null = null; + + return segments.map(([startIdx, endIdx], idx) => { + if (!prevSegment) { + prevSegment = [startIdx, endIdx]; + return { + el: null, + startHeading: null, + endHeading: null, + startIdx, + endIdx, + }; + } + + const start = pointFrom( + arrow.x + arrow.points[prevSegment[1]][0], + arrow.y + arrow.points[prevSegment[1]][1], + ); + const end = pointFrom( + arrow.x + arrow.points[startIdx][0], + arrow.y + arrow.points[startIdx][1], + ); + const el = createFakeElement( + arrow, + arrow.points[prevSegment[1]], + arrow.points[startIdx], + ); + const bounds = [el.x, el.y, el.x + el.width, el.y + el.height] as Bounds; + + debugDrawBounds(bounds); + + prevSegment = [startIdx, endIdx]; + + return { + el, + startHeading: e2h(bounds, start), + endHeading: e2h(bounds, end), + startIdx, + endIdx, + }; + }); +}; + +const e2h = (bounds: Bounds, point: GlobalPoint): Heading => { + const center = pointFrom( + (bounds[0] + bounds[2]) / 2, + (bounds[1] + bounds[3]) / 2, + ); + return point[0] - center[0] < 0.05 + ? point[1] > center[1] + ? HEADING_DOWN + : HEADING_UP + : point[0] > center[0] + ? HEADING_RIGHT + : HEADING_LEFT; +}; + +const getArrowSegments = ( + fixedSegmentIds: number[], + points: readonly LocalPoint[], +) => { + let prevIdx = 0; + const segments = fixedSegmentIds.map((segmentIdx) => { + const ret = [prevIdx, segmentIdx]; + prevIdx = segmentIdx - 1; + return ret; + }); + segments.push([prevIdx, points.length - 1]); + + return segments; +}; + export const updateElbowArrowPoints = ( arrow: ExcalidrawElbowArrowElement, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, @@ -82,87 +185,162 @@ export const updateElbowArrowPoints = ( disableBinding?: boolean; }, ): ElementUpdate => { - // Segment index const nextFixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? []; + const fakeElementsMap = toBrandedType(new Map(elementsMap)); + const points = Array.from(updates.points); // Determine the arrow parts based on fixed segments - let prevIdx = 0; - const parts = nextFixedSegments.map((segmentIdx) => { - const ret = [prevIdx, segmentIdx]; - prevIdx = segmentIdx - 1; - return ret; - }); - parts.push([prevIdx, updates.points.length - 1]); - - const points = Array.from(updates.points); - const unified = parts - .map(([startIdx, endIdx], id) => { - if (startIdx !== 0 && !updates.fixedSegments?.length) { - points[startIdx] = arrow.points[startIdx]; - debugDrawPoint( - pointFrom( - arrow.x + points[startIdx][0], - arrow.y + points[startIdx][1], - ), - { - //color: id === 0 ? "green" : "red", - color: "green", - }, - ); - } - if (endIdx !== points.length - 1 && !updates.fixedSegments?.length) { - points[endIdx] = arrow.points[endIdx]; - debugDrawPoint( - pointFrom( - arrow.x + points[endIdx][0], - arrow.y + points[endIdx][1], - ), - { - //color: id === 0 ? "green" : "red", - color: "red", - }, - ); + const segments = getArrowSegments(nextFixedSegments, updates.points); + + // Create a fake element at every segment mid point + const segmentUpdates: { + startPoint: GlobalPoint | null; + endPoint: GlobalPoint | null; + startIdx: number; + endIdx: number; + startBinding: FixedPointBinding | null; + endBinding: FixedPointBinding | null; + startArrowhead: Arrowhead | null; + endArrowhead: Arrowhead | null; + }[] = segments.map(() => ({ + startPoint: null, + endPoint: null, + startIdx: -1, + endIdx: -1, + startBinding: null, + endBinding: null, + startArrowhead: null, + endArrowhead: null, + })); + + mapSegmentsToFakeMidElements(arrow, segments).forEach( + (item, idx, elements) => { + if (item.el) { + fakeElementsMap.set(item.el.id, item.el); } - return ( + segmentUpdates[idx].startIdx = item?.startIdx ?? 0; + segmentUpdates[idx].endIdx = item?.endIdx ?? elements.length - 1; + segmentUpdates[idx].startArrowhead = + item?.startIdx === 0 ? arrow.startArrowhead : null; + segmentUpdates[idx].endArrowhead = + item?.endIdx === points.length - 1 ? arrow.endArrowhead : null; + segmentUpdates[idx].startBinding = + elements[idx - 1]?.el && elements[idx - 1]?.startHeading + ? { + elementId: elements[idx - 1].el!.id, + focus: 0, + gap: 0, + fixedPoint: compareHeading( + elements[idx - 1].startHeading!, + HEADING_DOWN, + ) + ? [0.51, 1] + : compareHeading(elements[idx - 1].startHeading!, HEADING_LEFT) + ? [0, 0.51] + : compareHeading(elements[idx - 1].startHeading!, HEADING_UP) + ? [0.51, 0] + : [1, 0.51], + } + : null; + segmentUpdates[idx].endBinding = + item.el && item.endHeading + ? { + elementId: item.el!.id, + focus: 0, + gap: 0, + fixedPoint: compareHeading(item.endHeading, HEADING_DOWN) + ? [0.51, 1] + : compareHeading(item.endHeading, HEADING_LEFT) + ? [0, 0.51] + : compareHeading(item.endHeading, HEADING_UP) + ? [0.51, 0] + : [1, 0.51], + } + : null; + segmentUpdates[idx].startPoint = + elements[idx - 1]?.el && segmentUpdates[idx].startBinding + ? getGlobalFixedPointForBindableElement( + segmentUpdates[idx].startBinding!.fixedPoint, + elements[idx - 1].el! as ExcalidrawBindableElement, + ) + : pointFrom( + arrow.x + points[item.startIdx][0], + arrow.y + points[item.startIdx][1], + ); + segmentUpdates[idx].endPoint = + item.el && segmentUpdates[idx].endBinding + ? getGlobalFixedPointForBindableElement( + segmentUpdates[idx].endBinding!.fixedPoint, + item.el as ExcalidrawBindableElement, + ) + : pointFrom( + arrow.x + points[item.endIdx][0], + arrow.y + points[item.endIdx][1], + ); + + // elements[idx - 1]?.el && + // segmentUpdates[idx].startBinding && + // debugDrawPoint(segmentUpdates[idx].startPoint!, { color: "green" }); + // item.el && + // segmentUpdates[idx].endBinding && + // debugDrawPoint(segmentUpdates[idx].endPoint!, { color: "red" }); + }, + ); + + // Calculate points + const unified = segmentUpdates + .map( + ({ + startIdx, + endIdx, + startBinding, + endBinding, + startArrowhead, + endArrowhead, + startPoint, + endPoint, + }) => routeElbowArrow( { - x: arrow.x + points[startIdx][0], - y: arrow.y + points[startIdx][1], - startArrowhead: startIdx === 0 ? arrow.startArrowhead : null, - endArrowhead: - endIdx === points.length - 1 ? arrow.endArrowhead : null, - startBinding: startIdx === 0 ? arrow.startBinding : null, - endBinding: endIdx === points.length - 1 ? arrow.endBinding : null, + x: startPoint![0], + y: startPoint![1], + startArrowhead, + endArrowhead, + startBinding: startIdx === 0 ? arrow.startBinding : startBinding, + endBinding: + endIdx === points.length - 1 ? arrow.endBinding : endBinding, }, - elementsMap, + fakeElementsMap, [ pointFrom(0, 0), pointFrom( - points[endIdx][0] - points[startIdx][0], - points[endIdx][1] - points[startIdx][1], + endPoint![0] - startPoint![0], + endPoint![1] - startPoint![1], ), ], options, - ) ?? [] - ); - }) + ) ?? [], + ) .flatMap((segment, idx, segments) => { - if (segments.length > 1) { - if (idx === 0) { - return segment.slice(0, -1); - } - if (idx === segments.length - 1) { - return segment.slice(1); - } + // if (segments.length > 1) { + // if (idx === 0) { + // return segment.slice(0, -1); + // } + // if (idx === segments.length - 1) { + // return segment.slice(1); + // } - return segment.slice(1, -1); - } + // return segment.slice(1, -1); + // } return segment; }); - return normalizedArrowElementUpdate(unified, nextFixedSegments); + return normalizedArrowElementUpdate( + simplifyElbowArrowPoints(unified), + nextFixedSegments, + ); }; /** diff --git a/packages/excalidraw/element/heading.ts b/packages/excalidraw/element/heading.ts index 21e95ec02a9a..e15a2c62b256 100644 --- a/packages/excalidraw/element/heading.ts +++ b/packages/excalidraw/element/heading.ts @@ -68,7 +68,7 @@ export const headingForPointFromElement = < >( element: Readonly, aabb: Readonly, - p: Readonly, + p: Readonly, ): Heading => { const SEARCH_CONE_MULTIPLIER = 2; @@ -122,14 +122,22 @@ export const headingForPointFromElement = < element.angle, ); - if (triangleIncludesPoint([top, right, midPoint] as Triangle, p)) { + if ( + triangleIncludesPoint([top, right, midPoint] as Triangle, p) + ) { return headingForDiamond(top, right); } else if ( - triangleIncludesPoint([right, bottom, midPoint] as Triangle, p) + triangleIncludesPoint( + [right, bottom, midPoint] as Triangle, + p, + ) ) { return headingForDiamond(right, bottom); } else if ( - triangleIncludesPoint([bottom, left, midPoint] as Triangle, p) + triangleIncludesPoint( + [bottom, left, midPoint] as Triangle, + p, + ) ) { return headingForDiamond(bottom, left); } @@ -158,17 +166,17 @@ export const headingForPointFromElement = < SEARCH_CONE_MULTIPLIER, ) as Point; - return triangleIncludesPoint( + return triangleIncludesPoint( [topLeft, topRight, midPoint] as Triangle, p, ) ? HEADING_UP - : triangleIncludesPoint( + : triangleIncludesPoint( [topRight, bottomRight, midPoint] as Triangle, p, ) ? HEADING_RIGHT - : triangleIncludesPoint( + : triangleIncludesPoint( [bottomRight, bottomLeft, midPoint] as Triangle, p, ) From 081e3d91faff0fa8b2cb870b9105cb81db8d15fc Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 14 Oct 2024 14:41:42 +0200 Subject: [PATCH 018/283] Working segment calc, but fixed segment movement is drifting --- packages/excalidraw/element/elbowarrow.ts | 59 +++++++++++++---------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 24ae25a849a3..9e0432d21af9 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -1,4 +1,5 @@ import { + lineSegment, pointFrom, pointScaleFromOrigin, pointTranslate, @@ -13,7 +14,7 @@ import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; -import { debugDrawBounds, debugDrawPoint } from "../visualdebug"; +import { debugDrawBounds, debugDrawLine, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -33,7 +34,6 @@ import { HEADING_LEFT, HEADING_RIGHT, HEADING_UP, - headingForPointFromElement, vectorToHeading, } from "./heading"; import type { ElementUpdate } from "./mutateElement"; @@ -226,65 +226,63 @@ export const updateElbowArrowPoints = ( segmentUpdates[idx].endArrowhead = item?.endIdx === points.length - 1 ? arrow.endArrowhead : null; segmentUpdates[idx].startBinding = - elements[idx - 1]?.el && elements[idx - 1]?.startHeading + item.el && item.startHeading ? { - elementId: elements[idx - 1].el!.id, + elementId: item.el!.id, focus: 0, gap: 0, - fixedPoint: compareHeading( - elements[idx - 1].startHeading!, - HEADING_DOWN, - ) + fixedPoint: compareHeading(item.startHeading, HEADING_DOWN) ? [0.51, 1] - : compareHeading(elements[idx - 1].startHeading!, HEADING_LEFT) + : compareHeading(item.startHeading, HEADING_LEFT) ? [0, 0.51] - : compareHeading(elements[idx - 1].startHeading!, HEADING_UP) + : compareHeading(item.startHeading, HEADING_UP) ? [0.51, 0] : [1, 0.51], } : null; segmentUpdates[idx].endBinding = - item.el && item.endHeading + elements[idx + 1]?.el && elements[idx + 1]?.endHeading ? { - elementId: item.el!.id, + elementId: elements[idx + 1].el!.id, focus: 0, gap: 0, - fixedPoint: compareHeading(item.endHeading, HEADING_DOWN) + fixedPoint: compareHeading( + elements[idx + 1].endHeading!, + HEADING_DOWN, + ) ? [0.51, 1] - : compareHeading(item.endHeading, HEADING_LEFT) + : compareHeading(elements[idx + 1].endHeading!, HEADING_LEFT) ? [0, 0.51] - : compareHeading(item.endHeading, HEADING_UP) + : compareHeading(elements[idx + 1].endHeading!, HEADING_UP) ? [0.51, 0] : [1, 0.51], } : null; segmentUpdates[idx].startPoint = - elements[idx - 1]?.el && segmentUpdates[idx].startBinding + item.el && segmentUpdates[idx].startBinding ? getGlobalFixedPointForBindableElement( segmentUpdates[idx].startBinding!.fixedPoint, - elements[idx - 1].el! as ExcalidrawBindableElement, + item.el as ExcalidrawBindableElement, ) : pointFrom( arrow.x + points[item.startIdx][0], arrow.y + points[item.startIdx][1], ); + segmentUpdates[idx].endPoint = - item.el && segmentUpdates[idx].endBinding + elements[idx + 1]?.el && segmentUpdates[idx].endBinding ? getGlobalFixedPointForBindableElement( segmentUpdates[idx].endBinding!.fixedPoint, - item.el as ExcalidrawBindableElement, + elements[idx + 1].el as ExcalidrawBindableElement, ) : pointFrom( arrow.x + points[item.endIdx][0], arrow.y + points[item.endIdx][1], ); - // elements[idx - 1]?.el && - // segmentUpdates[idx].startBinding && - // debugDrawPoint(segmentUpdates[idx].startPoint!, { color: "green" }); - // item.el && - // segmentUpdates[idx].endBinding && - // debugDrawPoint(segmentUpdates[idx].endPoint!, { color: "red" }); + // idx > 0 && + // debugDrawPoint(segmentUpdates[idx].startPoint, { color: "green" }); + // idx > 0 && debugDrawPoint(segmentUpdates[idx].endPoint, { color: "red" }); }, ); @@ -322,6 +320,17 @@ export const updateElbowArrowPoints = ( options, ) ?? [], ) + // .map((segment, six) => { + // segment.forEach((p, idx) => { + // //six === 1 && debugDrawPoint(p); + // idx > 0 && + // debugDrawLine(lineSegment(segment[idx - 1], p), { + // color: six > 0 ? "red" : "green", + // }); + // }); + + // return segment; + // }) .flatMap((segment, idx, segments) => { // if (segments.length > 1) { // if (idx === 0) { From f765ba990fd21fdcb074f97ee59092431cfe0bd7 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 14 Oct 2024 15:29:22 +0200 Subject: [PATCH 019/283] Segment fixing with movement, but whacky shape move --- packages/excalidraw/element/elbowarrow.ts | 51 ++++++++++++++--------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 9e0432d21af9..55bcb9f389fe 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -1,5 +1,4 @@ import { - lineSegment, pointFrom, pointScaleFromOrigin, pointTranslate, @@ -13,8 +12,7 @@ import { import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; -import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; -import { debugDrawBounds, debugDrawLine, debugDrawPoint } from "../visualdebug"; +import { invariant, isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -94,6 +92,7 @@ const createFakeElement = ( const mapSegmentsToFakeMidElements = ( arrow: ExcalidrawElbowArrowElement, segments: number[][], + points: LocalPoint[], ): { el: Ordered | null; startHeading: Heading | null; @@ -116,22 +115,20 @@ const mapSegmentsToFakeMidElements = ( } const start = pointFrom( - arrow.x + arrow.points[prevSegment[1]][0], - arrow.y + arrow.points[prevSegment[1]][1], + arrow.x + points[prevSegment[1]][0], + arrow.y + points[prevSegment[1]][1], ); const end = pointFrom( - arrow.x + arrow.points[startIdx][0], - arrow.y + arrow.points[startIdx][1], + arrow.x + points[startIdx][0], + arrow.y + points[startIdx][1], ); const el = createFakeElement( arrow, - arrow.points[prevSegment[1]], - arrow.points[startIdx], + points[prevSegment[1]], + points[startIdx], ); const bounds = [el.x, el.y, el.x + el.width, el.y + el.height] as Bounds; - debugDrawBounds(bounds); - prevSegment = [startIdx, endIdx]; return { @@ -190,7 +187,25 @@ export const updateElbowArrowPoints = ( const points = Array.from(updates.points); // Determine the arrow parts based on fixed segments - const segments = getArrowSegments(nextFixedSegments, updates.points); + const segments = getArrowSegments(nextFixedSegments, points); + + // Override segment end points + segments.forEach(([startIdx, endIdx], segmentIdx) => { + if ( + startIdx !== 0 && + !updates.fixedSegments?.[segmentIdx] && + arrow.fixedSegments?.[segmentIdx] + ) { + points[startIdx] = arrow.points[startIdx]; + } + if ( + endIdx !== arrow.points.length - 1 && + !updates.fixedSegments?.[segmentIdx] && + arrow.fixedSegments?.[segmentIdx] + ) { + points[endIdx] = arrow.points[arrow.points.length - 1]; + } + }); // Create a fake element at every segment mid point const segmentUpdates: { @@ -202,18 +217,18 @@ export const updateElbowArrowPoints = ( endBinding: FixedPointBinding | null; startArrowhead: Arrowhead | null; endArrowhead: Arrowhead | null; - }[] = segments.map(() => ({ + }[] = segments.map(([startIdx, endIdx]) => ({ startPoint: null, endPoint: null, - startIdx: -1, - endIdx: -1, + startIdx, + endIdx, startBinding: null, endBinding: null, startArrowhead: null, endArrowhead: null, })); - mapSegmentsToFakeMidElements(arrow, segments).forEach( + mapSegmentsToFakeMidElements(arrow, segments, points).forEach( (item, idx, elements) => { if (item.el) { fakeElementsMap.set(item.el.id, item.el); @@ -279,10 +294,6 @@ export const updateElbowArrowPoints = ( arrow.x + points[item.endIdx][0], arrow.y + points[item.endIdx][1], ); - - // idx > 0 && - // debugDrawPoint(segmentUpdates[idx].startPoint, { color: "green" }); - // idx > 0 && debugDrawPoint(segmentUpdates[idx].endPoint, { color: "red" }); }, ); From 92339ef4eef3ceb2e1451cf49559459f109c7dd4 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 14 Oct 2024 17:45:53 +0200 Subject: [PATCH 020/283] Working except fake element overlap --- packages/excalidraw/element/elbowarrow.ts | 137 +++++++----------- .../excalidraw/element/linearElementEditor.ts | 18 +-- 2 files changed, 63 insertions(+), 92 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 55bcb9f389fe..9cf76c8814db 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -13,6 +13,7 @@ import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; import { invariant, isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; +import { debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -81,10 +82,10 @@ const createFakeElement = ( ({ ...newElement({ type: "rectangle", - x: (arrow.x + start[0] + arrow.x + end[0]) / 2 - 5, - y: (arrow.y + start[1] + arrow.y + end[1]) / 2 - 5, - width: 10, - height: 10, + x: (arrow.x + start[0] + arrow.x + end[0]) / 2 - 1, + y: (arrow.y + start[1] + arrow.y + end[1]) / 2 - 1, + width: 2, + height: 2, }), index: "DONOTSYNC" as FractionalIndex, } as Ordered); @@ -182,7 +183,14 @@ export const updateElbowArrowPoints = ( disableBinding?: boolean; }, ): ElementUpdate => { - const nextFixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? []; + invariant( + arrow.points.length === 0 || arrow.points.length === updates.points.length, + "Arrow points and update points length is not equal", + ); + + const nextFixedSegments = Array.from( + new Set([...(arrow.fixedSegments ?? []), ...(updates.fixedSegments ?? [])]), + ); const fakeElementsMap = toBrandedType(new Map(elementsMap)); const points = Array.from(updates.points); @@ -192,19 +200,12 @@ export const updateElbowArrowPoints = ( // Override segment end points segments.forEach(([startIdx, endIdx], segmentIdx) => { if ( - startIdx !== 0 && - !updates.fixedSegments?.[segmentIdx] && - arrow.fixedSegments?.[segmentIdx] + !updates.fixedSegments?.find((i) => i === segmentIdx + 1) && + arrow.fixedSegments?.find((i) => i === segmentIdx + 1) ) { + points[startIdx + 1] = arrow.points[startIdx + 1]; points[startIdx] = arrow.points[startIdx]; } - if ( - endIdx !== arrow.points.length - 1 && - !updates.fixedSegments?.[segmentIdx] && - arrow.fixedSegments?.[segmentIdx] - ) { - points[endIdx] = arrow.points[arrow.points.length - 1]; - } }); // Create a fake element at every segment mid point @@ -247,12 +248,12 @@ export const updateElbowArrowPoints = ( focus: 0, gap: 0, fixedPoint: compareHeading(item.startHeading, HEADING_DOWN) - ? [0.51, 1] + ? [0.5, 1] : compareHeading(item.startHeading, HEADING_LEFT) - ? [0, 0.51] + ? [0, 0.5] : compareHeading(item.startHeading, HEADING_UP) - ? [0.51, 0] - : [1, 0.51], + ? [0.5, 0] + : [1, 0.5], } : null; segmentUpdates[idx].endBinding = @@ -265,12 +266,12 @@ export const updateElbowArrowPoints = ( elements[idx + 1].endHeading!, HEADING_DOWN, ) - ? [0.51, 1] + ? [0.5, 1] : compareHeading(elements[idx + 1].endHeading!, HEADING_LEFT) - ? [0, 0.51] + ? [0, 0.5] : compareHeading(elements[idx + 1].endHeading!, HEADING_UP) - ? [0.51, 0] - : [1, 0.51], + ? [0.5, 0] + : [1, 0.5], } : null; segmentUpdates[idx].startPoint = @@ -298,64 +299,38 @@ export const updateElbowArrowPoints = ( ); // Calculate points - const unified = segmentUpdates - .map( - ({ - startIdx, - endIdx, - startBinding, - endBinding, - startArrowhead, - endArrowhead, - startPoint, - endPoint, - }) => - routeElbowArrow( - { - x: startPoint![0], - y: startPoint![1], - startArrowhead, - endArrowhead, - startBinding: startIdx === 0 ? arrow.startBinding : startBinding, - endBinding: - endIdx === points.length - 1 ? arrow.endBinding : endBinding, - }, - fakeElementsMap, - [ - pointFrom(0, 0), - pointFrom( - endPoint![0] - startPoint![0], - endPoint![1] - startPoint![1], - ), - ], - options, - ) ?? [], - ) - // .map((segment, six) => { - // segment.forEach((p, idx) => { - // //six === 1 && debugDrawPoint(p); - // idx > 0 && - // debugDrawLine(lineSegment(segment[idx - 1], p), { - // color: six > 0 ? "red" : "green", - // }); - // }); - - // return segment; - // }) - .flatMap((segment, idx, segments) => { - // if (segments.length > 1) { - // if (idx === 0) { - // return segment.slice(0, -1); - // } - // if (idx === segments.length - 1) { - // return segment.slice(1); - // } - - // return segment.slice(1, -1); - // } - - return segment; - }); + const unified = segmentUpdates.flatMap( + ({ + startIdx, + endIdx, + startBinding, + endBinding, + startArrowhead, + endArrowhead, + startPoint, + endPoint, + }) => + routeElbowArrow( + { + x: startPoint![0], + y: startPoint![1], + startArrowhead, + endArrowhead, + startBinding: startIdx === 0 ? arrow.startBinding : startBinding, + endBinding: + endIdx === points.length - 1 ? arrow.endBinding : endBinding, + }, + fakeElementsMap, + [ + pointFrom(0, 0), + pointFrom( + endPoint![0] - startPoint![0], + endPoint![1] - startPoint![1], + ), + ], + options, + ) ?? [], + ); return normalizedArrowElementUpdate( simplifyElbowArrowPoints(unified), diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index a0530643609a..af9bd68a3c20 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1275,17 +1275,13 @@ export class LinearElementEditor { if (isElbowArrow(element)) { otherUpdates = otherUpdates || {}; - otherUpdates.fixedSegments = Array.from( - new Set([ - ...(element.fixedSegments ?? []), - // The segments being fixed are always the - // sub-ranges (minus their first idx) - ...targetPoints - .map((target) => target.index) - .sort() - .filter((i, _, a) => a.findIndex((t) => t === i - 1) > -1), - ]), - ).sort(); + // The segments being fixed are always the + // sub-ranges (minus their first idx) + otherUpdates.fixedSegments = targetPoints + .map((target) => target.index) + .sort() + .filter((i, _, a) => a.findIndex((t) => t === i - 1) > -1) + .sort(); } LinearElementEditor._updatePoints( From f72838992ebab3bc45a2602488e4e7872d9dd2d0 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 14 Oct 2024 19:29:03 +0200 Subject: [PATCH 021/283] Small fix to avoid some flexing --- packages/excalidraw/element/elbowarrow.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 9cf76c8814db..f420264f6bd1 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -13,7 +13,6 @@ import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; import { invariant, isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; -import { debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -82,10 +81,10 @@ const createFakeElement = ( ({ ...newElement({ type: "rectangle", - x: (arrow.x + start[0] + arrow.x + end[0]) / 2 - 1, - y: (arrow.y + start[1] + arrow.y + end[1]) / 2 - 1, - width: 2, - height: 2, + x: (arrow.x + start[0] + arrow.x + end[0]) / 2 - 2.5, + y: (arrow.y + start[1] + arrow.y + end[1]) / 2 - 2.5, + width: 5, + height: 5, }), index: "DONOTSYNC" as FractionalIndex, } as Ordered); @@ -198,7 +197,7 @@ export const updateElbowArrowPoints = ( const segments = getArrowSegments(nextFixedSegments, points); // Override segment end points - segments.forEach(([startIdx, endIdx], segmentIdx) => { + segments.forEach(([startIdx], segmentIdx) => { if ( !updates.fixedSegments?.find((i) => i === segmentIdx + 1) && arrow.fixedSegments?.find((i) => i === segmentIdx + 1) From 2005db9b9b587ace9babcd726c5f804e6602c8f2 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 14 Oct 2024 19:50:15 +0200 Subject: [PATCH 022/283] Refining bounding boxes --- packages/excalidraw/element/elbowarrow.ts | 51 +++++++++++++++-------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index f420264f6bd1..d8bd1cb2e6b5 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -13,6 +13,7 @@ import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; import { invariant, isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; +import { debugDrawBounds } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -327,7 +328,10 @@ export const updateElbowArrowPoints = ( endPoint![1] - startPoint![1], ), ], - options, + { + ...options, + disableDongles: true, + }, ) ?? [], ); @@ -360,6 +364,7 @@ const routeElbowArrow = ( options?: { isDragging?: boolean; disableBinding?: boolean; + disableDongles?: boolean; }, ): GlobalPoint[] | null => { const origStartGlobalPoint: GlobalPoint = pointTranslate< @@ -426,25 +431,29 @@ const routeElbowArrow = ( const startElementBounds = hoveredStartElement ? aabbForElement( hoveredStartElement, - offsetFromHeading( - startHeading, - arrow.startArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2, - 1, - ), + options?.disableDongles + ? [0, 0, 0, 0] + : 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, - ), + options?.disableDongles + ? [0, 0, 0, 0] + : offsetFromHeading( + endHeading, + arrow.endArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2, + 1, + ), ) : endPointBounds; const boundsOverlap = @@ -511,6 +520,9 @@ const routeElbowArrow = ( hoveredStartElement && aabbForElement(hoveredStartElement), hoveredEndElement && aabbForElement(hoveredEndElement), ); + + dynamicAABBs.forEach((b) => debugDrawBounds(b)); + const startDonglePosition = getDonglePosition( dynamicAABBs[0], startHeading, @@ -525,9 +537,13 @@ const routeElbowArrow = ( // Canculate Grid positions const grid = calculateGrid( dynamicAABBs, - startDonglePosition ? startDonglePosition : startGlobalPoint, + startDonglePosition && !options?.disableDongles + ? startDonglePosition + : startGlobalPoint, startHeading, - endDonglePosition ? endDonglePosition : endGlobalPoint, + endDonglePosition && !options?.disableDongles + ? endDonglePosition + : endGlobalPoint, endHeading, commonBounds, ); @@ -547,6 +563,7 @@ const routeElbowArrow = ( startNode.closed = true; } const dongleOverlap = + !options?.disableDongles && startDongle && endDongle && (pointInsideBounds(startDongle.pos, dynamicAABBs[1]) || From 70ae12a92a18b500c520d0cd7cb519780ffab775 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 15 Oct 2024 21:02:26 +0200 Subject: [PATCH 023/283] Revert dongle disable Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 51 +++++++++-------------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index d8bd1cb2e6b5..102241d82f26 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -185,7 +185,7 @@ export const updateElbowArrowPoints = ( ): ElementUpdate => { invariant( arrow.points.length === 0 || arrow.points.length === updates.points.length, - "Arrow points and update points length is not equal", + `Arrow points and update points length is not equal ${arrow.points.length} !== ${updates.points.length}`, ); const nextFixedSegments = Array.from( @@ -328,10 +328,7 @@ export const updateElbowArrowPoints = ( endPoint![1] - startPoint![1], ), ], - { - ...options, - disableDongles: true, - }, + options, ) ?? [], ); @@ -364,7 +361,6 @@ const routeElbowArrow = ( options?: { isDragging?: boolean; disableBinding?: boolean; - disableDongles?: boolean; }, ): GlobalPoint[] | null => { const origStartGlobalPoint: GlobalPoint = pointTranslate< @@ -431,31 +427,29 @@ const routeElbowArrow = ( const startElementBounds = hoveredStartElement ? aabbForElement( hoveredStartElement, - options?.disableDongles - ? [0, 0, 0, 0] - : offsetFromHeading( - startHeading, - arrow.startArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2, - 1, - ), + offsetFromHeading( + startHeading, + arrow.startArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2, + 1, + ), ) : startPointBounds; const endElementBounds = hoveredEndElement ? aabbForElement( hoveredEndElement, - options?.disableDongles - ? [0, 0, 0, 0] - : offsetFromHeading( - endHeading, - arrow.endArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2, - 1, - ), + offsetFromHeading( + endHeading, + arrow.endArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2, + 1, + ), ) : endPointBounds; + debugDrawBounds(startElementBounds, { color: "red" }); + debugDrawBounds(endElementBounds, { color: "blue" }); const boundsOverlap = pointInsideBounds( startGlobalPoint, @@ -537,13 +531,9 @@ const routeElbowArrow = ( // Canculate Grid positions const grid = calculateGrid( dynamicAABBs, - startDonglePosition && !options?.disableDongles - ? startDonglePosition - : startGlobalPoint, + startDonglePosition ? startDonglePosition : startGlobalPoint, startHeading, - endDonglePosition && !options?.disableDongles - ? endDonglePosition - : endGlobalPoint, + endDonglePosition ? endDonglePosition : endGlobalPoint, endHeading, commonBounds, ); @@ -563,7 +553,6 @@ const routeElbowArrow = ( startNode.closed = true; } const dongleOverlap = - !options?.disableDongles && startDongle && endDongle && (pointInsideBounds(startDongle.pos, dynamicAABBs[1]) || From cb3d6bb68c5f0d0e0df834877d2ad595210009c6 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 16 Oct 2024 12:33:46 +0200 Subject: [PATCH 024/283] Migrating code Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 387 +++++++++++------- .../excalidraw/element/linearElementEditor.ts | 66 ++- packages/excalidraw/element/newElement.ts | 3 +- packages/excalidraw/element/types.ts | 13 +- 4 files changed, 310 insertions(+), 159 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 102241d82f26..bca520c2991a 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -49,6 +49,7 @@ import type { ExcalidrawBindableElement, ExcalidrawElement, FixedPointBinding, + FixedSegment, FractionalIndex, Ordered, } from "./types"; @@ -171,166 +172,266 @@ const getArrowSegments = ( return segments; }; +const segmentListMerge = ( + oldFixedSegments: FixedSegment[], + newFixedSegments: FixedSegment[], +): FixedSegment[] => { + const oldSegments: [number, FixedSegment][] = oldFixedSegments.map( + (segment) => [segment.index, segment], + ); + const newSegments: [number, FixedSegment][] = newFixedSegments.map( + (segment) => [segment.index, segment], + ); + return Array.from( + new Map([...oldSegments, ...newSegments]).values(), + ).sort((a, b) => a.index - b.index); +}; + +/** + * + */ export const updateElbowArrowPoints = ( arrow: ExcalidrawElbowArrowElement, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, updates: { points: readonly LocalPoint[]; - fixedSegments?: number[]; + fixedSegments?: FixedSegment[]; }, options?: { isDragging?: boolean; disableBinding?: boolean; }, ): ElementUpdate => { - invariant( - arrow.points.length === 0 || arrow.points.length === updates.points.length, - `Arrow points and update points length is not equal ${arrow.points.length} !== ${updates.points.length}`, - ); + invariant(updates.points.length > 1, "Too few points"); - const nextFixedSegments = Array.from( - new Set([...(arrow.fixedSegments ?? []), ...(updates.fixedSegments ?? [])]), - ); const fakeElementsMap = toBrandedType(new Map(elementsMap)); - const points = Array.from(updates.points); - - // Determine the arrow parts based on fixed segments - const segments = getArrowSegments(nextFixedSegments, points); - - // Override segment end points - segments.forEach(([startIdx], segmentIdx) => { - if ( - !updates.fixedSegments?.find((i) => i === segmentIdx + 1) && - arrow.fixedSegments?.find((i) => i === segmentIdx + 1) - ) { - points[startIdx + 1] = arrow.points[startIdx + 1]; - points[startIdx] = arrow.points[startIdx]; - } - }); + const nextFixedSegments = segmentListMerge( + arrow.fixedSegments ?? [], + updates?.fixedSegments ?? [], + ); - // Create a fake element at every segment mid point - const segmentUpdates: { - startPoint: GlobalPoint | null; - endPoint: GlobalPoint | null; - startIdx: number; - endIdx: number; - startBinding: FixedPointBinding | null; - endBinding: FixedPointBinding | null; - startArrowhead: Arrowhead | null; - endArrowhead: Arrowhead | null; - }[] = segments.map(([startIdx, endIdx]) => ({ - startPoint: null, - endPoint: null, - startIdx, - endIdx, - startBinding: null, - endBinding: null, - startArrowhead: null, - endArrowhead: null, - })); + invariant( + nextFixedSegments.findIndex((s) => s.index === arrow.points.length - 1) === + -1, + "Last fixed segment is endpoint!", + ); - mapSegmentsToFakeMidElements(arrow, segments, points).forEach( - (item, idx, elements) => { - if (item.el) { - fakeElementsMap.set(item.el.id, item.el); + let previousVal: { + point: GlobalPoint; + fixedPoint: FixedPointBinding["fixedPoint"] | null; + } = { + point: pointFrom( + arrow.x + updates.points[0][0], + arrow.y + updates.points[0][1], + ), + fixedPoint: null, + }; + const pointPairs = nextFixedSegments.map((segment, segmentIdx) => { + const el = { + ...newElement({ + type: "rectangle", + x: segment.anchor[0] - 2.5, + y: segment.anchor[1] - 2.5, + width: 5, + height: 5, + }), + index: "DONOTSYNC" as FractionalIndex, + } as Ordered; + fakeElementsMap.set(el.id, el); + + const endFixedPoint = getGlobalFixedPointForBindableElement( + compareHeading(segment.heading, HEADING_DOWN) + ? [0.5, 1] + : compareHeading(segment.heading, HEADING_LEFT) + ? [0, 0.5] + : compareHeading(segment.heading, HEADING_UP) + ? [0.5, 0] + : [1, 0.5], + el, + ); + const nextStartHeading = getGlobalFixedPointForBindableElement( + compareHeading(segment.heading, HEADING_DOWN) + ? [0.5, 0] + : compareHeading(segment.heading, HEADING_LEFT) + ? [1, 0.5] + : compareHeading(segment.heading, HEADING_UP) + ? [0.5, 1] + : [0, 0.5], + el, + ); + const tmp = { + x: previousVal.point[0], + y: previousVal.point[1], + startArrowhead: segmentIdx === 0 ? arrow.startArrowhead : null, + endArrowhead: null as Arrowhead | null, + startBinding: segmentIdx === 0 ? arrow.startBinding : { + elementId: } + points: [ + pointFrom(0, 0), + getGlobalFixedPointForBindableElement(endFixedPoint, el), + ], + }; + previousVal = { + point: getGlobalFixedPointForBindableElement( + compareHeading(segment.heading, HEADING_DOWN) + ? [0.5, 0] + : compareHeading(segment.heading, HEADING_LEFT) + ? [1, 0.5] + : compareHeading(segment.heading, HEADING_UP) + ? [0.5, 1] + : [0, 0.5], + el, + ), + fixedPoint: nextStartHeading, + }; + return tmp; + }); + pointPairs.push({ + x: previousVal.point[0], + y: previousVal.point[1], + points: [pointFrom(0, 0), updates.points[updates.points.length - 1]], + startArrowhead: null, + endArrowhead: arrow.endArrowhead, + }); - segmentUpdates[idx].startIdx = item?.startIdx ?? 0; - segmentUpdates[idx].endIdx = item?.endIdx ?? elements.length - 1; - segmentUpdates[idx].startArrowhead = - item?.startIdx === 0 ? arrow.startArrowhead : null; - segmentUpdates[idx].endArrowhead = - item?.endIdx === points.length - 1 ? arrow.endArrowhead : null; - segmentUpdates[idx].startBinding = - item.el && item.startHeading - ? { - elementId: item.el!.id, - focus: 0, - gap: 0, - fixedPoint: compareHeading(item.startHeading, HEADING_DOWN) - ? [0.5, 1] - : compareHeading(item.startHeading, HEADING_LEFT) - ? [0, 0.5] - : compareHeading(item.startHeading, HEADING_UP) - ? [0.5, 0] - : [1, 0.5], - } - : null; - segmentUpdates[idx].endBinding = - elements[idx + 1]?.el && elements[idx + 1]?.endHeading - ? { - elementId: elements[idx + 1].el!.id, - focus: 0, - gap: 0, - fixedPoint: compareHeading( - elements[idx + 1].endHeading!, - HEADING_DOWN, - ) - ? [0.5, 1] - : compareHeading(elements[idx + 1].endHeading!, HEADING_LEFT) - ? [0, 0.5] - : compareHeading(elements[idx + 1].endHeading!, HEADING_UP) - ? [0.5, 0] - : [1, 0.5], - } - : null; - segmentUpdates[idx].startPoint = - item.el && segmentUpdates[idx].startBinding - ? getGlobalFixedPointForBindableElement( - segmentUpdates[idx].startBinding!.fixedPoint, - item.el as ExcalidrawBindableElement, - ) - : pointFrom( - arrow.x + points[item.startIdx][0], - arrow.y + points[item.startIdx][1], - ); - - segmentUpdates[idx].endPoint = - elements[idx + 1]?.el && segmentUpdates[idx].endBinding - ? getGlobalFixedPointForBindableElement( - segmentUpdates[idx].endBinding!.fixedPoint, - elements[idx + 1].el as ExcalidrawBindableElement, - ) - : pointFrom( - arrow.x + points[item.endIdx][0], - arrow.y + points[item.endIdx][1], - ); - }, - ); - - // Calculate points - const unified = segmentUpdates.flatMap( - ({ - startIdx, - endIdx, - startBinding, - endBinding, - startArrowhead, - endArrowhead, - startPoint, - endPoint, - }) => - routeElbowArrow( - { - x: startPoint![0], - y: startPoint![1], - startArrowhead, - endArrowhead, - startBinding: startIdx === 0 ? arrow.startBinding : startBinding, - endBinding: - endIdx === points.length - 1 ? arrow.endBinding : endBinding, - }, - fakeElementsMap, - [ - pointFrom(0, 0), - pointFrom( - endPoint![0] - startPoint![0], - endPoint![1] - startPoint![1], - ), - ], - options, - ) ?? [], - ); + // // Determine the arrow parts based on fixed segments + // const segments = getArrowSegments(nextFixedSegments, points); + + // // Override segment end points + // segments.forEach(([startIdx], segmentIdx) => { + // if ( + // !updates.fixedSegments?.find((i) => i === segmentIdx + 1) && + // arrow.fixedSegments?.find((i) => i === segmentIdx + 1) + // ) { + // points[startIdx + 1] = arrow.points[startIdx + 1]; + // points[startIdx] = arrow.points[startIdx]; + // } + // }); + + // // Create a fake element at every segment mid point + // const segmentUpdates: { + // startPoint: GlobalPoint | null; + // endPoint: GlobalPoint | null; + // startIdx: number; + // endIdx: number; + // startBinding: FixedPointBinding | null; + // endBinding: FixedPointBinding | null; + // startArrowhead: Arrowhead | null; + // endArrowhead: Arrowhead | null; + // }[] = segments.map(([startIdx, endIdx]) => ({ + // startPoint: null, + // endPoint: null, + // startIdx, + // endIdx, + // startBinding: null, + // endBinding: null, + // startArrowhead: null, + // endArrowhead: null, + // })); + + // mapSegmentsToFakeMidElements(arrow, segments, points).forEach( + // (item, idx, elements) => { + // if (item.el) { + // fakeElementsMap.set(item.el.id, item.el); + // } + + // segmentUpdates[idx].startIdx = item?.startIdx ?? 0; + // segmentUpdates[idx].endIdx = item?.endIdx ?? elements.length - 1; + // segmentUpdates[idx].startArrowhead = + // item?.startIdx === 0 ? arrow.startArrowhead : null; + // segmentUpdates[idx].endArrowhead = + // item?.endIdx === points.length - 1 ? arrow.endArrowhead : null; + // segmentUpdates[idx].startBinding = + // item.el && item.startHeading + // ? { + // elementId: item.el!.id, + // focus: 0, + // gap: 0, + // fixedPoint: compareHeading(item.startHeading, HEADING_DOWN) + // ? [0.5, 1] + // : compareHeading(item.startHeading, HEADING_LEFT) + // ? [0, 0.5] + // : compareHeading(item.startHeading, HEADING_UP) + // ? [0.5, 0] + // : [1, 0.5], + // } + // : null; + // segmentUpdates[idx].endBinding = + // elements[idx + 1]?.el && elements[idx + 1]?.endHeading + // ? { + // elementId: elements[idx + 1].el!.id, + // focus: 0, + // gap: 0, + // fixedPoint: compareHeading( + // elements[idx + 1].endHeading!, + // HEADING_DOWN, + // ) + // ? [0.5, 1] + // : compareHeading(elements[idx + 1].endHeading!, HEADING_LEFT) + // ? [0, 0.5] + // : compareHeading(elements[idx + 1].endHeading!, HEADING_UP) + // ? [0.5, 0] + // : [1, 0.5], + // } + // : null; + // segmentUpdates[idx].startPoint = + // item.el && segmentUpdates[idx].startBinding + // ? getGlobalFixedPointForBindableElement( + // segmentUpdates[idx].startBinding!.fixedPoint, + // item.el as ExcalidrawBindableElement, + // ) + // : pointFrom( + // arrow.x + points[item.startIdx][0], + // arrow.y + points[item.startIdx][1], + // ); + + // segmentUpdates[idx].endPoint = + // elements[idx + 1]?.el && segmentUpdates[idx].endBinding + // ? getGlobalFixedPointForBindableElement( + // segmentUpdates[idx].endBinding!.fixedPoint, + // elements[idx + 1].el as ExcalidrawBindableElement, + // ) + // : pointFrom( + // arrow.x + points[item.endIdx][0], + // arrow.y + points[item.endIdx][1], + // ); + // }, + // ); + + // // Calculate points + // const unified = segmentUpdates.flatMap( + // ({ + // startIdx, + // endIdx, + // startBinding, + // endBinding, + // startArrowhead, + // endArrowhead, + // startPoint, + // endPoint, + // }) => + // routeElbowArrow( + // { + // x: startPoint![0], + // y: startPoint![1], + // startArrowhead, + // endArrowhead, + // startBinding: startIdx === 0 ? arrow.startBinding : startBinding, + // endBinding: + // endIdx === points.length - 1 ? arrow.endBinding : endBinding, + // }, + // fakeElementsMap, + // [ + // pointFrom(0, 0), + // pointFrom( + // endPoint![0] - startPoint![0], + // endPoint![1] - startPoint![1], + // ), + // ], + // options, + // ) ?? [], + // ); return normalizedArrowElementUpdate( simplifyElbowArrowPoints(unified), diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index af9bd68a3c20..166821215e20 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -10,6 +10,7 @@ import type { OrderedExcalidrawElement, FixedPointBinding, SceneElementsMap, + FixedSegment, } from "./types"; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; import type { Bounds } from "./bounds"; @@ -65,6 +66,12 @@ import { mapIntervalToBezierT, } from "../shapes"; import { getGridPoint } from "../snapping"; +import { + HEADING_DOWN, + HEADING_LEFT, + HEADING_RIGHT, + HEADING_UP, +} from "./heading"; const editorMidPointsCache: { version: number | null; @@ -1231,7 +1238,7 @@ export class LinearElementEditor { otherUpdates?: { startBinding?: PointBinding | null; endBinding?: PointBinding | null; - fixedSegments?: number[] | null; + fixedSegments?: FixedSegment[] | null; }, options?: { changedElements?: Map; @@ -1274,14 +1281,49 @@ export class LinearElementEditor { }); if (isElbowArrow(element)) { - otherUpdates = otherUpdates || {}; - // The segments being fixed are always the - // sub-ranges (minus their first idx) - otherUpdates.fixedSegments = targetPoints - .map((target) => target.index) - .sort() - .filter((i, _, a) => a.findIndex((t) => t === i - 1) > -1) - .sort(); + otherUpdates = { + ...otherUpdates, + fixedSegments: targetPoints + .map((target) => target.index) + .sort() + // The segment id being fixed is always the last point index of the + // arrow segment, so it's always > 0. Also segments should always + // be 2 points. + .filter((i, _, a) => a.findIndex((t) => t === i - 1) > -1) + // Map segments to mid points of the given segment represented by + // the two points, and the direction. The segment idx we will need + // to know if a given segment is manually moved. + // NOTE: Segment indices are not permanent, the arrow update + // might simplify the arrow and remove/merge segments. + .map((idx) => { + if (nextPoints[idx][0] === nextPoints[idx - 1][0]) { + const anchor = pointFrom( + nextPoints[idx][0], + (nextPoints[idx][1] - nextPoints[idx - 1][1]) / 2, + ); + return { + anchor, + heading: + anchor[1] > nextPoints[idx - 1][1] + ? HEADING_UP + : HEADING_DOWN, + index: idx, + }; + } + const anchor = pointFrom( + (nextPoints[idx][0] - nextPoints[idx - 1][0]) / 2, + nextPoints[idx][1], + ); + return { + anchor, + heading: + anchor[0] > nextPoints[idx - 1][0] + ? HEADING_LEFT + : HEADING_RIGHT, + index: idx, + }; + }), + }; } LinearElementEditor._updatePoints( @@ -1406,7 +1448,7 @@ export class LinearElementEditor { otherUpdates?: { startBinding?: PointBinding | null; endBinding?: PointBinding | null; - fixedSegments?: number[] | null; + fixedSegments?: FixedSegment[] | null; }, options?: { changedElements?: Map; @@ -1417,7 +1459,7 @@ export class LinearElementEditor { const updates: { startBinding?: FixedPointBinding | null; endBinding?: FixedPointBinding | null; - fixedSegments?: number[] | null; + fixedSegments?: FixedSegment[] | null; points?: LocalPoint[]; } = {}; if (otherUpdates?.startBinding !== undefined) { @@ -1435,7 +1477,7 @@ export class LinearElementEditor { : null; } if (otherUpdates?.fixedSegments) { - updates.fixedSegments = otherUpdates.fixedSegments.sort(); + updates.fixedSegments = otherUpdates.fixedSegments; } updates.points = Array.from(nextPoints); diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index c6670d9b27f2..74d04e5e0ef2 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -18,6 +18,7 @@ import type { ExcalidrawIframeElement, ElementsMap, ExcalidrawArrowElement, + FixedSegment, } from "./types"; import { arrayToMap, @@ -457,7 +458,7 @@ export const newArrowElement = ( endArrowhead?: Arrowhead | null; points?: ExcalidrawArrowElement["points"]; elbowed?: boolean; - fixedSegments?: number[] | null; + fixedSegments?: FixedSegment[] | null; } & ElementConstructorOpts, ): NonDeleted => { return { diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 9395ecd03d72..d4ad15058172 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -1,4 +1,4 @@ -import type { LocalPoint, Radians } from "../../math"; +import type { GlobalPoint, LocalPoint, Radians } from "../../math"; import type { FONT_FAMILY, ROUNDNESS, @@ -12,6 +12,7 @@ import type { Merge, ValueOf, } from "../utility-types"; +import type { Heading } from "./heading"; export type ChartType = "bar" | "line"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; @@ -283,6 +284,12 @@ export type FixedPointBinding = Merge< } >; +export type FixedSegment = { + anchor: GlobalPoint; + heading: Heading; + index: number; +}; + export type Arrowhead = | "arrow" | "bar" @@ -309,7 +316,7 @@ export type ExcalidrawArrowElement = ExcalidrawLinearElement & Readonly<{ type: "arrow"; elbowed: boolean; - fixedSegments: number[] | null; + fixedSegments: FixedSegment[] | null; }>; export type ExcalidrawElbowArrowElement = Merge< @@ -318,7 +325,7 @@ export type ExcalidrawElbowArrowElement = Merge< elbowed: true; startBinding: FixedPointBinding | null; endBinding: FixedPointBinding | null; - fixedSegments: number[] | null; + fixedSegments: FixedSegment[] | null; } >; From 373e249012a38d9e545f04df1406155ba7493b2a Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 16 Oct 2024 13:49:09 +0200 Subject: [PATCH 025/283] Rewrite complete, testing --- packages/excalidraw/element/elbowarrow.ts | 388 ++++++---------------- 1 file changed, 95 insertions(+), 293 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index bca520c2991a..4a3dc0a339ec 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -47,7 +47,6 @@ import type { Arrowhead, ElementsMap, ExcalidrawBindableElement, - ExcalidrawElement, FixedPointBinding, FixedSegment, FractionalIndex, @@ -75,103 +74,6 @@ type Grid = { const BASE_PADDING = 40; -const createFakeElement = ( - arrow: ExcalidrawElbowArrowElement, - start: LocalPoint, - end: LocalPoint, -) => - ({ - ...newElement({ - type: "rectangle", - x: (arrow.x + start[0] + arrow.x + end[0]) / 2 - 2.5, - y: (arrow.y + start[1] + arrow.y + end[1]) / 2 - 2.5, - width: 5, - height: 5, - }), - index: "DONOTSYNC" as FractionalIndex, - } as Ordered); - -const mapSegmentsToFakeMidElements = ( - arrow: ExcalidrawElbowArrowElement, - segments: number[][], - points: LocalPoint[], -): { - el: Ordered | null; - startHeading: Heading | null; - endHeading: Heading | null; - startIdx: number; - endIdx: number; -}[] => { - let prevSegment: [number, number] | null = null; - - return segments.map(([startIdx, endIdx], idx) => { - if (!prevSegment) { - prevSegment = [startIdx, endIdx]; - return { - el: null, - startHeading: null, - endHeading: null, - startIdx, - endIdx, - }; - } - - const start = pointFrom( - arrow.x + points[prevSegment[1]][0], - arrow.y + points[prevSegment[1]][1], - ); - const end = pointFrom( - arrow.x + points[startIdx][0], - arrow.y + points[startIdx][1], - ); - const el = createFakeElement( - arrow, - points[prevSegment[1]], - points[startIdx], - ); - const bounds = [el.x, el.y, el.x + el.width, el.y + el.height] as Bounds; - - prevSegment = [startIdx, endIdx]; - - return { - el, - startHeading: e2h(bounds, start), - endHeading: e2h(bounds, end), - startIdx, - endIdx, - }; - }); -}; - -const e2h = (bounds: Bounds, point: GlobalPoint): Heading => { - const center = pointFrom( - (bounds[0] + bounds[2]) / 2, - (bounds[1] + bounds[3]) / 2, - ); - return point[0] - center[0] < 0.05 - ? point[1] > center[1] - ? HEADING_DOWN - : HEADING_UP - : point[0] > center[0] - ? HEADING_RIGHT - : HEADING_LEFT; -}; - -const getArrowSegments = ( - fixedSegmentIds: number[], - points: readonly LocalPoint[], -) => { - let prevIdx = 0; - const segments = fixedSegmentIds.map((segmentIdx) => { - const ret = [prevIdx, segmentIdx]; - prevIdx = segmentIdx - 1; - return ret; - }); - segments.push([prevIdx, points.length - 1]); - - return segments; -}; - const segmentListMerge = ( oldFixedSegments: FixedSegment[], newFixedSegments: FixedSegment[], @@ -202,8 +104,6 @@ export const updateElbowArrowPoints = ( disableBinding?: boolean; }, ): ElementUpdate => { - invariant(updates.points.length > 1, "Too few points"); - const fakeElementsMap = toBrandedType(new Map(elementsMap)); const nextFixedSegments = segmentListMerge( arrow.fixedSegments ?? [], @@ -219,61 +119,75 @@ export const updateElbowArrowPoints = ( let previousVal: { point: GlobalPoint; fixedPoint: FixedPointBinding["fixedPoint"] | null; + elementId: string | null; } = { point: pointFrom( arrow.x + updates.points[0][0], arrow.y + updates.points[0][1], ), fixedPoint: null, + elementId: null, }; - const pointPairs = nextFixedSegments.map((segment, segmentIdx) => { - const el = { - ...newElement({ - type: "rectangle", - x: segment.anchor[0] - 2.5, - y: segment.anchor[1] - 2.5, - width: 5, - height: 5, - }), - index: "DONOTSYNC" as FractionalIndex, - } as Ordered; - fakeElementsMap.set(el.id, el); - - const endFixedPoint = getGlobalFixedPointForBindableElement( - compareHeading(segment.heading, HEADING_DOWN) - ? [0.5, 1] - : compareHeading(segment.heading, HEADING_LEFT) - ? [0, 0.5] - : compareHeading(segment.heading, HEADING_UP) - ? [0.5, 0] - : [1, 0.5], - el, - ); - const nextStartHeading = getGlobalFixedPointForBindableElement( - compareHeading(segment.heading, HEADING_DOWN) - ? [0.5, 0] - : compareHeading(segment.heading, HEADING_LEFT) - ? [1, 0.5] - : compareHeading(segment.heading, HEADING_UP) - ? [0.5, 1] - : [0, 0.5], - el, - ); - const tmp = { - x: previousVal.point[0], - y: previousVal.point[1], - startArrowhead: segmentIdx === 0 ? arrow.startArrowhead : null, - endArrowhead: null as Arrowhead | null, - startBinding: segmentIdx === 0 ? arrow.startBinding : { - elementId: - } - points: [ - pointFrom(0, 0), - getGlobalFixedPointForBindableElement(endFixedPoint, el), - ], - }; - previousVal = { - point: getGlobalFixedPointForBindableElement( + const pointPairs: ExcalidrawElbowArrowElement[] = nextFixedSegments.map( + (segment, segmentIdx) => { + const el = { + ...newElement({ + type: "rectangle", + x: segment.anchor[0] - 2.5, + y: segment.anchor[1] - 2.5, + width: 5, + height: 5, + }), + index: "DONOTSYNC" as FractionalIndex, + } as Ordered; + fakeElementsMap.set(el.id, el); + + const endFixedPoint = getGlobalFixedPointForBindableElement( + compareHeading(segment.heading, HEADING_DOWN) + ? [0.5, 1] + : compareHeading(segment.heading, HEADING_LEFT) + ? [0, 0.5] + : compareHeading(segment.heading, HEADING_UP) + ? [0.5, 0] + : [1, 0.5], + el, + ); + const endGlobalPoint = getGlobalFixedPointForBindableElement( + endFixedPoint, + el, + ); + + const tmp = { + ...arrow, + x: previousVal.point[0], + y: previousVal.point[1], + startArrowhead: segmentIdx === 0 ? arrow.startArrowhead : null, + endArrowhead: null as Arrowhead | null, + startBinding: + segmentIdx === 0 + ? arrow.startBinding + : ({ + elementId: previousVal.elementId, + focus: 0, + gap: 0, + fixedPoint: previousVal.fixedPoint, + } as FixedPointBinding), + endBinding: { + elementId: el.id, + focus: 0, + gap: 0, + fixedPoint: endFixedPoint, + } as FixedPointBinding | null, + points: [ + pointFrom(0, 0), + pointFrom( + endGlobalPoint[0] - previousVal.point[0], + endGlobalPoint[1] - previousVal.point[1], + ), + ], + fixedSegments: nextFixedSegments, + }; + const nextStartFixedPoint = getGlobalFixedPointForBindableElement( compareHeading(segment.heading, HEADING_DOWN) ? [0.5, 0] : compareHeading(segment.heading, HEADING_LEFT) @@ -282,156 +196,44 @@ export const updateElbowArrowPoints = ( ? [0.5, 1] : [0, 0.5], el, - ), - fixedPoint: nextStartHeading, - }; - return tmp; - }); + ); + previousVal = { + point: getGlobalFixedPointForBindableElement(nextStartFixedPoint, el), + fixedPoint: nextStartFixedPoint, + elementId: el.id, + }; + return tmp; + }, + ); pointPairs.push({ + ...arrow, x: previousVal.point[0], y: previousVal.point[1], points: [pointFrom(0, 0), updates.points[updates.points.length - 1]], startArrowhead: null, endArrowhead: arrow.endArrowhead, + startBinding: + nextFixedSegments.length === 0 + ? arrow.startBinding + : { + elementId: previousVal.elementId!, + focus: 0, + gap: 0, + fixedPoint: previousVal.fixedPoint!, + }, + endBinding: arrow.endBinding, }); - // // Determine the arrow parts based on fixed segments - // const segments = getArrowSegments(nextFixedSegments, points); - - // // Override segment end points - // segments.forEach(([startIdx], segmentIdx) => { - // if ( - // !updates.fixedSegments?.find((i) => i === segmentIdx + 1) && - // arrow.fixedSegments?.find((i) => i === segmentIdx + 1) - // ) { - // points[startIdx + 1] = arrow.points[startIdx + 1]; - // points[startIdx] = arrow.points[startIdx]; - // } - // }); - - // // Create a fake element at every segment mid point - // const segmentUpdates: { - // startPoint: GlobalPoint | null; - // endPoint: GlobalPoint | null; - // startIdx: number; - // endIdx: number; - // startBinding: FixedPointBinding | null; - // endBinding: FixedPointBinding | null; - // startArrowhead: Arrowhead | null; - // endArrowhead: Arrowhead | null; - // }[] = segments.map(([startIdx, endIdx]) => ({ - // startPoint: null, - // endPoint: null, - // startIdx, - // endIdx, - // startBinding: null, - // endBinding: null, - // startArrowhead: null, - // endArrowhead: null, - // })); - - // mapSegmentsToFakeMidElements(arrow, segments, points).forEach( - // (item, idx, elements) => { - // if (item.el) { - // fakeElementsMap.set(item.el.id, item.el); - // } - - // segmentUpdates[idx].startIdx = item?.startIdx ?? 0; - // segmentUpdates[idx].endIdx = item?.endIdx ?? elements.length - 1; - // segmentUpdates[idx].startArrowhead = - // item?.startIdx === 0 ? arrow.startArrowhead : null; - // segmentUpdates[idx].endArrowhead = - // item?.endIdx === points.length - 1 ? arrow.endArrowhead : null; - // segmentUpdates[idx].startBinding = - // item.el && item.startHeading - // ? { - // elementId: item.el!.id, - // focus: 0, - // gap: 0, - // fixedPoint: compareHeading(item.startHeading, HEADING_DOWN) - // ? [0.5, 1] - // : compareHeading(item.startHeading, HEADING_LEFT) - // ? [0, 0.5] - // : compareHeading(item.startHeading, HEADING_UP) - // ? [0.5, 0] - // : [1, 0.5], - // } - // : null; - // segmentUpdates[idx].endBinding = - // elements[idx + 1]?.el && elements[idx + 1]?.endHeading - // ? { - // elementId: elements[idx + 1].el!.id, - // focus: 0, - // gap: 0, - // fixedPoint: compareHeading( - // elements[idx + 1].endHeading!, - // HEADING_DOWN, - // ) - // ? [0.5, 1] - // : compareHeading(elements[idx + 1].endHeading!, HEADING_LEFT) - // ? [0, 0.5] - // : compareHeading(elements[idx + 1].endHeading!, HEADING_UP) - // ? [0.5, 0] - // : [1, 0.5], - // } - // : null; - // segmentUpdates[idx].startPoint = - // item.el && segmentUpdates[idx].startBinding - // ? getGlobalFixedPointForBindableElement( - // segmentUpdates[idx].startBinding!.fixedPoint, - // item.el as ExcalidrawBindableElement, - // ) - // : pointFrom( - // arrow.x + points[item.startIdx][0], - // arrow.y + points[item.startIdx][1], - // ); - - // segmentUpdates[idx].endPoint = - // elements[idx + 1]?.el && segmentUpdates[idx].endBinding - // ? getGlobalFixedPointForBindableElement( - // segmentUpdates[idx].endBinding!.fixedPoint, - // elements[idx + 1].el as ExcalidrawBindableElement, - // ) - // : pointFrom( - // arrow.x + points[item.endIdx][0], - // arrow.y + points[item.endIdx][1], - // ); - // }, - // ); - - // // Calculate points - // const unified = segmentUpdates.flatMap( - // ({ - // startIdx, - // endIdx, - // startBinding, - // endBinding, - // startArrowhead, - // endArrowhead, - // startPoint, - // endPoint, - // }) => - // routeElbowArrow( - // { - // x: startPoint![0], - // y: startPoint![1], - // startArrowhead, - // endArrowhead, - // startBinding: startIdx === 0 ? arrow.startBinding : startBinding, - // endBinding: - // endIdx === points.length - 1 ? arrow.endBinding : endBinding, - // }, - // fakeElementsMap, - // [ - // pointFrom(0, 0), - // pointFrom( - // endPoint![0] - startPoint![0], - // endPoint![1] - startPoint![1], - // ), - // ], - // options, - // ) ?? [], - // ); + const unified = pointPairs.flatMap((tmp, idx) => { + return ( + routeElbowArrow( + tmp, + fakeElementsMap, + tmp.points, + idx === pointPairs.length - 1 ? options : undefined, + ) ?? [] + ); + }); return normalizedArrowElementUpdate( simplifyElbowArrowPoints(unified), @@ -1280,14 +1082,14 @@ const getBindableElementForId = ( const normalizedArrowElementUpdate = ( global: GlobalPoint[], - nextFixedSegments: number[], + nextFixedSegments: FixedSegment[] | null, ): { points: LocalPoint[]; x: number; y: number; width: number; height: number; - fixedSegments: number[] | null; + fixedSegments: FixedSegment[] | null; } => { const offsetX = global[0][0]; const offsetY = global[0][1]; @@ -1303,7 +1105,7 @@ const normalizedArrowElementUpdate = ( points, x: offsetX, y: offsetY, - fixedSegments: nextFixedSegments.length ? nextFixedSegments : null, + fixedSegments: nextFixedSegments?.length ? nextFixedSegments : null, ...getSizeFromPoints(points), }; }; From 21e498d7da71a69571bb2f621d61a14496be2777 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 16 Oct 2024 21:31:03 +0200 Subject: [PATCH 026/283] Fixed bug with wrong endpoint --- packages/excalidraw/element/elbowarrow.ts | 186 ++++++++++-------- .../excalidraw/element/linearElementEditor.ts | 12 +- 2 files changed, 115 insertions(+), 83 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 4a3dc0a339ec..822b22eea4f5 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -13,7 +13,6 @@ import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; import { invariant, isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; -import { debugDrawBounds } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -72,6 +71,15 @@ type Grid = { data: (Node | null)[]; }; +type ElbowArrowState = { + x: number; + y: number; + startBinding: FixedPointBinding | null; + endBinding: FixedPointBinding | null; + startArrowhead: Arrowhead | null; + endArrowhead: Arrowhead | null; +}; + const BASE_PADDING = 40; const segmentListMerge = ( @@ -121,44 +129,43 @@ export const updateElbowArrowPoints = ( fixedPoint: FixedPointBinding["fixedPoint"] | null; elementId: string | null; } = { - point: pointFrom( + point: pointFrom( arrow.x + updates.points[0][0], arrow.y + updates.points[0][1], ), - fixedPoint: null, - elementId: null, + fixedPoint: arrow.startBinding?.fixedPoint ?? null, + elementId: arrow.startBinding?.elementId ?? null, }; - const pointPairs: ExcalidrawElbowArrowElement[] = nextFixedSegments.map( - (segment, segmentIdx) => { + const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = + nextFixedSegments.map((segment, segmentIdx) => { const el = { ...newElement({ type: "rectangle", - x: segment.anchor[0] - 2.5, - y: segment.anchor[1] - 2.5, - width: 5, - height: 5, + x: segment.anchor[0] - 5, + y: segment.anchor[1] - 5, + width: 10, + height: 10, }), index: "DONOTSYNC" as FractionalIndex, } as Ordered; fakeElementsMap.set(el.id, el); - const endFixedPoint = getGlobalFixedPointForBindableElement( - compareHeading(segment.heading, HEADING_DOWN) - ? [0.5, 1] - : compareHeading(segment.heading, HEADING_LEFT) - ? [0, 0.5] - : compareHeading(segment.heading, HEADING_UP) - ? [0.5, 0] - : [1, 0.5], - el, - ); + const endFixedPoint: [number, number] = compareHeading( + segment.heading, + HEADING_DOWN, + ) + ? [0.5, 1] + : compareHeading(segment.heading, HEADING_LEFT) + ? [0, 0.5] + : compareHeading(segment.heading, HEADING_UP) + ? [0.5, 0] + : [1, 0.5]; const endGlobalPoint = getGlobalFixedPointForBindableElement( endFixedPoint, el, ); - const tmp = { - ...arrow, + const state = { x: previousVal.point[0], y: previousVal.point[1], startArrowhead: segmentIdx === 0 ? arrow.startArrowhead : null, @@ -166,74 +173,95 @@ export const updateElbowArrowPoints = ( startBinding: segmentIdx === 0 ? arrow.startBinding - : ({ - elementId: previousVal.elementId, + : { + elementId: previousVal.elementId!, focus: 0, gap: 0, - fixedPoint: previousVal.fixedPoint, - } as FixedPointBinding), + fixedPoint: previousVal.fixedPoint!, + }, endBinding: { elementId: el.id, focus: 0, gap: 0, fixedPoint: endFixedPoint, - } as FixedPointBinding | null, - points: [ - pointFrom(0, 0), - pointFrom( - endGlobalPoint[0] - previousVal.point[0], - endGlobalPoint[1] - previousVal.point[1], - ), - ], - fixedSegments: nextFixedSegments, + }, }; - const nextStartFixedPoint = getGlobalFixedPointForBindableElement( - compareHeading(segment.heading, HEADING_DOWN) - ? [0.5, 0] - : compareHeading(segment.heading, HEADING_LEFT) - ? [1, 0.5] - : compareHeading(segment.heading, HEADING_UP) - ? [0.5, 1] - : [0, 0.5], - el, + const nextStartFixedPoint: [number, number] = compareHeading( + segment.heading, + HEADING_DOWN, + ) + ? [0.5, 0] + : compareHeading(segment.heading, HEADING_LEFT) + ? [1, 0.5] + : compareHeading(segment.heading, HEADING_UP) + ? [0.5, 1] + : [0, 0.5]; + const endLocalPoint = pointFrom( + endGlobalPoint[0] - previousVal.point[0], + endGlobalPoint[1] - previousVal.point[1], ); + previousVal = { point: getGlobalFixedPointForBindableElement(nextStartFixedPoint, el), fixedPoint: nextStartFixedPoint, elementId: el.id, }; - return tmp; + + return [state, [pointFrom(0, 0), endLocalPoint]]; + }); + pointPairs.push([ + { + x: previousVal.point[0], + y: previousVal.point[1], + startArrowhead: null, + endArrowhead: arrow.endArrowhead, + startBinding: + nextFixedSegments.length === 0 + ? arrow.startBinding + : { + elementId: previousVal.elementId!, + focus: 0, + gap: 0, + fixedPoint: previousVal.fixedPoint!, + }, + endBinding: arrow.endBinding, }, - ); - pointPairs.push({ - ...arrow, - x: previousVal.point[0], - y: previousVal.point[1], - points: [pointFrom(0, 0), updates.points[updates.points.length - 1]], - startArrowhead: null, - endArrowhead: arrow.endArrowhead, - startBinding: - nextFixedSegments.length === 0 - ? arrow.startBinding - : { - elementId: previousVal.elementId!, - focus: 0, - gap: 0, - fixedPoint: previousVal.fixedPoint!, - }, - endBinding: arrow.endBinding, - }); + [ + pointFrom(0, 0), + pointFrom( + updates.points[updates.points.length - 1][0] - previousVal.point[0], + updates.points[updates.points.length - 1][1] - previousVal.point[1], + ), + ], + ]); + + const unified = pointPairs + .map(([state, points], idx) => { + const raw = simplifyElbowArrowPoints( + routeElbowArrow( + state, + fakeElementsMap, + points, + idx, + idx === pointPairs.length - 1 || idx === 0 + ? options + : { + disableBinding: true, + }, + ) ?? [], + ); - const unified = pointPairs.flatMap((tmp, idx) => { - return ( - routeElbowArrow( - tmp, - fakeElementsMap, - tmp.points, - idx === pointPairs.length - 1 ? options : undefined, - ) ?? [] - ); - }); + const base = + nextFixedSegments.length > 1 ? nextFixedSegments[idx - 1].index : 0; + if (nextFixedSegments[idx]) { + nextFixedSegments[idx].index = base + raw.length - 2; + } + + return raw; + }) + .flatMap((s) => { + return s; + }); return normalizedArrowElementUpdate( simplifyElbowArrowPoints(unified), @@ -261,6 +289,7 @@ const routeElbowArrow = ( }, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, nextPoints: readonly LocalPoint[], + idx: number, options?: { isDragging?: boolean; disableBinding?: boolean; @@ -290,6 +319,7 @@ const routeElbowArrow = ( elementsMap, startElement, hoveredStartElement, + options?.isDragging, ); const endGlobalPoint = getGlobalPoint( @@ -351,8 +381,10 @@ const routeElbowArrow = ( ), ) : endPointBounds; - debugDrawBounds(startElementBounds, { color: "red" }); - debugDrawBounds(endElementBounds, { color: "blue" }); + //debugDrawBounds(startElementBounds, { color: "cyan" }); + //debugDrawBounds(endElementBounds, { color: "blue" }); + // debugDrawPoint(endGlobalPoint, { color: "yellow", permanent: true }); + //debugDrawPoint(origEndGlobalPoint, { color: "red", permanent: true }); const boundsOverlap = pointInsideBounds( startGlobalPoint, @@ -418,7 +450,7 @@ const routeElbowArrow = ( hoveredEndElement && aabbForElement(hoveredEndElement), ); - dynamicAABBs.forEach((b) => debugDrawBounds(b)); + //dynamicAABBs.forEach((b) => debugDrawBounds(b)); const startDonglePosition = getDonglePosition( dynamicAABBs[0], diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 166821215e20..50870bfbe6ec 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1298,26 +1298,26 @@ export class LinearElementEditor { .map((idx) => { if (nextPoints[idx][0] === nextPoints[idx - 1][0]) { const anchor = pointFrom( - nextPoints[idx][0], - (nextPoints[idx][1] - nextPoints[idx - 1][1]) / 2, + element.x + nextPoints[idx][0], + element.y + (nextPoints[idx][1] - nextPoints[idx - 1][1]) / 2, ); return { anchor, heading: - anchor[1] > nextPoints[idx - 1][1] + anchor[1] > element.y + nextPoints[idx - 1][1] ? HEADING_UP : HEADING_DOWN, index: idx, }; } const anchor = pointFrom( - (nextPoints[idx][0] - nextPoints[idx - 1][0]) / 2, - nextPoints[idx][1], + element.x + (nextPoints[idx][0] - nextPoints[idx - 1][0]) / 2, + element.y + nextPoints[idx][1], ); return { anchor, heading: - anchor[0] > nextPoints[idx - 1][0] + anchor[0] > element.x + nextPoints[idx - 1][0] ? HEADING_LEFT : HEADING_RIGHT, index: idx, From 36a09ae81f95206c6149d94f516fa53ceac778d9 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 17 Oct 2024 11:16:36 +0200 Subject: [PATCH 027/283] Fix coordspace translation issue --- packages/excalidraw/element/elbowarrow.ts | 56 ++++++++++++++++------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 822b22eea4f5..99269eeff151 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -13,6 +13,7 @@ import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; import { invariant, isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; +import { debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -228,13 +229,47 @@ export const updateElbowArrowPoints = ( }, [ pointFrom(0, 0), + // Translate from unsegmented local array point -> global point -> last segment local point pointFrom( - updates.points[updates.points.length - 1][0] - previousVal.point[0], - updates.points[updates.points.length - 1][1] - previousVal.point[1], + arrow.x + + updates.points[updates.points.length - 1][0] - + previousVal.point[0], + arrow.y + + updates.points[updates.points.length - 1][1] - + previousVal.point[1], ), ], ]); + const nfs = pointPairs.slice(1).map(([{ x, y }, points], idx) => { + const point = pointFrom(x + points[0][0], y + points[0][1]); + const [prevState, prevPoints] = pointPairs[idx - 1]; + const previous = pointFrom( + prevState.x + prevPoints[prevPoints.length - 1][0], + prevState.y + prevPoints[prevPoints.length - 1][1], + ); + if (point[0] === previous[0]) { + const anchor = pointFrom( + point[0], + (point[1] + previous[1]) / 2, + ); + return { + anchor, + heading: anchor[1] > previous[1] ? HEADING_UP : HEADING_DOWN, + index: idx, + }; + } + const anchor = pointFrom( + (point[0] + previous[0]) / 2, + point[1], + ); + return { + anchor, + heading: anchor[0] > previous[0] ? HEADING_LEFT : HEADING_RIGHT, + index: idx, + }; + }); + const unified = pointPairs .map(([state, points], idx) => { const raw = simplifyElbowArrowPoints( @@ -251,22 +286,13 @@ export const updateElbowArrowPoints = ( ) ?? [], ); - const base = - nextFixedSegments.length > 1 ? nextFixedSegments[idx - 1].index : 0; - if (nextFixedSegments[idx]) { - nextFixedSegments[idx].index = base + raw.length - 2; - } - return raw; }) .flatMap((s) => { return s; }); - - return normalizedArrowElementUpdate( - simplifyElbowArrowPoints(unified), - nextFixedSegments, - ); + //console.log(JSON.stringify(nfs, undefined, 2)); + return normalizedArrowElementUpdate(simplifyElbowArrowPoints(unified), nfs); }; /** @@ -381,10 +407,6 @@ const routeElbowArrow = ( ), ) : endPointBounds; - //debugDrawBounds(startElementBounds, { color: "cyan" }); - //debugDrawBounds(endElementBounds, { color: "blue" }); - // debugDrawPoint(endGlobalPoint, { color: "yellow", permanent: true }); - //debugDrawPoint(origEndGlobalPoint, { color: "red", permanent: true }); const boundsOverlap = pointInsideBounds( startGlobalPoint, From 1fce44c4f641690c531901a3be7cb6e1aff66287 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 17 Oct 2024 11:43:58 +0200 Subject: [PATCH 028/283] No first or last mid point for elbow arrows --- packages/excalidraw/renderer/interactiveScene.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 50b5498cdacd..c5e05c0329de 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -503,7 +503,11 @@ const renderLinearPointHandles = ( element, elementsMap, appState, - ).filter((midPoint): midPoint is GlobalPoint => midPoint !== null); + ).filter( + (midPoint, idx, midPoints): midPoint is GlobalPoint => + midPoint !== null && + !(isElbowArrow(element) && (idx === 0 || idx === midPoints.length - 1)), + ); midPoints.forEach((segmentMidPoint) => { if ( From 9b4fce4397be71b2ff6e37da56a895628b141609 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 17 Oct 2024 13:12:19 +0200 Subject: [PATCH 029/283] Fix mid-point alignment issue --- packages/excalidraw/element/elbowarrow.ts | 42 +++++++++++-------- .../excalidraw/element/linearElementEditor.ts | 32 +++++++------- 2 files changed, 42 insertions(+), 32 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 99269eeff151..85f5d2d9cc6c 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -13,7 +13,7 @@ import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; import { invariant, isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; -import { debugDrawPoint } from "../visualdebug"; +import { debugDrawBounds, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -241,33 +241,40 @@ export const updateElbowArrowPoints = ( ], ]); + let nfsBase = 0; const nfs = pointPairs.slice(1).map(([{ x, y }, points], idx) => { const point = pointFrom(x + points[0][0], y + points[0][1]); - const [prevState, prevPoints] = pointPairs[idx - 1]; + const [prevState, prevPoints] = pointPairs[idx]; const previous = pointFrom( prevState.x + prevPoints[prevPoints.length - 1][0], prevState.y + prevPoints[prevPoints.length - 1][1], ); - if (point[0] === previous[0]) { + let res; + if (point[0] - previous[0] < 0.05) { const anchor = pointFrom( point[0], (point[1] + previous[1]) / 2, ); - return { + res = { anchor, heading: anchor[1] > previous[1] ? HEADING_UP : HEADING_DOWN, - index: idx, + index: nfsBase + prevPoints.length, + }; + } else { + const anchor = pointFrom( + (point[0] + previous[0]) / 2, + point[1], + ); + res = { + anchor, + heading: anchor[0] > previous[0] ? HEADING_LEFT : HEADING_RIGHT, + index: nfsBase + prevPoints.length, }; } - const anchor = pointFrom( - (point[0] + previous[0]) / 2, - point[1], - ); - return { - anchor, - heading: anchor[0] > previous[0] ? HEADING_LEFT : HEADING_RIGHT, - index: idx, - }; + + nfsBase += prevPoints.length - 1; + + return res; }); const unified = pointPairs @@ -291,7 +298,7 @@ export const updateElbowArrowPoints = ( .flatMap((s) => { return s; }); - //console.log(JSON.stringify(nfs, undefined, 2)); + return normalizedArrowElementUpdate(simplifyElbowArrowPoints(unified), nfs); }; @@ -407,6 +414,8 @@ const routeElbowArrow = ( ), ) : endPointBounds; + debugDrawBounds(startElementBounds, { color: "pink", permanent: false }); + debugDrawBounds(endElementBounds, { color: "green", permanent: false }); const boundsOverlap = pointInsideBounds( startGlobalPoint, @@ -471,9 +480,6 @@ const routeElbowArrow = ( hoveredStartElement && aabbForElement(hoveredStartElement), hoveredEndElement && aabbForElement(hoveredEndElement), ); - - //dynamicAABBs.forEach((b) => debugDrawBounds(b)); - const startDonglePosition = getDonglePosition( dynamicAABBs[0], startHeading, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 50870bfbe6ec..7357d9a583a3 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1296,12 +1296,13 @@ export class LinearElementEditor { // NOTE: Segment indices are not permanent, the arrow update // might simplify the arrow and remove/merge segments. .map((idx) => { - if (nextPoints[idx][0] === nextPoints[idx - 1][0]) { + let res; + if (nextPoints[idx][0] - nextPoints[idx - 1][0] < 0.05) { const anchor = pointFrom( element.x + nextPoints[idx][0], element.y + (nextPoints[idx][1] - nextPoints[idx - 1][1]) / 2, ); - return { + res = { anchor, heading: anchor[1] > element.y + nextPoints[idx - 1][1] @@ -1309,19 +1310,22 @@ export class LinearElementEditor { : HEADING_DOWN, index: idx, }; + } else { + const anchor = pointFrom( + element.x + (nextPoints[idx][0] - nextPoints[idx - 1][0]) / 2, + element.y + nextPoints[idx][1], + ); + res = { + anchor, + heading: + anchor[0] > element.x + nextPoints[idx - 1][0] + ? HEADING_LEFT + : HEADING_RIGHT, + index: idx, + }; } - const anchor = pointFrom( - element.x + (nextPoints[idx][0] - nextPoints[idx - 1][0]) / 2, - element.y + nextPoints[idx][1], - ); - return { - anchor, - heading: - anchor[0] > element.x + nextPoints[idx - 1][0] - ? HEADING_LEFT - : HEADING_RIGHT, - index: idx, - }; + + return res; }), }; } From 81ce2dc7e3a199e9c80899d014a8dffc5843ac7e Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 17 Oct 2024 14:10:42 +0200 Subject: [PATCH 030/283] Moving fixed midpoint --- packages/excalidraw/element/elbowarrow.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 85f5d2d9cc6c..ceb952b4e9d7 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -13,7 +13,6 @@ import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; import { invariant, isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; -import { debugDrawBounds, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -139,11 +138,24 @@ export const updateElbowArrowPoints = ( }; const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = nextFixedSegments.map((segment, segmentIdx) => { + let anchor = segment.anchor; + if (arrow.startBinding && arrow.endBinding) { + const start = pointFrom(arrow.x, arrow.y); + const end = pointFrom( + arrow.x + updates.points[updates.points.length - 1][0], + arrow.y + updates.points[updates.points.length - 1][1], + ); + anchor = + compareHeading(segment.heading, HEADING_UP) || + compareHeading(segment.heading, HEADING_DOWN) + ? pointFrom(anchor[0], (start[1] + end[1]) / 2) + : pointFrom((start[0] + end[0]) / 2, anchor[1]); + } const el = { ...newElement({ type: "rectangle", - x: segment.anchor[0] - 5, - y: segment.anchor[1] - 5, + x: anchor[0] - 5, + y: anchor[1] - 5, width: 10, height: 10, }), @@ -414,8 +426,6 @@ const routeElbowArrow = ( ), ) : endPointBounds; - debugDrawBounds(startElementBounds, { color: "pink", permanent: false }); - debugDrawBounds(endElementBounds, { color: "green", permanent: false }); const boundsOverlap = pointInsideBounds( startGlobalPoint, From e56240ab9f65f05aeada9f1c6cb6d97fe2da8f25 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 17 Oct 2024 14:10:54 +0200 Subject: [PATCH 031/283] Additional lines intersection code --- packages/math/line.ts | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/math/line.ts b/packages/math/line.ts index c646e04d4620..89999baa91ff 100644 --- a/packages/math/line.ts +++ b/packages/math/line.ts @@ -1,4 +1,4 @@ -import { pointCenter, pointRotateRads } from "./point"; +import { pointCenter, pointFrom, pointRotateRads } from "./point"; import type { GlobalPoint, Line, LocalPoint, Radians } from "./types"; /** @@ -38,8 +38,16 @@ export function lineFromPointArray

( : undefined; } -// return the coordinates resulting from rotating the given line about an origin by an angle in degrees -// note that when the origin is not given, the midpoint of the given line is used as the origin +/** + * Return the coordinates resulting from rotating the given line about an + * origin by an angle in degrees note that when the origin is not given, + * the midpoint of the given line is used as the origin + * + * @param l + * @param angle + * @param origin + * @returns + */ export const lineRotate = ( l: Line, angle: Radians, @@ -50,3 +58,29 @@ export const lineRotate = ( pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle), ); }; + +/** + * Determines the intersection point (unless the lines are parallel) of two + * lines + * + * @param a + * @param b + * @returns + */ +export const linesIntersectAt = ( + a: Line, + b: Line, +): Point | null => { + const A1 = a[1][1] - a[0][1]; + const B1 = a[0][0] - a[1][0]; + const A2 = b[1][1] - b[0][1]; + const B2 = b[0][0] - b[1][0]; + const D = A1 * B2 - A2 * B1; + if (D !== 0) { + const C1 = A1 * a[0][0] + B1 * a[0][1]; + const C2 = A2 * b[0][0] + B2 * b[0][1]; + return pointFrom((C1 * B2 - C2 * B1) / D, (A1 * C2 - A2 * C1) / D); + } + + return null; +}; From 9174f2a5225b7e790afa4da4a63dacf2c70189c5 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 17 Oct 2024 14:12:41 +0200 Subject: [PATCH 032/283] Update snapshots --- .../data/__snapshots__/transform.test.ts.snap | 13 ++++++++++ .../__snapshots__/dragCreate.test.tsx.snap | 1 + .../tests/__snapshots__/history.test.tsx.snap | 26 +++++++++++++++++++ .../tests/__snapshots__/move.test.tsx.snap | 1 + .../multiPointCreate.test.tsx.snap | 1 + .../regressionTests.test.tsx.snap | 5 ++++ .../__snapshots__/selection.test.tsx.snap | 1 + .../data/__snapshots__/restore.test.ts.snap | 1 + 8 files changed, 49 insertions(+) diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index 967de923e12a..acf0a993c8aa 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -93,6 +93,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 35, @@ -151,6 +152,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "gap": 3.834326468444573, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -343,6 +345,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "gap": 205, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -447,6 +450,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -625,6 +629,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -843,6 +848,7 @@ exports[`Test Transform > should transform linear elements 1`] = ` "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -891,6 +897,7 @@ exports[`Test Transform > should transform linear elements 2`] = ` "endArrowhead": "triangle", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -1489,6 +1496,7 @@ exports[`Test Transform > should transform the elements correctly when linear el "gap": 5.299874999999986, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -1554,6 +1562,7 @@ exports[`Test Transform > should transform the elements correctly when linear el "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -1864,6 +1873,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -1917,6 +1927,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -1970,6 +1981,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -2023,6 +2035,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, diff --git a/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap index acc9b79f5d5b..59bd3cbd2ae7 100644 --- a/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap @@ -12,6 +12,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 50, diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index b5123134fc96..fbdf544a4069 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -193,6 +193,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 99, @@ -554,6 +555,7 @@ History { "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -788,6 +790,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -1061,6 +1064,7 @@ History { "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -1238,6 +1242,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": "2.61991", @@ -1607,6 +1612,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": "2.61991", @@ -1765,6 +1771,7 @@ History { "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": "22.36242", @@ -2315,6 +2322,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": "408.19672", @@ -2477,6 +2485,7 @@ History { "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -7255,6 +7264,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 10, @@ -7332,6 +7342,7 @@ History { "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 10, @@ -10254,6 +10265,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 30, @@ -10343,6 +10355,7 @@ History { "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 10, @@ -11079,6 +11092,7 @@ History { "gap": "3.53708", }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": "236.10000", @@ -15032,6 +15046,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -15403,6 +15418,7 @@ History { "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -15729,6 +15745,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -16024,6 +16041,7 @@ History { "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -16350,6 +16368,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -16645,6 +16664,7 @@ History { "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -16969,6 +16989,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -17335,6 +17356,7 @@ History { "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -17685,6 +17707,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -18067,6 +18090,7 @@ History { "gap": 1, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 0, @@ -19766,6 +19790,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 20, @@ -19847,6 +19872,7 @@ History { "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 10, diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index 654eccfea4de..5315b5c35459 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -195,6 +195,7 @@ exports[`move element > rectangles with binding arrow 7`] = ` "gap": 10, }, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": "81.47368", diff --git a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap index 1b312a55122c..686d539f9e84 100644 --- a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap @@ -10,6 +10,7 @@ exports[`multi point mode in linear elements > arrow 3`] = ` "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 110, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index 5debb398cdee..f0e391b65f08 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -6266,6 +6266,7 @@ History { "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 10, @@ -6407,6 +6408,7 @@ History { "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 10, @@ -8542,6 +8544,7 @@ History { "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 10, @@ -9173,6 +9176,7 @@ History { "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 10, @@ -14258,6 +14262,7 @@ History { "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 10, diff --git a/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap index 2eea908425ae..d0dc2a71a9fe 100644 --- a/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap @@ -10,6 +10,7 @@ exports[`select single element on the scene > arrow 1`] = ` "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 50, diff --git a/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap b/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap index d0c81fb907f2..f5b799b7c8bf 100644 --- a/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap +++ b/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap @@ -10,6 +10,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = ` "endArrowhead": null, "endBinding": null, "fillStyle": "solid", + "fixedSegments": [], "frameId": null, "groupIds": [], "height": 100, From 2d863905259f428b709087196fc547ec32f4079e Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 17 Oct 2024 15:06:48 +0200 Subject: [PATCH 033/283] Fix comparison --- packages/excalidraw/element/linearElementEditor.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 7357d9a583a3..92a7a39c6f06 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1297,7 +1297,7 @@ export class LinearElementEditor { // might simplify the arrow and remove/merge segments. .map((idx) => { let res; - if (nextPoints[idx][0] - nextPoints[idx - 1][0] < 0.05) { + if (Math.abs(nextPoints[idx][0] - nextPoints[idx - 1][0]) < 0.05) { const anchor = pointFrom( element.x + nextPoints[idx][0], element.y + (nextPoints[idx][1] - nextPoints[idx - 1][1]) / 2, @@ -1324,12 +1324,12 @@ export class LinearElementEditor { index: idx, }; } - + //console.log("LINEAR", res); return res; }), }; } - + console.log("..."); LinearElementEditor._updatePoints( element, nextPoints, From f86442a91535b0ed4a5b2e1285919f6b506986a5 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 17 Oct 2024 15:12:47 +0200 Subject: [PATCH 034/283] Fix the other comparison --- packages/excalidraw/element/elbowarrow.ts | 2 +- packages/excalidraw/element/linearElementEditor.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index ceb952b4e9d7..8882b26f2d70 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -262,7 +262,7 @@ export const updateElbowArrowPoints = ( prevState.y + prevPoints[prevPoints.length - 1][1], ); let res; - if (point[0] - previous[0] < 0.05) { + if (Math.abs(point[0] - previous[0]) < 0.05) { const anchor = pointFrom( point[0], (point[1] + previous[1]) / 2, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 92a7a39c6f06..f54e935a3247 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1324,12 +1324,12 @@ export class LinearElementEditor { index: idx, }; } - //console.log("LINEAR", res); + return res; }), }; } - console.log("..."); + LinearElementEditor._updatePoints( element, nextPoints, From 93cf179bceca32f690fb56cfdb8d816b3362e063 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 17 Oct 2024 15:48:38 +0200 Subject: [PATCH 035/283] Side flipping --- packages/excalidraw/element/elbowarrow.ts | 63 +++++++++++++++-------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 8882b26f2d70..91eff578250b 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -32,6 +32,7 @@ import { HEADING_LEFT, HEADING_RIGHT, HEADING_UP, + headingIsVertical, vectorToHeading, } from "./heading"; import type { ElementUpdate } from "./mutateElement"; @@ -118,10 +119,10 @@ export const updateElbowArrowPoints = ( updates?.fixedSegments ?? [], ); - invariant( - nextFixedSegments.findIndex((s) => s.index === arrow.points.length - 1) === - -1, - "Last fixed segment is endpoint!", + const arrowStartPoint = pointFrom(arrow.x, arrow.y); + const arrowEndPoint = pointFrom( + arrow.x + updates.points[updates.points.length - 1][0], + arrow.y + updates.points[updates.points.length - 1][1], ); let previousVal: { @@ -138,18 +139,20 @@ export const updateElbowArrowPoints = ( }; const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = nextFixedSegments.map((segment, segmentIdx) => { + const heading = segment.heading; let anchor = segment.anchor; if (arrow.startBinding && arrow.endBinding) { - const start = pointFrom(arrow.x, arrow.y); - const end = pointFrom( - arrow.x + updates.points[updates.points.length - 1][0], - arrow.y + updates.points[updates.points.length - 1][1], - ); - anchor = - compareHeading(segment.heading, HEADING_UP) || - compareHeading(segment.heading, HEADING_DOWN) - ? pointFrom(anchor[0], (start[1] + end[1]) / 2) - : pointFrom((start[0] + end[0]) / 2, anchor[1]); + if (headingIsVertical(heading)) { + anchor = pointFrom( + anchor[0], + (arrowStartPoint[1] + arrowEndPoint[1]) / 2, + ); + } else { + anchor = pointFrom( + (arrowStartPoint[0] + arrowEndPoint[0]) / 2, + anchor[1], + ); + } } const el = { ...newElement({ @@ -164,13 +167,13 @@ export const updateElbowArrowPoints = ( fakeElementsMap.set(el.id, el); const endFixedPoint: [number, number] = compareHeading( - segment.heading, + heading, HEADING_DOWN, ) ? [0.5, 1] - : compareHeading(segment.heading, HEADING_LEFT) + : compareHeading(heading, HEADING_LEFT) ? [0, 0.5] - : compareHeading(segment.heading, HEADING_UP) + : compareHeading(heading, HEADING_UP) ? [0.5, 0] : [1, 0.5]; const endGlobalPoint = getGlobalFixedPointForBindableElement( @@ -200,13 +203,13 @@ export const updateElbowArrowPoints = ( }, }; const nextStartFixedPoint: [number, number] = compareHeading( - segment.heading, + heading, HEADING_DOWN, ) ? [0.5, 0] - : compareHeading(segment.heading, HEADING_LEFT) + : compareHeading(heading, HEADING_LEFT) ? [1, 0.5] - : compareHeading(segment.heading, HEADING_UP) + : compareHeading(heading, HEADING_UP) ? [0.5, 1] : [0, 0.5]; const endLocalPoint = pointFrom( @@ -267,9 +270,17 @@ export const updateElbowArrowPoints = ( point[0], (point[1] + previous[1]) / 2, ); + res = { anchor, - heading: anchor[1] > previous[1] ? HEADING_UP : HEADING_DOWN, + heading: + anchor[1] > previous[1] + ? arrowStartPoint[1] > arrowEndPoint[1] + ? HEADING_DOWN + : HEADING_UP + : arrowEndPoint[1] > arrowStartPoint[1] + ? HEADING_UP + : HEADING_DOWN, index: nfsBase + prevPoints.length, }; } else { @@ -277,9 +288,17 @@ export const updateElbowArrowPoints = ( (point[0] + previous[0]) / 2, point[1], ); + res = { anchor, - heading: anchor[0] > previous[0] ? HEADING_LEFT : HEADING_RIGHT, + heading: + anchor[0] > previous[0] + ? arrowStartPoint[0] > arrowEndPoint[0] + ? HEADING_RIGHT + : HEADING_LEFT + : arrowEndPoint[0] > arrowStartPoint[0] + ? HEADING_LEFT + : HEADING_RIGHT, index: nfsBase + prevPoints.length, }; } From cc214aa986bbe770b7e4b4b3a948995bbc6e8530 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 17 Oct 2024 15:50:09 +0200 Subject: [PATCH 036/283] Fix lint --- packages/excalidraw/element/elbowarrow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 91eff578250b..a0fbe9b92d40 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -12,7 +12,7 @@ import { import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; -import { invariant, isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; +import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; import { bindPointToSnapToElementOutline, distanceToBindableElement, From 7d07770d03df49a38916a64dd8d180aaa5cb37f4 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sun, 20 Oct 2024 17:39:10 +0200 Subject: [PATCH 037/283] Do not allow moving the first or last Signed-off-by: Mark Tolmacs --- .../excalidraw/element/linearElementEditor.ts | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index f54e935a3247..0b6875225943 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1245,6 +1245,28 @@ export class LinearElementEditor { }, ) { const { points } = element; + let targets = Array.from(targetPoints); + const indices = targets.map((p) => p.index).sort(); + + if (isElbowArrow(element)) { + // Do not allow modifying the first segment + if (indices[0] === 0 && indices[1] === 1) { + targets = targets.slice(2); + } + // Do not allow modifying the last segment + if ( + indices[indices.length - 1] !== undefined && + indices[indices.length - 1] === element.points.length - 1 && + indices[indices.length - 2] !== undefined && + indices[indices.length - 2] === element.points.length - 2 + ) { + targets = targets.slice(0, -2); + } + // If no point remains to modify, return + if (targets.length < 1) { + return; + } + } // 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 @@ -1254,7 +1276,7 @@ export class LinearElementEditor { let offsetX = 0; let offsetY = 0; - const selectedOriginPoint = targetPoints.find(({ index }) => index === 0); + const selectedOriginPoint = targets.find(({ index }) => index === 0); if (selectedOriginPoint) { offsetX = @@ -1264,7 +1286,7 @@ export class LinearElementEditor { } const nextPoints: LocalPoint[] = points.map((p, idx) => { - const selectedPointData = targetPoints.find((t) => t.index === idx); + const selectedPointData = targets.find((t) => t.index === idx); if (selectedPointData) { if (selectedPointData.index === 0) { return p; @@ -1283,9 +1305,7 @@ export class LinearElementEditor { if (isElbowArrow(element)) { otherUpdates = { ...otherUpdates, - fixedSegments: targetPoints - .map((target) => target.index) - .sort() + fixedSegments: indices // The segment id being fixed is always the last point index of the // arrow segment, so it's always > 0. Also segments should always // be 2 points. @@ -1337,7 +1357,7 @@ export class LinearElementEditor { offsetY, otherUpdates, { - isDragging: targetPoints.reduce( + isDragging: targets.reduce( (dragging, targetPoint): boolean => dragging || targetPoint.isDragging === true, false, From 4c627267fe5dbc14427741b3418985a335f821f5 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 21 Oct 2024 07:47:03 +0200 Subject: [PATCH 038/283] start end segment distinction Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 40 +++++++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index a0fbe9b92d40..4295f6378439 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -13,6 +13,7 @@ import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; +import { debugDrawBounds, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -315,7 +316,8 @@ export const updateElbowArrowPoints = ( state, fakeElementsMap, points, - idx, + idx === 0, + idx === pointPairs.length - 1, idx === pointPairs.length - 1 || idx === 0 ? options : { @@ -353,7 +355,8 @@ const routeElbowArrow = ( }, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, nextPoints: readonly LocalPoint[], - idx: number, + isStartSegment: boolean, + isEndSegment: boolean, options?: { isDragging?: boolean; disableBinding?: boolean; @@ -470,10 +473,30 @@ const routeElbowArrow = ( : [startElementBounds, endElementBounds], ); const dynamicAABBs = generateDynamicAABBs( - boundsOverlap ? startPointBounds : startElementBounds, - boundsOverlap ? endPointBounds : endElementBounds, + isEndSegment + ? ([ + hoveredStartElement!.x + hoveredStartElement!.width / 2 - 1, + hoveredStartElement!.y + hoveredStartElement!.height / 2 - 1, + hoveredStartElement!.x + hoveredStartElement!.width / 2 + 1, + hoveredStartElement!.y + hoveredStartElement!.height / 2 + 1, + ] as Bounds) + : boundsOverlap + ? startPointBounds + : startElementBounds, + isStartSegment + ? ([ + hoveredEndElement!.x + hoveredEndElement!.width / 2 - 1, + hoveredEndElement!.y + hoveredEndElement!.height / 2 - 1, + hoveredEndElement!.x + hoveredEndElement!.width / 2 + 1, + hoveredEndElement!.y + hoveredEndElement!.height / 2 + 1, + ] as Bounds) + : boundsOverlap + ? endPointBounds + : endElementBounds, commonBounds, - boundsOverlap + isEndSegment + ? [0, 0, 0, 0] + : boundsOverlap ? offsetFromHeading( startHeading, !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, @@ -489,7 +512,9 @@ const routeElbowArrow = ( : FIXED_BINDING_DISTANCE * 2), BASE_PADDING, ), - boundsOverlap + isStartSegment + ? [0, 0, 0, 0] + : boundsOverlap ? offsetFromHeading( endHeading, !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, @@ -509,6 +534,7 @@ const routeElbowArrow = ( hoveredStartElement && aabbForElement(hoveredStartElement), hoveredEndElement && aabbForElement(hoveredEndElement), ); + dynamicAABBs.forEach((b) => debugDrawBounds(b)); const startDonglePosition = getDonglePosition( dynamicAABBs[0], startHeading, @@ -568,7 +594,7 @@ const routeElbowArrow = ( startDongle && points.unshift(startGlobalPoint); endDongle && points.push(endGlobalPoint); - return simplifyElbowArrowPoints(points); + return points; } return null; From 205a426a2c92837dd3825e69e0b016189ab2d82c Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 21 Oct 2024 10:45:55 +0200 Subject: [PATCH 039/283] Fix back and forth in close fixed segments --- packages/excalidraw/element/elbowarrow.ts | 59 ++++++++++++----------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 4295f6378439..6f74a6a4d146 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -311,20 +311,19 @@ export const updateElbowArrowPoints = ( const unified = pointPairs .map(([state, points], idx) => { - const raw = simplifyElbowArrowPoints( + const raw = routeElbowArrow( state, fakeElementsMap, points, - idx === 0, - idx === pointPairs.length - 1, + nextFixedSegments.length > 0 && idx === 0, + nextFixedSegments.length > 0 && idx === pointPairs.length - 1, idx === pointPairs.length - 1 || idx === 0 ? options : { disableBinding: true, }, - ) ?? [], - ); + ) ?? []; return raw; }) @@ -332,7 +331,7 @@ export const updateElbowArrowPoints = ( return s; }); - return normalizedArrowElementUpdate(simplifyElbowArrowPoints(unified), nfs); + return normalizeArrowElementUpdate(getElbowArrowCornerPoints(unified), nfs); }; /** @@ -534,7 +533,6 @@ const routeElbowArrow = ( hoveredStartElement && aabbForElement(hoveredStartElement), hoveredEndElement && aabbForElement(hoveredEndElement), ); - dynamicAABBs.forEach((b) => debugDrawBounds(b)); const startDonglePosition = getDonglePosition( dynamicAABBs[0], startHeading, @@ -1195,7 +1193,7 @@ const getBindableElementForId = ( return null; }; -const normalizedArrowElementUpdate = ( +const normalizeArrowElementUpdate = ( global: GlobalPoint[], nextFixedSegments: FixedSegment[] | null, ): { @@ -1225,25 +1223,32 @@ const normalizedArrowElementUpdate = ( }; }; -/// If last and current segments have the same heading, skip the middle point -const simplifyElbowArrowPoints = (points: GlobalPoint[]): GlobalPoint[] => - points - .slice(2) - .reduce( - (result, p) => - compareHeading( - vectorToHeading( - vectorFromPoint( - result[result.length - 1], - result[result.length - 2], - ), - ), - vectorToHeading(vectorFromPoint(p, result[result.length - 1])), - ) - ? [...result.slice(0, -1), p] - : [...result, p], - [points[0] ?? [0, 0], points[1] ?? [1, 0]], - ); +const getElbowArrowCornerPoints = (points: GlobalPoint[]): GlobalPoint[] => { + if (points.length > 1) { + let previousHorizontal = points[0][1] === points[1][1]; + 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 = p[1] === next[1]; + let result = true; + if ( + (previousHorizontal && nextHorizontal) || + (!previousHorizontal && !nextHorizontal) + ) { + result = false; + } + + previousHorizontal = nextHorizontal; + + return result; + }); + } + + return points; +}; const neighborIndexToHeading = (idx: number): Heading => { switch (idx) { From 67f99eafeec281f2557078a2c0baea5b3d2be912 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 21 Oct 2024 19:34:30 +0200 Subject: [PATCH 040/283] Reorder point - segment counting --- packages/excalidraw/element/elbowarrow.ts | 46 +++++++++++------------ 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 6f74a6a4d146..c83fcf5e8362 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -13,7 +13,6 @@ import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; -import { debugDrawBounds, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -257,6 +256,24 @@ export const updateElbowArrowPoints = ( ], ]); + const points = pointPairs.map(([state, points], idx) => { + const raw = + routeElbowArrow( + state, + fakeElementsMap, + points, + nextFixedSegments.length > 0 && idx === 0, + nextFixedSegments.length > 0 && idx === pointPairs.length - 1, + idx === pointPairs.length - 1 || idx === 0 + ? options + : { + disableBinding: true, + }, + ) ?? []; + + return raw; + }); + let nfsBase = 0; const nfs = pointPairs.slice(1).map(([{ x, y }, points], idx) => { const point = pointFrom(x + points[0][0], y + points[0][1]); @@ -309,29 +326,10 @@ export const updateElbowArrowPoints = ( return res; }); - const unified = pointPairs - .map(([state, points], idx) => { - const raw = - routeElbowArrow( - state, - fakeElementsMap, - points, - nextFixedSegments.length > 0 && idx === 0, - nextFixedSegments.length > 0 && idx === pointPairs.length - 1, - idx === pointPairs.length - 1 || idx === 0 - ? options - : { - disableBinding: true, - }, - ) ?? []; - - return raw; - }) - .flatMap((s) => { - return s; - }); - - return normalizeArrowElementUpdate(getElbowArrowCornerPoints(unified), nfs); + return normalizeArrowElementUpdate( + getElbowArrowCornerPoints(points.flat()), + nfs, + ); }; /** From f504dd39a353fa73709d50cb31bbaa1de0b8076c Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 21 Oct 2024 20:50:32 +0200 Subject: [PATCH 041/283] Fix naming Signed-off-by: Mark Tolmacs --- packages/excalidraw/components/App.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 5a2ed10b1b06..56a50cd16f31 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7730,15 +7730,15 @@ class App extends React.Component { segmentIdx!, elementsMap, ); - const isHorizontal = startPoint[0] === endPoint[0]; + const isHorizontal = startPoint[1] === endPoint[1]; LinearElementEditor.movePoints(arrow, [ { index: segmentIdx! - 1, point: LinearElementEditor.pointFromAbsoluteCoords( arrow, pointFrom( - isHorizontal ? pointerCoords.x : startPoint[0], - !isHorizontal ? pointerCoords.y : startPoint[1], + !isHorizontal ? pointerCoords.x : startPoint[0], + isHorizontal ? pointerCoords.y : startPoint[1], ), elementsMap, ), @@ -7749,8 +7749,8 @@ class App extends React.Component { point: LinearElementEditor.pointFromAbsoluteCoords( arrow, pointFrom( - isHorizontal ? pointerCoords.x : endPoint[0], - !isHorizontal ? pointerCoords.y : endPoint[1], + !isHorizontal ? pointerCoords.x : endPoint[0], + isHorizontal ? pointerCoords.y : endPoint[1], ), elementsMap, ), From c054f1f046916162e52f305183742d284e9cafcf Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 23 Oct 2024 22:25:17 +0200 Subject: [PATCH 042/283] Antoher approach to track dragged segment index --- packages/excalidraw/components/App.tsx | 115 +++++++----------- packages/excalidraw/element/elbowarrow.ts | 7 +- .../excalidraw/element/linearElementEditor.ts | 102 ++++++++++++++-- packages/excalidraw/element/types.ts | 4 +- 4 files changed, 148 insertions(+), 80 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 56a50cd16f31..a7fb03ae01b4 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -186,7 +186,6 @@ import type { ExcalidrawNonSelectionElement, ExcalidrawArrowElement, NonDeletedSceneElementsMap, - ExcalidrawElbowArrowElement, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -7656,8 +7655,6 @@ class App extends React.Component { const linearElementEditor = this.state.editingLinearElement || this.state.selectedLinearElement; - let didDrag = false; - if ( LinearElementEditor.shouldAddMidpoint( this.state.selectedLinearElement, @@ -7704,60 +7701,41 @@ class App extends React.Component { return; } else if ( - isElbowArrow( - LinearElementEditor.getElement( - this.state.selectedLinearElement.elementId, - elementsMap, - )!, - ) && - this.state.selectedLinearElement.pointerDownState.segmentMidpoint - .index + linearElementEditor.elbowed && + linearElementEditor.pointerDownState.segmentMidpoint.index ) { - const arrow = LinearElementEditor.getElement( - this.state.selectedLinearElement.elementId, - elementsMap, - ) as ExcalidrawElbowArrowElement; - const { index: segmentIdx } = - this.state.selectedLinearElement.pointerDownState.segmentMidpoint; - const startPoint = - LinearElementEditor.getPointAtIndexGlobalCoordinates( - arrow, - segmentIdx! - 1, - elementsMap, - ); - const endPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - arrow, - segmentIdx!, + const ret = LinearElementEditor.moveElbowArrowSegment( + this.state.selectedLinearElement, + pointerCoords, elementsMap, ); - const isHorizontal = startPoint[1] === endPoint[1]; - LinearElementEditor.movePoints(arrow, [ - { - index: segmentIdx! - 1, - point: LinearElementEditor.pointFromAbsoluteCoords( - arrow, - pointFrom( - !isHorizontal ? pointerCoords.x : startPoint[0], - isHorizontal ? pointerCoords.y : startPoint[1], - ), - elementsMap, - ), - isDragging: true, - }, - { - index: segmentIdx!, - point: LinearElementEditor.pointFromAbsoluteCoords( - arrow, - pointFrom( - !isHorizontal ? pointerCoords.x : endPoint[0], - isHorizontal ? pointerCoords.y : endPoint[1], - ), - elementsMap, - ), - isDragging: true, - }, - ]); - didDrag = true; + + // Since we are reading from previous state which is not possible with + // automatic batching in React 18 hence using flush sync to synchronously + // update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details. + + flushSync(() => { + if (this.state.selectedLinearElement) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + pointerDownState: ret.pointerDownState, + selectedPointsIndices: ret.selectedPointsIndices, + }, + }); + } + if (this.state.editingLinearElement) { + this.setState({ + editingLinearElement: { + ...this.state.editingLinearElement, + pointerDownState: ret.pointerDownState, + selectedPointsIndices: ret.selectedPointsIndices, + }, + }); + } + }); + + return; } else if ( linearElementEditor.pointerDownState.segmentMidpoint.value !== null && !linearElementEditor.pointerDownState.segmentMidpoint.added @@ -7765,21 +7743,20 @@ class App extends React.Component { return; } - didDrag = - LinearElementEditor.handlePointDragging( - event, - this, - pointerCoords.x, - pointerCoords.y, - (element, pointsSceneCoords) => { - this.maybeSuggestBindingsForLinearElementAtCoords( - element, - pointsSceneCoords, - ); - }, - linearElementEditor, - this.scene, - ) || didDrag; + const didDrag = LinearElementEditor.handlePointDragging( + event, + this, + pointerCoords.x, + pointerCoords.y, + (element, pointsSceneCoords) => { + this.maybeSuggestBindingsForLinearElementAtCoords( + element, + pointsSceneCoords, + ); + }, + linearElementEditor, + this.scene, + ); if (didDrag) { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index c83fcf5e8362..6562a46afe15 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -51,6 +51,7 @@ import type { FixedSegment, FractionalIndex, Ordered, + Sequential, } from "./types"; type GridAddress = [number, number] & { _brand: "gridaddress" }; @@ -328,7 +329,7 @@ export const updateElbowArrowPoints = ( return normalizeArrowElementUpdate( getElbowArrowCornerPoints(points.flat()), - nfs, + nfs as Sequential, ); }; @@ -1193,14 +1194,14 @@ const getBindableElementForId = ( const normalizeArrowElementUpdate = ( global: GlobalPoint[], - nextFixedSegments: FixedSegment[] | null, + nextFixedSegments: Sequential | null, ): { points: LocalPoint[]; x: number; y: number; width: number; height: number; - fixedSegments: FixedSegment[] | null; + fixedSegments: Sequential | null; } => { const offsetX = global[0][0]; const offsetY = global[0][1]; diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 0b6875225943..909e468f4fab 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -11,6 +11,8 @@ import type { FixedPointBinding, SceneElementsMap, FixedSegment, + ExcalidrawElbowArrowElement, + Sequential, } from "./types"; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; import type { Bounds } from "./bounds"; @@ -149,13 +151,13 @@ export class LinearElementEditor { * @param id the `elementId` from the instance of this class (so that we can * statically guarantee this method returns an ExcalidrawLinearElement) */ - static getElement( + static getElement( id: InstanceType["elementId"], elementsMap: ElementsMap, - ) { + ): T | null { const element = elementsMap.get(id); if (element) { - return element as NonDeleted; + return element as NonDeleted; } return null; } @@ -1238,7 +1240,7 @@ export class LinearElementEditor { otherUpdates?: { startBinding?: PointBinding | null; endBinding?: PointBinding | null; - fixedSegments?: FixedSegment[] | null; + fixedSegments?: Sequential | null; }, options?: { changedElements?: Map; @@ -1346,7 +1348,7 @@ export class LinearElementEditor { } return res; - }), + }) as Sequential, }; } @@ -1472,7 +1474,7 @@ export class LinearElementEditor { otherUpdates?: { startBinding?: PointBinding | null; endBinding?: PointBinding | null; - fixedSegments?: FixedSegment[] | null; + fixedSegments?: Sequential | null; }, options?: { changedElements?: Map; @@ -1483,7 +1485,7 @@ export class LinearElementEditor { const updates: { startBinding?: FixedPointBinding | null; endBinding?: FixedPointBinding | null; - fixedSegments?: FixedSegment[] | null; + fixedSegments?: Sequential | null; points?: LocalPoint[]; } = {}; if (otherUpdates?.startBinding !== undefined) { @@ -1796,6 +1798,92 @@ export class LinearElementEditor { return coords; }; + + /** + * + */ + static moveElbowArrowSegment( + linearElementEditor: LinearElementEditor, + pointerCoords: { x: number; y: number }, + elementsMap: ElementsMap, + ): LinearElementEditor { + if (!linearElementEditor.elbowed) { + return linearElementEditor; + } + + const { index: segmentIdx } = + linearElementEditor.pointerDownState.segmentMidpoint; + const element = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ); + + if (!element || !segmentIdx) { + return linearElementEditor; + } + + // Calculate the expected / existing position of the + // segment in the `fixedSegments` array on the arrow + let currFixedSegmentsArrayIdx = + element.fixedSegments?.findIndex( + (segment) => segment.index === segmentIdx, + ) ?? -1; + if (currFixedSegmentsArrayIdx < 0) { + // Segment not yet fixed - we expect it to fall into this place in the array: + currFixedSegmentsArrayIdx = + element.fixedSegments?.filter((segment) => segment.index < segmentIdx) + ?.length ?? 0; + } + + const startPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + segmentIdx - 1, + elementsMap, + ); + const endPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + segmentIdx, + elementsMap, + ); + const isHorizontal = startPoint[1] === endPoint[1]; + LinearElementEditor.movePoints(element, [ + { + index: segmentIdx! - 1, + point: LinearElementEditor.pointFromAbsoluteCoords( + element, + pointFrom( + !isHorizontal ? pointerCoords.x : startPoint[0], + isHorizontal ? pointerCoords.y : startPoint[1], + ), + elementsMap, + ), + isDragging: true, + }, + { + index: segmentIdx!, + point: LinearElementEditor.pointFromAbsoluteCoords( + element, + pointFrom( + !isHorizontal ? pointerCoords.x : endPoint[0], + isHorizontal ? pointerCoords.y : endPoint[1], + ), + elementsMap, + ), + isDragging: true, + }, + ]); + + return { + ...linearElementEditor, + pointerDownState: { + ...linearElementEditor.pointerDownState, + segmentMidpoint: { + ...linearElementEditor.pointerDownState.segmentMidpoint, + index: element.fixedSegments![currFixedSegmentsArrayIdx].index, // Update index for the next frame + }, + }, + }; + } } const normalizeSelectedPoints = ( diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index d4ad15058172..1d5b3d38cb54 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -325,10 +325,12 @@ export type ExcalidrawElbowArrowElement = Merge< elbowed: true; startBinding: FixedPointBinding | null; endBinding: FixedPointBinding | null; - fixedSegments: FixedSegment[] | null; + fixedSegments: Sequential | null; } >; +export type Sequential = T[] & { __brand: "__sequential" }; + export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & Readonly<{ type: "freedraw"; From 6ca24dcdd5d2d7d877fc037d78b4b91656bf985b Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 24 Oct 2024 11:50:41 +0200 Subject: [PATCH 043/283] New segment calc --- packages/excalidraw/element/elbowarrow.ts | 108 ++++++++++------------ packages/excalidraw/utils.ts | 38 ++++++++ 2 files changed, 89 insertions(+), 57 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 6562a46afe15..3a0a3c7e77cc 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -2,6 +2,7 @@ import { pointFrom, pointScaleFromOrigin, pointTranslate, + PRECISION, vector, vectorCross, vectorFromPoint, @@ -12,7 +13,13 @@ import { import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; -import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; +import { + isAnyTrue, + multiDimensionalArrayDeepFilter, + multiDimensionalArrayDeepFlatMapper, + toBrandedType, + tupleToCoors, +} from "../utils"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -138,6 +145,7 @@ export const updateElbowArrowPoints = ( fixedPoint: arrow.startBinding?.fixedPoint ?? null, elementId: arrow.startBinding?.elementId ?? null, }; + const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = nextFixedSegments.map((segment, segmentIdx) => { const heading = segment.heading; @@ -257,7 +265,7 @@ export const updateElbowArrowPoints = ( ], ]); - const points = pointPairs.map(([state, points], idx) => { + const rawPointGroups = pointPairs.map(([state, points], idx) => { const raw = routeElbowArrow( state, @@ -275,62 +283,45 @@ export const updateElbowArrowPoints = ( return raw; }); - let nfsBase = 0; - const nfs = pointPairs.slice(1).map(([{ x, y }, points], idx) => { - const point = pointFrom(x + points[0][0], y + points[0][1]); - const [prevState, prevPoints] = pointPairs[idx]; - const previous = pointFrom( - prevState.x + prevPoints[prevPoints.length - 1][0], - prevState.y + prevPoints[prevPoints.length - 1][1], - ); - let res; - if (Math.abs(point[0] - previous[0]) < 0.05) { - const anchor = pointFrom( - point[0], - (point[1] + previous[1]) / 2, - ); + const simplifiedPointGroups = getElbowArrowCornerPoints(rawPointGroups); - res = { - anchor, - heading: - anchor[1] > previous[1] - ? arrowStartPoint[1] > arrowEndPoint[1] - ? HEADING_DOWN - : HEADING_UP - : arrowEndPoint[1] > arrowStartPoint[1] - ? HEADING_UP - : HEADING_DOWN, - index: nfsBase + prevPoints.length, - }; - } else { + let currentGroupIdx = 0; + const nfs = multiDimensionalArrayDeepFlatMapper< + GlobalPoint, + FixedSegment | null + >(simplifiedPointGroups, (point, [groupIdx], points, index) => { + if (currentGroupIdx < groupIdx) { + // Watch for the case when point group idx changes, + // that's where we need to generate a fixed segment + // i.e. we are the first point of the group excluding the first group + currentGroupIdx = groupIdx; + + const prevGroupLastPoint = points[index - 1]; const anchor = pointFrom( - (point[0] + previous[0]) / 2, - point[1], + (prevGroupLastPoint[0] + point[0]) / 2, + (prevGroupLastPoint[1] + point[1]) / 2, ); - - res = { + const segmentHorizontal = + Math.abs(prevGroupLastPoint[1] - point[1]) < PRECISION; + return { anchor, - heading: - anchor[0] > previous[0] - ? arrowStartPoint[0] > arrowEndPoint[0] - ? HEADING_RIGHT - : HEADING_LEFT - : arrowEndPoint[0] > arrowStartPoint[0] + index, + heading: segmentHorizontal + ? anchor[0] > prevGroupLastPoint[0] ? HEADING_LEFT - : HEADING_RIGHT, - index: nfsBase + prevPoints.length, + : HEADING_RIGHT + : anchor[1] > prevGroupLastPoint[1] + ? HEADING_UP + : HEADING_DOWN, }; } - nfsBase += prevPoints.length - 1; - - return res; - }); + return null; + }).filter( + (segment): segment is FixedSegment => segment != null, + ) as Sequential; - return normalizeArrowElementUpdate( - getElbowArrowCornerPoints(points.flat()), - nfs as Sequential, - ); + return normalizeArrowElementUpdate(simplifiedPointGroups.flat(), nfs); }; /** @@ -1222,31 +1213,34 @@ const normalizeArrowElementUpdate = ( }; }; -const getElbowArrowCornerPoints = (points: GlobalPoint[]): GlobalPoint[] => { +const getElbowArrowCornerPoints = ( + pointGroups: GlobalPoint[][], +): GlobalPoint[][] => { + const points = pointGroups.flat(); + let previousHorizontal = points[0][1] === points[1][1]; if (points.length > 1) { - let previousHorizontal = points[0][1] === points[1][1]; - return points.filter((p, idx) => { + return multiDimensionalArrayDeepFilter(pointGroups, (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 = p[1] === next[1]; - let result = true; if ( (previousHorizontal && nextHorizontal) || (!previousHorizontal && !nextHorizontal) ) { - result = false; + previousHorizontal = nextHorizontal; + return false; } previousHorizontal = nextHorizontal; - - return result; + return true; }); } - return points; + return pointGroups; }; const neighborIndexToHeading = (idx: number): Heading => { diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 8176b1a7478e..1b32b1416739 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -1225,3 +1225,41 @@ export class PromisePool { }); } } + +export const multiDimensionalArrayDeepFilter = ( + matrix: T[][], + f: (item: T, index: number) => boolean, +) => { + let pointer = 0; + return matrix + .map((group) => group.filter((item) => f(item, pointer++))) + .filter((group) => group.length > 0); +}; + +export const multiDimensionalArrayDeepMapper = ( + matrix: T[][], + f: ( + item: T, + index: [rowIdx: number, colIdx: number], + arr: T[], + idx: number, + ) => U, +) => { + let pointer = 0; + const flatArray = matrix.flat(); + return matrix + .map((group, groupIdx) => + group.map((item, idx) => f(item, [groupIdx, idx], flatArray, pointer++)), + ) + .filter((group) => group.length > 0); +}; + +export const multiDimensionalArrayDeepFlatMapper = ( + matrix: T[][], + f: ( + item: T, + index: [rowIdx: number, colIdx: number], + arr: T[], + idx: number, + ) => U, +) => multiDimensionalArrayDeepMapper(matrix, f).flat(); From 1f27d198a70ea90e68eb7421f5e383bd68703367 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 24 Oct 2024 14:10:51 +0200 Subject: [PATCH 044/283] Fix looping fixed segment --- packages/excalidraw/components/App.tsx | 4 +++ packages/excalidraw/element/elbowarrow.ts | 8 +++++ .../excalidraw/element/linearElementEditor.ts | 31 ++++++++++++++++--- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 13853e3dc08d..a1a421db7ccb 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7792,6 +7792,10 @@ class App extends React.Component { pointerCoords, elementsMap, ); + // console.log( + // this.state.selectedLinearElement.pointerDownState.segmentMidpoint + // .index, + // ); // Since we are reading from previous state which is not possible with // automatic batching in React 18 hence using flush sync to synchronously diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 3a0a3c7e77cc..a22537fb4609 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -285,6 +285,13 @@ export const updateElbowArrowPoints = ( const simplifiedPointGroups = getElbowArrowCornerPoints(rawPointGroups); + // simplifiedPointGroups.forEach((group, idx) => + // group.forEach((p) => + // debugDrawPoint(p, { color: idx > 0 ? "green" : "red", permanent: true }), + // ), + // ); + // debugCloseFrame(); + let currentGroupIdx = 0; const nfs = multiDimensionalArrayDeepFlatMapper< GlobalPoint, @@ -303,6 +310,7 @@ export const updateElbowArrowPoints = ( ); const segmentHorizontal = Math.abs(prevGroupLastPoint[1] - point[1]) < PRECISION; + return { anchor, index, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 909e468f4fab..0ce68f7338f2 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -59,6 +59,7 @@ import { type LocalPoint, pointDistance, pointTranslate, + PRECISION, } from "../../math"; import { getBezierCurveLength, @@ -1319,7 +1320,9 @@ export class LinearElementEditor { // might simplify the arrow and remove/merge segments. .map((idx) => { let res; - if (Math.abs(nextPoints[idx][0] - nextPoints[idx - 1][0]) < 0.05) { + if ( + Math.abs(nextPoints[idx][0] - nextPoints[idx - 1][0]) < PRECISION + ) { const anchor = pointFrom( element.x + nextPoints[idx][0], element.y + (nextPoints[idx][1] - nextPoints[idx - 1][1]) / 2, @@ -1327,7 +1330,8 @@ export class LinearElementEditor { res = { anchor, heading: - anchor[1] > element.y + nextPoints[idx - 1][1] + element.y + nextPoints[idx][1] > + element.y + nextPoints[idx - 1][1] ? HEADING_UP : HEADING_DOWN, index: idx, @@ -1340,7 +1344,8 @@ export class LinearElementEditor { res = { anchor, heading: - anchor[0] > element.x + nextPoints[idx - 1][0] + element.x + nextPoints[idx][0] > + element.x + nextPoints[idx - 1][0] ? HEADING_LEFT : HEADING_RIGHT, index: idx, @@ -1846,6 +1851,7 @@ export class LinearElementEditor { elementsMap, ); const isHorizontal = startPoint[1] === endPoint[1]; + LinearElementEditor.movePoints(element, [ { index: segmentIdx! - 1, @@ -1873,13 +1879,30 @@ export class LinearElementEditor { }, ]); + const newIndex = element.fixedSegments![currFixedSegmentsArrayIdx].index; + // debugDrawPoint( + // pointFrom( + // element.x + element.points[newIndex][0], + // element.y + element.points[newIndex][1], + // ), + // { color: "red", permanent: true }, + // ); + + // debugDrawPoint( + // pointFrom( + // element.x + element.points[newIndex - 1][0], + // element.y + element.points[newIndex - 1][1], + // ), + // { color: "green", permanent: true }, + // ); + return { ...linearElementEditor, pointerDownState: { ...linearElementEditor.pointerDownState, segmentMidpoint: { ...linearElementEditor.pointerDownState.segmentMidpoint, - index: element.fixedSegments![currFixedSegmentsArrayIdx].index, // Update index for the next frame + index: newIndex, // Update index for the next frame }, }, }; From 8f4fc9b822514ba9e2c09d118b535160800b1f1c Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 24 Oct 2024 15:46:24 +0200 Subject: [PATCH 045/283] Fix cursor and start / end segment no move --- packages/excalidraw/components/App.tsx | 27 ++++++++++++++++--- .../excalidraw/element/linearElementEditor.ts | 22 +++++---------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index a1a421db7ccb..dcdd85a827b0 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -5868,7 +5868,7 @@ class App extends React.Component { this.setState({ activeEmbeddable: { element: hitElement, state: "hover" }, }); - } else { + } else if (!hitElement || !isElbowArrow(hitElement)) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); if (this.state.activeEmbeddable?.state === "hover") { this.setState({ activeEmbeddable: null }); @@ -6001,6 +6001,7 @@ class App extends React.Component { return; } if (this.state.selectedLinearElement) { + const elementIsElbowArrow = isElbowArrow(element); let hoverPointIndex = -1; let segmentMidPointHoveredCoords = null; if ( @@ -6028,15 +6029,33 @@ class App extends React.Component { this.state, this.scene.getNonDeletedElementsMap(), ); + const segmentMidPointIndex = + (segmentMidPointHoveredCoords && + LinearElementEditor.getSegmentMidPointIndex( + linearElementEditor, + this.state, + segmentMidPointHoveredCoords, + elementsMap, + )) ?? + -1; - if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) { + if ( + (!elementIsElbowArrow && + (hoverPointIndex >= 0 || segmentMidPointHoveredCoords)) || + (elementIsElbowArrow && + segmentMidPointIndex > 1 && + segmentMidPointIndex < element.points.length - 1) + ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); - } else if (this.hitElement(scenePointerX, scenePointerY, element)) { + } else if ( + !elementIsElbowArrow && + this.hitElement(scenePointerX, scenePointerY, element) + ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } } else if (this.hitElement(scenePointerX, scenePointerY, element)) { if ( - !isElbowArrow(element) || + !elementIsElbowArrow || !(element.startBinding || element.endBinding) ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 0ce68f7338f2..ced857781af5 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1823,7 +1823,12 @@ export class LinearElementEditor { elementsMap, ); - if (!element || !segmentIdx) { + if ( + !element || + !segmentIdx || + segmentIdx === 1 || + segmentIdx === element.points.length - 1 + ) { return linearElementEditor; } @@ -1880,21 +1885,6 @@ export class LinearElementEditor { ]); const newIndex = element.fixedSegments![currFixedSegmentsArrayIdx].index; - // debugDrawPoint( - // pointFrom( - // element.x + element.points[newIndex][0], - // element.y + element.points[newIndex][1], - // ), - // { color: "red", permanent: true }, - // ); - - // debugDrawPoint( - // pointFrom( - // element.x + element.points[newIndex - 1][0], - // element.y + element.points[newIndex - 1][1], - // ), - // { color: "green", permanent: true }, - // ); return { ...linearElementEditor, From 828e0185eb274962a59f47bffcb1821a3daafb5c Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 24 Oct 2024 16:04:49 +0200 Subject: [PATCH 046/283] Fixed segment flipping on direction change --- packages/excalidraw/element/elbowarrow.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index a22537fb4609..ffa7a002acbc 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -39,6 +39,7 @@ import { HEADING_LEFT, HEADING_RIGHT, HEADING_UP, + headingIsHorizontal, headingIsVertical, vectorToHeading, } from "./heading"; @@ -148,7 +149,7 @@ export const updateElbowArrowPoints = ( const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = nextFixedSegments.map((segment, segmentIdx) => { - const heading = segment.heading; + let heading = segment.heading; let anchor = segment.anchor; if (arrow.startBinding && arrow.endBinding) { if (headingIsVertical(heading)) { @@ -175,6 +176,14 @@ export const updateElbowArrowPoints = ( } as Ordered; fakeElementsMap.set(el.id, el); + heading = headingIsHorizontal(heading) + ? previousVal.point[0] > anchor[0] + ? HEADING_RIGHT + : HEADING_LEFT + : previousVal.point[1] > anchor[1] + ? HEADING_DOWN + : HEADING_UP; + const endFixedPoint: [number, number] = compareHeading( heading, HEADING_DOWN, @@ -285,13 +294,6 @@ export const updateElbowArrowPoints = ( const simplifiedPointGroups = getElbowArrowCornerPoints(rawPointGroups); - // simplifiedPointGroups.forEach((group, idx) => - // group.forEach((p) => - // debugDrawPoint(p, { color: idx > 0 ? "green" : "red", permanent: true }), - // ), - // ); - // debugCloseFrame(); - let currentGroupIdx = 0; const nfs = multiDimensionalArrayDeepFlatMapper< GlobalPoint, @@ -315,10 +317,10 @@ export const updateElbowArrowPoints = ( anchor, index, heading: segmentHorizontal - ? anchor[0] > prevGroupLastPoint[0] + ? point[0] > prevGroupLastPoint[0] ? HEADING_LEFT : HEADING_RIGHT - : anchor[1] > prevGroupLastPoint[1] + : point[1] > prevGroupLastPoint[1] ? HEADING_UP : HEADING_DOWN, }; From 52a4f04a9f43cdc23a81579123aeb82c4841c1b6 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 24 Oct 2024 16:29:15 +0200 Subject: [PATCH 047/283] Naive segment delete --- packages/excalidraw/components/App.tsx | 52 +++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index dcdd85a827b0..6449c749a38b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -187,6 +187,9 @@ import type { ExcalidrawNonSelectionElement, ExcalidrawArrowElement, NonDeletedSceneElementsMap, + Sequential, + FixedSegment, + ExcalidrawElbowArrowElement, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -5208,6 +5211,11 @@ class App extends React.Component { const selectedElements = this.scene.getSelectedElements(this.state); + let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( + event, + this.state, + ); + if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) { if ( event[KEYS.CTRL_OR_CMD] && @@ -5220,6 +5228,44 @@ class App extends React.Component { this.setState({ editingLinearElement: new LinearElementEditor(selectedElements[0]), }); + return; + } else if ( + isElbowArrow(selectedElements[0]) && + this.state.selectedLinearElement + ) { + // Delete fixed segment point + this.store.shouldCaptureIncrement(); + const elementsMap = this.scene.getNonDeletedElementsMap(); + const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords( + this.state.selectedLinearElement, + { x: sceneX, y: sceneY }, + this.state, + elementsMap, + ); + const index = + segmentMidPoint && + LinearElementEditor.getSegmentMidPointIndex( + this.state.selectedLinearElement, + this.state, + segmentMidPoint, + elementsMap, + ); + const fixedSegments = selectedElements[0].fixedSegments?.filter( + (segment) => segment.index !== index, + ) as Sequential | null; + const el = elementsMap.get( + selectedElements[0].id, + )! as ExcalidrawElbowArrowElement; + mutateElement(el, { + fixedSegments, + points: [ + selectedElements[0].points[0], + selectedElements[0].points[ + selectedElements[0].points[0].length - 1 + ], + ], + }); + return; } } @@ -5231,11 +5277,6 @@ class App extends React.Component { resetCursor(this.interactiveCanvas); - let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( - event, - this.state, - ); - const selectedGroupIds = getSelectedGroupIds(this.state); if (selectedGroupIds.length > 0) { @@ -7806,6 +7847,7 @@ class App extends React.Component { linearElementEditor.elbowed && linearElementEditor.pointerDownState.segmentMidpoint.index ) { + this.store.shouldCaptureIncrement(); const ret = LinearElementEditor.moveElbowArrowSegment( this.state.selectedLinearElement, pointerCoords, From 493351135e5fff6477af6909db9ef71c99ae7cbc Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 24 Oct 2024 18:24:53 +0200 Subject: [PATCH 048/283] Fixed point delete Signed-off-by: Mark Tolmacs --- packages/excalidraw/components/App.tsx | 20 +++------ packages/excalidraw/element/elbowarrow.ts | 45 ++++++++++++-------- packages/excalidraw/element/mutateElement.ts | 5 ++- 3 files changed, 37 insertions(+), 33 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 6449c749a38b..ebfe4d69e01f 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -189,7 +189,6 @@ import type { NonDeletedSceneElementsMap, Sequential, FixedSegment, - ExcalidrawElbowArrowElement, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -5250,21 +5249,12 @@ class App extends React.Component { segmentMidPoint, elementsMap, ); - const fixedSegments = selectedElements[0].fixedSegments?.filter( - (segment) => segment.index !== index, + const fixedSegments = selectedElements[0].fixedSegments?.map( + (segment) => + segment.index !== index ? segment : { ...segment, anchor: null }, ) as Sequential | null; - const el = elementsMap.get( - selectedElements[0].id, - )! as ExcalidrawElbowArrowElement; - mutateElement(el, { - fixedSegments, - points: [ - selectedElements[0].points[0], - selectedElements[0].points[ - selectedElements[0].points[0].length - 1 - ], - ], - }); + + mutateElement(selectedElements[0], { fixedSegments }); return; } diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index ffa7a002acbc..ec518600d2bf 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -96,15 +96,24 @@ const segmentListMerge = ( oldFixedSegments: FixedSegment[], newFixedSegments: FixedSegment[], ): FixedSegment[] => { - const oldSegments: [number, FixedSegment][] = oldFixedSegments.map( - (segment) => [segment.index, segment], - ); - const newSegments: [number, FixedSegment][] = newFixedSegments.map( - (segment) => [segment.index, segment], - ); - return Array.from( - new Map([...oldSegments, ...newSegments]).values(), - ).sort((a, b) => a.index - b.index); + let fixedSegments = oldFixedSegments; + newFixedSegments.forEach((segment) => { + if (segment.anchor == null) { + // Delete segment request + fixedSegments = fixedSegments.filter((s) => s.index !== segment.index); + } else { + const idx = fixedSegments.findIndex((s) => s.index === segment.index); + if (idx > -1) { + // Modify segment request + fixedSegments[idx] = segment; + } else { + // Add segment request + fixedSegments.push(segment); + } + } + }); + + return fixedSegments.sort((a, b) => a.index - b.index); }; /** @@ -114,7 +123,7 @@ export const updateElbowArrowPoints = ( arrow: ExcalidrawElbowArrowElement, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, updates: { - points: readonly LocalPoint[]; + points?: readonly LocalPoint[]; fixedSegments?: FixedSegment[]; }, options?: { @@ -122,16 +131,18 @@ export const updateElbowArrowPoints = ( disableBinding?: boolean; }, ): ElementUpdate => { + const updatedPoints = updates.points ?? arrow.points; + const fakeElementsMap = toBrandedType(new Map(elementsMap)); const nextFixedSegments = segmentListMerge( arrow.fixedSegments ?? [], updates?.fixedSegments ?? [], ); - + //console.log(nextFixedSegments); const arrowStartPoint = pointFrom(arrow.x, arrow.y); const arrowEndPoint = pointFrom( - arrow.x + updates.points[updates.points.length - 1][0], - arrow.y + updates.points[updates.points.length - 1][1], + arrow.x + updatedPoints[updatedPoints.length - 1][0], + arrow.y + updatedPoints[updatedPoints.length - 1][1], ); let previousVal: { @@ -140,8 +151,8 @@ export const updateElbowArrowPoints = ( elementId: string | null; } = { point: pointFrom( - arrow.x + updates.points[0][0], - arrow.y + updates.points[0][1], + arrow.x + updatedPoints[0][0], + arrow.y + updatedPoints[0][1], ), fixedPoint: arrow.startBinding?.fixedPoint ?? null, elementId: arrow.startBinding?.elementId ?? null, @@ -265,10 +276,10 @@ export const updateElbowArrowPoints = ( // Translate from unsegmented local array point -> global point -> last segment local point pointFrom( arrow.x + - updates.points[updates.points.length - 1][0] - + updatedPoints[updatedPoints.length - 1][0] - previousVal.point[0], arrow.y + - updates.points[updates.points.length - 1][1] - + updatedPoints[updatedPoints.length - 1][1] - previousVal.point[1], ), ], diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index 395b98035603..6d6326508ec6 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -36,7 +36,10 @@ export const mutateElement = >( // (see https://github.com/microsoft/TypeScript/issues/21732) const { points, fileId } = updates as any; - if (typeof points !== "undefined") { + if ( + typeof points !== "undefined" || + Object.hasOwn(updates, "fixedSegments") + ) { if (isElbowArrow(element)) { const mergedElementsMap = toBrandedType( new Map([ From 48e55d4eadd98df55daf28f8b2c8d379fcea3c00 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 24 Oct 2024 18:57:09 +0200 Subject: [PATCH 049/283] Mark fixed segment midpoint with hollow handle Signed-off-by: Mark Tolmacs --- .../excalidraw/renderer/interactiveScene.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 1a0f6b7ccb78..874e4ec5b684 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -514,12 +514,24 @@ const renderLinearPointHandles = ( !(isElbowArrow(element) && (idx === 0 || idx === midPoints.length - 1)), ); - midPoints.forEach((segmentMidPoint) => { - if ( - isElbowArrow(element) || - appState.editingLinearElement || - points.length === 2 - ) { + midPoints.forEach((segmentMidPoint, segmentIdx) => { + if (isElbowArrow(element)) { + const isFixedSegmentMidPoint = + (element.fixedSegments?.findIndex( + // First segment is always unfixable and plus one to address the + // fixedSegments array = +2 offset + (segment) => segment.index === segmentIdx + 2, + ) ?? -1) === -1; + + renderSingleLinearPoint( + context, + appState, + segmentMidPoint, + POINT_HANDLE_SIZE / 2, + false, + isFixedSegmentMidPoint, + ); + } else if (appState.editingLinearElement || points.length === 2) { renderSingleLinearPoint( context, appState, From 5f81c9d249a6ccb56026b3cf35b6b952e921fd50 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sun, 27 Oct 2024 18:34:31 +0100 Subject: [PATCH 050/283] Fixed, grid snapping --- packages/excalidraw/components/App.tsx | 7 ++++- packages/excalidraw/element/elbowarrow.ts | 6 ++--- .../excalidraw/element/linearElementEditor.ts | 26 ++++++++++++------- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index ebfe4d69e01f..d083318d8af7 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7838,9 +7838,14 @@ class App extends React.Component { linearElementEditor.pointerDownState.segmentMidpoint.index ) { this.store.shouldCaptureIncrement(); + const [gridX, gridY] = getGridPoint( + pointerCoords.x, + pointerCoords.y, + event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), + ); const ret = LinearElementEditor.moveElbowArrowSegment( this.state.selectedLinearElement, - pointerCoords, + { x: gridX, y: gridY }, elementsMap, ); // console.log( diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index ec518600d2bf..9aaa4397ab51 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -93,10 +93,10 @@ type ElbowArrowState = { const BASE_PADDING = 40; const segmentListMerge = ( - oldFixedSegments: FixedSegment[], - newFixedSegments: FixedSegment[], + oldFixedSegments: readonly FixedSegment[], + newFixedSegments: readonly FixedSegment[], ): FixedSegment[] => { - let fixedSegments = oldFixedSegments; + let fixedSegments = Array.from(oldFixedSegments); newFixedSegments.forEach((segment) => { if (segment.anchor == null) { // Delete segment request diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index ced857781af5..e7fc40805749 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1323,12 +1323,15 @@ export class LinearElementEditor { if ( Math.abs(nextPoints[idx][0] - nextPoints[idx - 1][0]) < PRECISION ) { - const anchor = pointFrom( - element.x + nextPoints[idx][0], - element.y + (nextPoints[idx][1] - nextPoints[idx - 1][1]) / 2, - ); res = { - anchor, + anchor: pointFrom( + element.x + nextPoints[idx][0], + (element.y + + nextPoints[idx][1] + + element.y + + nextPoints[idx - 1][1]) / + 2, + ), heading: element.y + nextPoints[idx][1] > element.y + nextPoints[idx - 1][1] @@ -1337,12 +1340,15 @@ export class LinearElementEditor { index: idx, }; } else { - const anchor = pointFrom( - element.x + (nextPoints[idx][0] - nextPoints[idx - 1][0]) / 2, - element.y + nextPoints[idx][1], - ); res = { - anchor, + anchor: pointFrom( + (element.x + + nextPoints[idx][0] + + element.x + + nextPoints[idx - 1][0]) / + 2, + element.y + nextPoints[idx][1], + ), heading: element.x + nextPoints[idx][0] > element.x + nextPoints[idx - 1][0] From 85f900b960d953633f2a33bdbe639bde629bc038 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sun, 27 Oct 2024 19:31:39 +0100 Subject: [PATCH 051/283] Multi fixed element w/ rendering error --- packages/excalidraw/components/App.tsx | 48 ++++++++--------- packages/excalidraw/element/elbowarrow.ts | 53 ++++++++++--------- .../excalidraw/element/linearElementEditor.ts | 7 +++ 3 files changed, 60 insertions(+), 48 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d083318d8af7..8dad976779bc 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7847,36 +7847,36 @@ class App extends React.Component { this.state.selectedLinearElement, { x: gridX, y: gridY }, elementsMap, + this.state, ); - // console.log( - // this.state.selectedLinearElement.pointerDownState.segmentMidpoint - // .index, - // ); // Since we are reading from previous state which is not possible with // automatic batching in React 18 hence using flush sync to synchronously // update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details. - flushSync(() => { - if (this.state.selectedLinearElement) { - this.setState({ - selectedLinearElement: { - ...this.state.selectedLinearElement, - pointerDownState: ret.pointerDownState, - selectedPointsIndices: ret.selectedPointsIndices, - }, - }); - } - if (this.state.editingLinearElement) { - this.setState({ - editingLinearElement: { - ...this.state.editingLinearElement, - pointerDownState: ret.pointerDownState, - selectedPointsIndices: ret.selectedPointsIndices, - }, - }); - } - }); + if ( + this.state.selectedLinearElement.pointerDownState.segmentMidpoint + .index !== ret.pointerDownState.segmentMidpoint.index + ) { + flushSync(() => { + if (this.state.selectedLinearElement) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + pointerDownState: ret.pointerDownState, + }, + }); + } + if (this.state.editingLinearElement) { + this.setState({ + editingLinearElement: { + ...this.state.editingLinearElement, + pointerDownState: ret.pointerDownState, + }, + }); + } + }); + } return; } else if ( diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 9aaa4397ab51..dd3511393372 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -2,7 +2,6 @@ import { pointFrom, pointScaleFromOrigin, pointTranslate, - PRECISION, vector, vectorCross, vectorFromPoint, @@ -40,7 +39,6 @@ import { HEADING_RIGHT, HEADING_UP, headingIsHorizontal, - headingIsVertical, vectorToHeading, } from "./heading"; import type { ElementUpdate } from "./mutateElement"; @@ -138,12 +136,17 @@ export const updateElbowArrowPoints = ( arrow.fixedSegments ?? [], updates?.fixedSegments ?? [], ); - //console.log(nextFixedSegments); - const arrowStartPoint = pointFrom(arrow.x, arrow.y); - const arrowEndPoint = pointFrom( - arrow.x + updatedPoints[updatedPoints.length - 1][0], - arrow.y + updatedPoints[updatedPoints.length - 1][1], - ); + + // const references = [pointFrom(arrow.x, arrow.y)]; + // nextFixedSegments.forEach((segment) => { + // references.push(segment.anchor); + // }); + // references.push( + // pointFrom( + // arrow.x + updatedPoints[updatedPoints.length - 1][0], + // arrow.y + updatedPoints[updatedPoints.length - 1][1], + // ), + // ); let previousVal: { point: GlobalPoint; @@ -161,20 +164,20 @@ export const updateElbowArrowPoints = ( const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = nextFixedSegments.map((segment, segmentIdx) => { let heading = segment.heading; - let anchor = segment.anchor; - if (arrow.startBinding && arrow.endBinding) { - if (headingIsVertical(heading)) { - anchor = pointFrom( - anchor[0], - (arrowStartPoint[1] + arrowEndPoint[1]) / 2, - ); - } else { - anchor = pointFrom( - (arrowStartPoint[0] + arrowEndPoint[0]) / 2, - anchor[1], - ); - } - } + const anchor = segment.anchor; + // if (arrow.startBinding && arrow.endBinding) { + // if (headingIsVertical(heading)) { + // anchor = pointFrom( + // anchor[0], + // (references[segmentIdx][1] + references[segmentIdx + 2][1]) / 2, //(arrowStartPoint[1] + arrowEndPoint[1]) / 2, + // ); + // } else { + // anchor = pointFrom( + // (references[segmentIdx][0] + references[segmentIdx + 2][0]) / 2, //(arrowStartPoint[0] + arrowEndPoint[0]) / 2, + // anchor[1], + // ); + // } + // } const el = { ...newElement({ type: "rectangle", @@ -321,8 +324,10 @@ export const updateElbowArrowPoints = ( (prevGroupLastPoint[0] + point[0]) / 2, (prevGroupLastPoint[1] + point[1]) / 2, ); - const segmentHorizontal = - Math.abs(prevGroupLastPoint[1] - point[1]) < PRECISION; + + const segmentHorizontal = headingIsHorizontal( + nextFixedSegments[currentGroupIdx - 1].heading, + ); return { anchor, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index e7fc40805749..29edcba7ec98 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1817,6 +1817,7 @@ export class LinearElementEditor { linearElementEditor: LinearElementEditor, pointerCoords: { x: number; y: number }, elementsMap: ElementsMap, + appState: AppState, ): LinearElementEditor { if (!linearElementEditor.elbowed) { return linearElementEditor; @@ -1890,6 +1891,12 @@ export class LinearElementEditor { }, ]); + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + appState, + ); + const newIndex = element.fixedSegments![currFixedSegmentsArrayIdx].index; return { From 88d11151e0ecdcdd2e976299e2ea56f885aeaeed Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sun, 27 Oct 2024 22:37:34 +0100 Subject: [PATCH 052/283] Comparison fix attempt Signed-off-by: Mark Tolmacs --- packages/excalidraw/components/App.tsx | 1 - .../excalidraw/element/linearElementEditor.ts | 58 +++++++++---------- 2 files changed, 28 insertions(+), 31 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 8dad976779bc..3dffa3cc0e1b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7837,7 +7837,6 @@ class App extends React.Component { linearElementEditor.elbowed && linearElementEditor.pointerDownState.segmentMidpoint.index ) { - this.store.shouldCaptureIncrement(); const [gridX, gridY] = getGridPoint( pointerCoords.x, pointerCoords.y, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 29edcba7ec98..48d5f9184036 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1319,11 +1319,11 @@ export class LinearElementEditor { // NOTE: Segment indices are not permanent, the arrow update // might simplify the arrow and remove/merge segments. .map((idx) => { - let res; if ( - Math.abs(nextPoints[idx][0] - nextPoints[idx - 1][0]) < PRECISION + Math.abs(nextPoints[idx][0] - nextPoints[idx - 1][0]) < + Math.abs(nextPoints[idx][1] - nextPoints[idx - 1][1]) ) { - res = { + return { anchor: pointFrom( element.x + nextPoints[idx][0], (element.y + @@ -1339,26 +1339,24 @@ export class LinearElementEditor { : HEADING_DOWN, index: idx, }; - } else { - res = { - anchor: pointFrom( - (element.x + - nextPoints[idx][0] + - element.x + - nextPoints[idx - 1][0]) / - 2, - element.y + nextPoints[idx][1], - ), - heading: - element.x + nextPoints[idx][0] > - element.x + nextPoints[idx - 1][0] - ? HEADING_LEFT - : HEADING_RIGHT, - index: idx, - }; } - return res; + return { + anchor: pointFrom( + (element.x + + nextPoints[idx][0] + + element.x + + nextPoints[idx - 1][0]) / + 2, + element.y + nextPoints[idx][1], + ), + heading: + element.x + nextPoints[idx][0] > + element.x + nextPoints[idx - 1][0] + ? HEADING_LEFT + : HEADING_RIGHT, + index: idx, + }; }) as Sequential, }; } @@ -1852,17 +1850,17 @@ export class LinearElementEditor { ?.length ?? 0; } - const startPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - segmentIdx - 1, - elementsMap, + const startPoint = pointFrom( + element.x + element.points[segmentIdx - 1][0], + element.y + element.points[segmentIdx - 1][1], ); - const endPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - segmentIdx, - elementsMap, + const endPoint = pointFrom( + element.x + element.points[segmentIdx][0], + element.y + element.points[segmentIdx][1], ); - const isHorizontal = startPoint[1] === endPoint[1]; + const isHorizontal = + Math.abs(startPoint[1] - endPoint[1]) < + Math.abs(startPoint[0] - endPoint[0]); LinearElementEditor.movePoints(element, [ { From 5edf99237614f3fb644f597851dccee3a9447136 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 28 Oct 2024 07:53:31 +0100 Subject: [PATCH 053/283] Testing simplified point translation Signed-off-by: Mark Tolmacs --- .../excalidraw/element/linearElementEditor.ts | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 48d5f9184036..7aa76cdbde82 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -59,7 +59,6 @@ import { type LocalPoint, pointDistance, pointTranslate, - PRECISION, } from "../../math"; import { getBezierCurveLength, @@ -1865,25 +1864,17 @@ export class LinearElementEditor { LinearElementEditor.movePoints(element, [ { index: segmentIdx! - 1, - point: LinearElementEditor.pointFromAbsoluteCoords( - element, - pointFrom( - !isHorizontal ? pointerCoords.x : startPoint[0], - isHorizontal ? pointerCoords.y : startPoint[1], - ), - elementsMap, + point: pointFrom( + (!isHorizontal ? pointerCoords.x : startPoint[0]) - element.x, + (isHorizontal ? pointerCoords.y : startPoint[1]) - element.y, ), isDragging: true, }, { index: segmentIdx!, - point: LinearElementEditor.pointFromAbsoluteCoords( - element, - pointFrom( - !isHorizontal ? pointerCoords.x : endPoint[0], - isHorizontal ? pointerCoords.y : endPoint[1], - ), - elementsMap, + point: pointFrom( + (!isHorizontal ? pointerCoords.x : endPoint[0]) - element.x, + (isHorizontal ? pointerCoords.y : endPoint[1]) - element.y, ), isDragging: true, }, From f0b864e43e126f14680fc88c46db46c557734a4d Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 28 Oct 2024 10:29:48 +0100 Subject: [PATCH 054/283] Segment direction and render fix --- packages/excalidraw/element/elbowarrow.ts | 26 ++++++---- packages/excalidraw/element/resizeElements.ts | 2 +- packages/excalidraw/scene/Shape.ts | 49 ++++++++++--------- 3 files changed, 44 insertions(+), 33 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index dd3511393372..4cf6e7548df7 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -320,15 +320,20 @@ export const updateElbowArrowPoints = ( currentGroupIdx = groupIdx; const prevGroupLastPoint = points[index - 1]; - const anchor = pointFrom( - (prevGroupLastPoint[0] + point[0]) / 2, - (prevGroupLastPoint[1] + point[1]) / 2, - ); const segmentHorizontal = headingIsHorizontal( nextFixedSegments[currentGroupIdx - 1].heading, ); + const anchor = pointFrom( + segmentHorizontal + ? (prevGroupLastPoint[0] + point[0]) / 2 + : nextFixedSegments[currentGroupIdx - 1].anchor[0], + segmentHorizontal + ? nextFixedSegments[currentGroupIdx - 1].anchor[1] + : (prevGroupLastPoint[1] + point[1]) / 2, + ); + return { anchor, index, @@ -1243,7 +1248,10 @@ const getElbowArrowCornerPoints = ( pointGroups: GlobalPoint[][], ): GlobalPoint[][] => { const points = pointGroups.flat(); - let previousHorizontal = points[0][1] === points[1][1]; + + let previousHorizontal = + Math.abs(points[0][1] - points[1][1]) < + Math.abs(points[0][0] - points[1][0]); if (points.length > 1) { return multiDimensionalArrayDeepFilter(pointGroups, (p, idx) => { // The very first and last points are always kept @@ -1252,11 +1260,9 @@ const getElbowArrowCornerPoints = ( } const next = points[idx + 1]; - const nextHorizontal = p[1] === next[1]; - if ( - (previousHorizontal && nextHorizontal) || - (!previousHorizontal && !nextHorizontal) - ) { + const nextHorizontal = + Math.abs(p[1] - next[1]) < Math.abs(p[0] - next[0]); + if (previousHorizontal === nextHorizontal) { previousHorizontal = nextHorizontal; return false; } diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 6fcbf33383c0..de0ed2f38e93 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -1088,7 +1088,7 @@ export const resizeMultipleElements = ( } as ExcalidrawElbowArrowElement, elementsMap, ); - debugDrawPoint(startPoint); + const points = Array.from(update.points || element.points); points[0] = pointFrom(0, 0); points[points.length - 1] = pointFrom( diff --git a/packages/excalidraw/scene/Shape.ts b/packages/excalidraw/scene/Shape.ts index 0426b3f70f50..653dac83707b 100644 --- a/packages/excalidraw/scene/Shape.ts +++ b/packages/excalidraw/scene/Shape.ts @@ -23,12 +23,7 @@ import { } from "../element/typeChecks"; import { canChangeRoundness } from "./comparisons"; import type { EmbedsValidationStatus } from "../types"; -import { - pointFrom, - pointDistance, - type GlobalPoint, - type LocalPoint, -} from "../../math"; +import { pointFrom, pointDistance, type LocalPoint } from "../../math"; import { getCornerRadius, isPathALoop } from "../shapes"; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; @@ -504,45 +499,55 @@ export const _generateElementShape = ( } }; -const generateElbowArrowShape = ( - points: readonly Point[], +const generateElbowArrowShape = ( + points: readonly LocalPoint[], radius: number, ) => { const subpoints = [] as [number, number][]; for (let i = 1; i < points.length - 1; i += 1) { const prev = points[i - 1]; const next = points[i + 1]; + const point = points[i]; const corner = Math.min( radius, pointDistance(points[i], next) / 2, pointDistance(points[i], prev) / 2, ); - if (prev[0] < points[i][0] && prev[1] === points[i][1]) { - // LEFT - subpoints.push([points[i][0] - corner, points[i][1]]); - } else if (prev[0] === points[i][0] && prev[1] < points[i][1]) { + const prevIsHorizontal = + Math.abs(prev[1] - point[1]) < Math.abs(prev[0] - point[0]); + if (prevIsHorizontal) { + if (prev[0] < point[0]) { + // LEFT + subpoints.push([points[i][0] - corner, points[i][1]]); + } else { + // RIGHT + subpoints.push([points[i][0] + corner, points[i][1]]); + } + } else if (prev[1] < point[1]) { // UP subpoints.push([points[i][0], points[i][1] - corner]); - } else if (prev[0] > points[i][0] && prev[1] === points[i][1]) { - // RIGHT - subpoints.push([points[i][0] + corner, points[i][1]]); } else { subpoints.push([points[i][0], points[i][1] + corner]); } subpoints.push(points[i] as [number, number]); - if (next[0] < points[i][0] && next[1] === points[i][1]) { - // LEFT - subpoints.push([points[i][0] - corner, points[i][1]]); - } else if (next[0] === points[i][0] && next[1] < points[i][1]) { + const nextIsHorizontal = + Math.abs(point[1] - next[1]) < Math.abs(point[0] - next[0]); + if (nextIsHorizontal) { + if (next[0] < point[0]) { + // LEFT + subpoints.push([points[i][0] - corner, points[i][1]]); + } else { + // RIGHT + subpoints.push([points[i][0] + corner, points[i][1]]); + } + } else if (next[1] < point[1]) { // UP subpoints.push([points[i][0], points[i][1] - corner]); - } else if (next[0] > points[i][0] && next[1] === points[i][1]) { - // RIGHT - subpoints.push([points[i][0] + corner, points[i][1]]); } else { + // DOWN subpoints.push([points[i][0], points[i][1] + corner]); } } From 83a73416d20887bbddaec6a5f10ad0333408d978 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 28 Oct 2024 12:51:46 +0100 Subject: [PATCH 055/283] Fixed multi segment drag --- packages/excalidraw/element/elbowarrow.ts | 55 +++++++++++++---------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 4cf6e7548df7..15870929fb42 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -39,6 +39,7 @@ import { HEADING_RIGHT, HEADING_UP, headingIsHorizontal, + headingIsVertical, vectorToHeading, } from "./heading"; import type { ElementUpdate } from "./mutateElement"; @@ -137,16 +138,16 @@ export const updateElbowArrowPoints = ( updates?.fixedSegments ?? [], ); - // const references = [pointFrom(arrow.x, arrow.y)]; - // nextFixedSegments.forEach((segment) => { - // references.push(segment.anchor); - // }); - // references.push( - // pointFrom( - // arrow.x + updatedPoints[updatedPoints.length - 1][0], - // arrow.y + updatedPoints[updatedPoints.length - 1][1], - // ), - // ); + const references: GlobalPoint[] = [pointFrom(arrow.x, arrow.y)]; + nextFixedSegments.forEach((segment) => { + references.push(segment.anchor); + }); + references.push( + pointFrom( + arrow.x + updatedPoints[updatedPoints.length - 1][0], + arrow.y + updatedPoints[updatedPoints.length - 1][1], + ), + ); let previousVal: { point: GlobalPoint; @@ -164,20 +165,26 @@ export const updateElbowArrowPoints = ( const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = nextFixedSegments.map((segment, segmentIdx) => { let heading = segment.heading; - const anchor = segment.anchor; - // if (arrow.startBinding && arrow.endBinding) { - // if (headingIsVertical(heading)) { - // anchor = pointFrom( - // anchor[0], - // (references[segmentIdx][1] + references[segmentIdx + 2][1]) / 2, //(arrowStartPoint[1] + arrowEndPoint[1]) / 2, - // ); - // } else { - // anchor = pointFrom( - // (references[segmentIdx][0] + references[segmentIdx + 2][0]) / 2, //(arrowStartPoint[0] + arrowEndPoint[0]) / 2, - // anchor[1], - // ); - // } - // } + let anchor = segment.anchor; + + // Allow shifting the focus point of both fixed segment borders + // are far away in one direction, creating a "valley" otherwise + const prevRef = references[segmentIdx]; + const nextRef = references[segmentIdx + 2]; + + if (prevRef && nextRef) { + if (headingIsVertical(heading)) { + anchor = pointFrom( + anchor[0], + (prevRef[1] + nextRef[1]) / 2, + ); + } else { + anchor = pointFrom( + (prevRef[0] + nextRef[0]) / 2, + anchor[1], + ); + } + } const el = { ...newElement({ type: "rectangle", From 67d21f2cd70924c9311b04674edc2c706fb2513b Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 28 Oct 2024 15:33:00 +0100 Subject: [PATCH 056/283] Fix paste --- packages/excalidraw/components/App.tsx | 15 ++++- .../excalidraw/element/linearElementEditor.ts | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 3dffa3cc0e1b..f60542e421f4 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -3198,10 +3198,23 @@ class App extends React.Component { const newElements = duplicateElements( elements.map((element) => { - return newElementWith(element, { + let newElement = newElementWith(element, { x: element.x + gridX - minX, y: element.y + gridY - minY, }); + + if (isElbowArrow(newElement)) { + newElement = { + ...newElement, + fixedSegments: LinearElementEditor.restoreFixedSegments( + newElement, + newElement.x, + newElement.y, + ), + }; + } + + return newElement; }), { randomizeSeed: !opts.retainSeed, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 7aa76cdbde82..4072134c96ef 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1377,6 +1377,61 @@ export class LinearElementEditor { ); } + static restoreFixedSegments( + element: ExcalidrawElbowArrowElement, + x: number, + y: number, + ) { + return ( + element.fixedSegments?.map((segment) => { + if ( + Math.abs( + element.points[segment.index][0] - + element.points[segment.index - 1][0], + ) < + Math.abs( + element.points[segment.index][1] - + element.points[segment.index - 1][1], + ) + ) { + return { + anchor: pointFrom( + x + element.points[segment.index][0], + (y + + element.points[segment.index][1] + + y + + element.points[segment.index - 1][1]) / + 2, + ), + heading: + y + element.points[segment.index][1] > + y + element.points[segment.index - 1][1] + ? HEADING_UP + : HEADING_DOWN, + index: segment.index, + }; + } + + return { + anchor: pointFrom( + (x + + element.points[segment.index][0] + + x + + element.points[segment.index - 1][0]) / + 2, + y + element.points[segment.index][1], + ), + heading: + x + element.points[segment.index][0] > + x + element.points[segment.index - 1][0] + ? HEADING_LEFT + : HEADING_RIGHT, + index: segment.index, + }; + }) ?? null + ); + } + static shouldAddMidpoint( linearElementEditor: LinearElementEditor, pointerCoords: PointerCoords, From 91966fff71dbb61eaf38d2fdf1248f91d721ea0c Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 28 Oct 2024 15:36:31 +0100 Subject: [PATCH 057/283] Fix lint --- packages/excalidraw/element/resizeElements.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index de0ed2f38e93..17a1c2a7eea5 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -68,7 +68,6 @@ import { pointRotateRads, type Radians, } from "../../math"; -import { debugDrawPoint } from "../visualdebug"; // Returns true when transform (resizing/rotation) happened export const transformElements = ( From 47ba905eed9b090c1a94402d6786b16ff3a9e3e3 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 28 Oct 2024 16:14:07 +0100 Subject: [PATCH 058/283] Optimize frame highlight state change, fix recursive state change warnings --- packages/excalidraw/components/App.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index f60542e421f4..5d57b343780b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7964,10 +7964,13 @@ class App extends React.Component { isFrameLikeElement(e), ); const topLayerFrame = this.getTopLayerFrameAtSceneCoords(pointerCoords); - this.setState({ - frameToHighlight: - topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null, - }); + const frameToHighlight = + topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null; + if (this.state.frameToHighlight !== frameToHighlight) { + flushSync(() => { + this.setState({ frameToHighlight }); + }); + } // Marking that click was used for dragging to check // if elements should be deselected on pointerup @@ -8114,7 +8117,9 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), ); - this.setState({ snapLines }); + flushSync(() => { + this.setState({ snapLines }); + }); // when we're editing the name of a frame, we want the user to be // able to select and interact with the text input From ba6f730b166062b9bb6b3b9c7e443490705fd7c6 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 28 Oct 2024 16:36:34 +0100 Subject: [PATCH 059/283] Allow proper dragging --- packages/excalidraw/components/App.tsx | 4 - packages/excalidraw/element/dragElements.ts | 27 ++++-- .../excalidraw/element/linearElementEditor.ts | 82 +++++++++---------- 3 files changed, 58 insertions(+), 55 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 5d57b343780b..4ecc57288ef6 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7862,10 +7862,6 @@ class App extends React.Component { this.state, ); - // Since we are reading from previous state which is not possible with - // automatic batching in React 18 hence using flush sync to synchronously - // update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details. - if ( this.state.selectedLinearElement.pointerDownState.segmentMidpoint .index !== ret.pointerDownState.segmentMidpoint.index diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index f773a7a06e8e..f120256158db 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -22,6 +22,7 @@ import { import { getFontString } from "../utils"; import { TEXT_AUTOWRAP_THRESHOLD } from "../constants"; import { getGridPoint } from "../snapping"; +import { LinearElementEditor } from "./linearElementEditor"; export const dragSelectedElements = ( pointerDownState: PointerDownState, @@ -42,9 +43,10 @@ export const dragSelectedElements = ( return; } - const selectedElements = _selectedElements.filter( - (el) => !(isElbowArrow(el) && el.startBinding && el.endBinding), - ); + const selectedElements = _selectedElements; + // .filter( + // (el) => !(isElbowArrow(el) && el.startBinding && el.endBinding), + // ); // 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 @@ -78,10 +80,17 @@ export const dragSelectedElements = ( elementsToUpdate.forEach((element) => { updateElementCoords(pointerDownState, element, adjustedOffset); - if ( + if (isElbowArrow(element)) { + mutateElement(element, { + fixedSegments: LinearElementEditor.restoreFixedSegments( + element, + element.x, + element.y, + ), + }); + } + if (!isArrowElement(element)) { // skip arrow labels since we calculate its position during render - !isArrowElement(element) - ) { const textElement = getBoundTextElement( element, scene.getNonDeletedElementsMap(), @@ -89,10 +98,10 @@ export const dragSelectedElements = ( if (textElement) { updateElementCoords(pointerDownState, textElement, adjustedOffset); } + updateBoundElements(element, scene.getElementsMapIncludingDeleted(), { + simultaneouslyUpdated: Array.from(elementsToUpdate), + }); } - updateBoundElements(element, scene.getElementsMapIncludingDeleted(), { - simultaneouslyUpdated: Array.from(elementsToUpdate), - }); }); }; diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 4072134c96ef..f12c541ce883 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1381,55 +1381,53 @@ export class LinearElementEditor { element: ExcalidrawElbowArrowElement, x: number, y: number, - ) { - return ( - element.fixedSegments?.map((segment) => { - if ( - Math.abs( - element.points[segment.index][0] - - element.points[segment.index - 1][0], - ) < - Math.abs( - element.points[segment.index][1] - - element.points[segment.index - 1][1], - ) - ) { - return { - anchor: pointFrom( - x + element.points[segment.index][0], - (y + - element.points[segment.index][1] + - y + - element.points[segment.index - 1][1]) / - 2, - ), - heading: - y + element.points[segment.index][1] > - y + element.points[segment.index - 1][1] - ? HEADING_UP - : HEADING_DOWN, - index: segment.index, - }; - } - + ): Sequential | null { + return (element.fixedSegments?.map((segment) => { + if ( + Math.abs( + element.points[segment.index][0] - + element.points[segment.index - 1][0], + ) < + Math.abs( + element.points[segment.index][1] - + element.points[segment.index - 1][1], + ) + ) { return { anchor: pointFrom( - (x + - element.points[segment.index][0] + - x + - element.points[segment.index - 1][0]) / + x + element.points[segment.index][0], + (y + + element.points[segment.index][1] + + y + + element.points[segment.index - 1][1]) / 2, - y + element.points[segment.index][1], ), heading: - x + element.points[segment.index][0] > - x + element.points[segment.index - 1][0] - ? HEADING_LEFT - : HEADING_RIGHT, + y + element.points[segment.index][1] > + y + element.points[segment.index - 1][1] + ? HEADING_UP + : HEADING_DOWN, index: segment.index, }; - }) ?? null - ); + } + + return { + anchor: pointFrom( + (x + + element.points[segment.index][0] + + x + + element.points[segment.index - 1][0]) / + 2, + y + element.points[segment.index][1], + ), + heading: + x + element.points[segment.index][0] > + x + element.points[segment.index - 1][0] + ? HEADING_LEFT + : HEADING_RIGHT, + index: segment.index, + }; + }) ?? null) as Sequential; } static shouldAddMidpoint( From 396554270ebf70df027885d9faa7c6efd49e019b Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 30 Oct 2024 13:35:43 +0100 Subject: [PATCH 060/283] Midpoints rendering for elbow arrows ignoring pre-cached ones --- packages/excalidraw/element/elbowarrow.ts | 8 +- .../excalidraw/element/linearElementEditor.ts | 3 +- .../excalidraw/renderer/interactiveScene.ts | 97 +++++++++++-------- 3 files changed, 65 insertions(+), 43 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 15870929fb42..7c7c9f321211 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -188,10 +188,10 @@ export const updateElbowArrowPoints = ( const el = { ...newElement({ type: "rectangle", - x: anchor[0] - 5, - y: anchor[1] - 5, - width: 10, - height: 10, + x: anchor[0] - 3, + y: anchor[1] - 3, + width: 6, + height: 6, }), index: "DONOTSYNC" as FractionalIndex, } as Ordered; diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index f12c541ce883..f7465a15610a 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -27,6 +27,7 @@ import type { InteractiveCanvasAppState, AppClassProperties, NullableGridSize, + Zoom, } from "../types"; import { mutateElement } from "./mutateElement"; @@ -612,7 +613,7 @@ export class LinearElementEditor { element: NonDeleted, startPoint: GlobalPoint | LocalPoint, endPoint: GlobalPoint | LocalPoint, - zoom: AppState["zoom"], + zoom: Zoom, ) { let distance = pointDistance( pointFrom(startPoint[0], startPoint[1]), diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 874e4ec5b684..603cf32259f7 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -74,7 +74,12 @@ import type { InteractiveSceneRenderConfig, RenderableElementsMap, } from "../scene/types"; -import type { GlobalPoint, LocalPoint, Radians } from "../../math"; +import { + pointFrom, + type GlobalPoint, + type LocalPoint, + type Radians, +} from "../../math"; import { getCornerRadius } from "../shapes"; const renderLinearElementPointHighlight = ( @@ -504,44 +509,60 @@ const renderLinearPointHandles = ( }); //Rendering segment mid points - const midPoints = LinearElementEditor.getEditorMidPoints( - element, - elementsMap, - appState, - ).filter( - (midPoint, idx, midPoints): midPoint is GlobalPoint => - midPoint !== null && - !(isElbowArrow(element) && (idx === 0 || idx === midPoints.length - 1)), - ); - - midPoints.forEach((segmentMidPoint, segmentIdx) => { - if (isElbowArrow(element)) { - const isFixedSegmentMidPoint = - (element.fixedSegments?.findIndex( - // First segment is always unfixable and plus one to address the - // fixedSegments array = +2 offset - (segment) => segment.index === segmentIdx + 2, - ) ?? -1) === -1; + if (isElbowArrow(element)) { + const indices = element.fixedSegments?.map((s) => s.index) ?? []; + const fixedPoints = element.points.slice(0, -1).map((p, i) => { + return indices.includes(i + 1); + }); + const globalPoint = element.points.map((p) => + pointFrom(element.x + p[0], element.y + p[1]), + ); + globalPoint.slice(1, -2).forEach((p, idx) => { + if ( + !LinearElementEditor.isSegmentTooShort( + element, + p, + element.points[idx + 2], + appState.zoom, + ) + ) { + renderSingleLinearPoint( + context, + appState, + pointFrom( + (p[0] + globalPoint[idx + 2][0]) / 2, + (p[1] + globalPoint[idx + 2][1]) / 2, + ), + POINT_HANDLE_SIZE / 2, + false, + !fixedPoints[idx + 1], + ); + } + }); + } else { + const midPoints = LinearElementEditor.getEditorMidPoints( + element, + elementsMap, + appState, + ).filter( + (midPoint, idx, midPoints): midPoint is GlobalPoint => + midPoint !== null && + !(isElbowArrow(element) && (idx === 0 || idx === midPoints.length - 1)), + ); - renderSingleLinearPoint( - context, - appState, - segmentMidPoint, - POINT_HANDLE_SIZE / 2, - false, - isFixedSegmentMidPoint, - ); - } else if (appState.editingLinearElement || points.length === 2) { - renderSingleLinearPoint( - context, - appState, - segmentMidPoint, - POINT_HANDLE_SIZE / 2, - false, - true, - ); - } - }); + midPoints.forEach((segmentMidPoint, segmentIdx) => { + if (appState.editingLinearElement || points.length === 2) { + renderSingleLinearPoint( + context, + appState, + segmentMidPoint, + POINT_HANDLE_SIZE / 2, + false, + true, + ); + } + }); + } context.restore(); }; From 9aea5c2f3b16d074f80d698376fdab84c1011702 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 30 Oct 2024 18:27:21 +0100 Subject: [PATCH 061/283] Hook detection --- packages/excalidraw/element/elbowarrow.ts | 34 ++++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 7c7c9f321211..d14f586281f2 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -188,10 +188,10 @@ export const updateElbowArrowPoints = ( const el = { ...newElement({ type: "rectangle", - x: anchor[0] - 3, - y: anchor[1] - 3, - width: 6, - height: 6, + x: anchor[0] - 0.1, + y: anchor[1] - 0.1, + width: 0.2, + height: 0.2, }), index: "DONOTSYNC" as FractionalIndex, } as Ordered; @@ -205,6 +205,32 @@ export const updateElbowArrowPoints = ( ? HEADING_DOWN : HEADING_UP; + // Detect hooks and flip the heading if needed + if (updatedPoints[segment.index - 3]) { + const isHook = !compareHeading( + vectorToHeading( + vectorFromPoint( + updatedPoints[segment.index - 1], + updatedPoints[segment.index], + ), + ), + vectorToHeading( + vectorFromPoint( + updatedPoints[segment.index - 3], + updatedPoints[segment.index - 2], + ), + ), + ); + if ( + isHook && + ((headingIsHorizontal(heading) && previousVal.point[0] > anchor[0]) || + (headingIsVertical(heading) && previousVal.point[1] > anchor[1])) + ) { + console.log("hook"); + heading = flipHeading(heading); + } + } + const endFixedPoint: [number, number] = compareHeading( heading, HEADING_DOWN, From 3006312d2fe3c9ed82648c104d10c2197d3ed60a Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 31 Oct 2024 18:03:22 +0100 Subject: [PATCH 062/283] Broken but refactor is needed going forward Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 206 +++++++++++++--------- 1 file changed, 125 insertions(+), 81 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index d14f586281f2..d6a6bc085ffa 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -149,7 +149,7 @@ export const updateElbowArrowPoints = ( ), ); - let previousVal: { + let previousFixedSegment: { point: GlobalPoint; fixedPoint: FixedPointBinding["fixedPoint"] | null; elementId: string | null; @@ -198,39 +198,13 @@ export const updateElbowArrowPoints = ( fakeElementsMap.set(el.id, el); heading = headingIsHorizontal(heading) - ? previousVal.point[0] > anchor[0] + ? previousFixedSegment.point[0] > anchor[0] ? HEADING_RIGHT : HEADING_LEFT - : previousVal.point[1] > anchor[1] + : previousFixedSegment.point[1] > anchor[1] ? HEADING_DOWN : HEADING_UP; - // Detect hooks and flip the heading if needed - if (updatedPoints[segment.index - 3]) { - const isHook = !compareHeading( - vectorToHeading( - vectorFromPoint( - updatedPoints[segment.index - 1], - updatedPoints[segment.index], - ), - ), - vectorToHeading( - vectorFromPoint( - updatedPoints[segment.index - 3], - updatedPoints[segment.index - 2], - ), - ), - ); - if ( - isHook && - ((headingIsHorizontal(heading) && previousVal.point[0] > anchor[0]) || - (headingIsVertical(heading) && previousVal.point[1] > anchor[1])) - ) { - console.log("hook"); - heading = flipHeading(heading); - } - } - const endFixedPoint: [number, number] = compareHeading( heading, HEADING_DOWN, @@ -241,24 +215,20 @@ export const updateElbowArrowPoints = ( : compareHeading(heading, HEADING_UP) ? [0.5, 0] : [1, 0.5]; - const endGlobalPoint = getGlobalFixedPointForBindableElement( - endFixedPoint, - el, - ); const state = { - x: previousVal.point[0], - y: previousVal.point[1], + x: previousFixedSegment.point[0], + y: previousFixedSegment.point[1], startArrowhead: segmentIdx === 0 ? arrow.startArrowhead : null, endArrowhead: null as Arrowhead | null, startBinding: segmentIdx === 0 ? arrow.startBinding : { - elementId: previousVal.elementId!, + elementId: previousFixedSegment.elementId!, focus: 0, gap: 0, - fixedPoint: previousVal.fixedPoint!, + fixedPoint: previousFixedSegment.fixedPoint!, }, endBinding: { elementId: el.id, @@ -277,12 +247,16 @@ export const updateElbowArrowPoints = ( : compareHeading(heading, HEADING_UP) ? [0.5, 1] : [0, 0.5]; + const segmentEndGlobalPoint = getGlobalFixedPointForBindableElement( + endFixedPoint, + el, + ); const endLocalPoint = pointFrom( - endGlobalPoint[0] - previousVal.point[0], - endGlobalPoint[1] - previousVal.point[1], + segmentEndGlobalPoint[0] - previousFixedSegment.point[0], + segmentEndGlobalPoint[1] - previousFixedSegment.point[1], ); - previousVal = { + previousFixedSegment = { point: getGlobalFixedPointForBindableElement(nextStartFixedPoint, el), fixedPoint: nextStartFixedPoint, elementId: el.id, @@ -292,18 +266,18 @@ export const updateElbowArrowPoints = ( }); pointPairs.push([ { - x: previousVal.point[0], - y: previousVal.point[1], + x: previousFixedSegment.point[0], + y: previousFixedSegment.point[1], startArrowhead: null, endArrowhead: arrow.endArrowhead, startBinding: nextFixedSegments.length === 0 ? arrow.startBinding : { - elementId: previousVal.elementId!, + elementId: previousFixedSegment.elementId!, focus: 0, gap: 0, - fixedPoint: previousVal.fixedPoint!, + fixedPoint: previousFixedSegment.fixedPoint!, }, endBinding: arrow.endBinding, }, @@ -313,28 +287,21 @@ export const updateElbowArrowPoints = ( pointFrom( arrow.x + updatedPoints[updatedPoints.length - 1][0] - - previousVal.point[0], + previousFixedSegment.point[0], arrow.y + updatedPoints[updatedPoints.length - 1][1] - - previousVal.point[1], + previousFixedSegment.point[1], ), ], ]); const rawPointGroups = pointPairs.map(([state, points], idx) => { const raw = - routeElbowArrow( - state, - fakeElementsMap, - points, - nextFixedSegments.length > 0 && idx === 0, - nextFixedSegments.length > 0 && idx === pointPairs.length - 1, - idx === pointPairs.length - 1 || idx === 0 - ? options - : { - disableBinding: true, - }, - ) ?? []; + routeElbowArrow(state, fakeElementsMap, points, { + ...options, + ...(idx !== 0 ? { startIsMidPoint: true } : {}), + ...(idx !== pointPairs.length - 1 ? { endIsMidPoint: true } : {}), + }) ?? []; return raw; }); @@ -389,15 +356,30 @@ export const updateElbowArrowPoints = ( }; /** - * Generate the elbow arrow segments + * Retrieves data necessary for calculating the elbow arrow path. * - * @param arrow - * @param elementsMap - * @param nextPoints - * @param options - * @returns + * @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.disableBinding - Indicates if binding should be disabled. + * @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 routeElbowArrow = ( +const getElbowArrowData = ( arrow: { x: number; y: number; @@ -408,13 +390,12 @@ const routeElbowArrow = ( }, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, nextPoints: readonly LocalPoint[], - isStartSegment: boolean, - isEndSegment: boolean, options?: { isDragging?: boolean; - disableBinding?: boolean; + startIsMidPoint?: boolean; + endIsMidPoint?: boolean; }, -): GlobalPoint[] | null => { +) => { const origStartGlobalPoint: GlobalPoint = pointTranslate< LocalPoint, GlobalPoint @@ -484,6 +465,8 @@ const routeElbowArrow = ( startHeading, arrow.startArrowhead ? FIXED_BINDING_DISTANCE * 6 + : options?.startIsMidPoint + ? 0.01 : FIXED_BINDING_DISTANCE * 2, 1, ), @@ -496,6 +479,8 @@ const routeElbowArrow = ( endHeading, arrow.endArrowhead ? FIXED_BINDING_DISTANCE * 6 + : options?.endIsMidPoint + ? 0.01 : FIXED_BINDING_DISTANCE * 2, 1, ), @@ -526,7 +511,7 @@ const routeElbowArrow = ( : [startElementBounds, endElementBounds], ); const dynamicAABBs = generateDynamicAABBs( - isEndSegment + options?.startIsMidPoint ? ([ hoveredStartElement!.x + hoveredStartElement!.width / 2 - 1, hoveredStartElement!.y + hoveredStartElement!.height / 2 - 1, @@ -536,7 +521,7 @@ const routeElbowArrow = ( : boundsOverlap ? startPointBounds : startElementBounds, - isStartSegment + options?.endIsMidPoint ? ([ hoveredEndElement!.x + hoveredEndElement!.width / 2 - 1, hoveredEndElement!.y + hoveredEndElement!.height / 2 - 1, @@ -547,7 +532,7 @@ const routeElbowArrow = ( ? endPointBounds : endElementBounds, commonBounds, - isEndSegment + options?.startIsMidPoint ? [0, 0, 0, 0] : boundsOverlap ? offsetFromHeading( @@ -565,7 +550,7 @@ const routeElbowArrow = ( : FIXED_BINDING_DISTANCE * 2), BASE_PADDING, ), - isStartSegment + options?.endIsMidPoint ? [0, 0, 0, 0] : boundsOverlap ? offsetFromHeading( @@ -586,6 +571,8 @@ const routeElbowArrow = ( boundsOverlap, hoveredStartElement && aabbForElement(hoveredStartElement), hoveredEndElement && aabbForElement(hoveredEndElement), + options?.endIsMidPoint, + options?.startIsMidPoint, ); const startDonglePosition = getDonglePosition( dynamicAABBs[0], @@ -598,6 +585,61 @@ const routeElbowArrow = ( 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: { + x: number; + y: number; + startBinding: FixedPointBinding | null; + endBinding: FixedPointBinding | null; + startArrowhead: Arrowhead | null; + endArrowhead: Arrowhead | null; + }, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + nextPoints: readonly LocalPoint[], + options?: { + isDragging?: boolean; + startIsMidPoint?: boolean; + endIsMidPoint?: boolean; + }, +): GlobalPoint[] | null => { + const { + dynamicAABBs, + startDonglePosition, + startGlobalPoint, + startHeading, + endDonglePosition, + endGlobalPoint, + endHeading, + commonBounds, + hoveredEndElement, + } = getElbowArrowData(arrow, elementsMap, nextPoints, options); + // Canculate Grid positions const grid = calculateGrid( dynamicAABBs, @@ -820,6 +862,8 @@ const generateDynamicAABBs = ( disableSideHack?: boolean, startElementBounds?: Bounds | null, endElementBounds?: Bounds | null, + disableSlideUnderForFirst?: boolean, + disableSlideUnderForSecond?: boolean, ): Bounds[] => { const startEl = startElementBounds ?? a; const endEl = endElementBounds ?? b; @@ -830,28 +874,28 @@ const generateDynamicAABBs = ( const first = [ a[0] > b[2] - ? a[1] > b[3] || a[3] < b[1] + ? !disableSlideUnderForFirst && (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] + ? !disableSlideUnderForFirst && (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] + ? !disableSlideUnderForFirst && (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] + ? !disableSlideUnderForFirst && (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] @@ -860,28 +904,28 @@ const generateDynamicAABBs = ( ] as Bounds; const second = [ b[0] > a[2] - ? b[1] > a[3] || b[3] < a[1] + ? !disableSlideUnderForSecond && (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] + ? !disableSlideUnderForSecond && (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] + ? !disableSlideUnderForSecond && (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] + ? !disableSlideUnderForSecond && (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] From 6aef677f067b580d355cd07897d35a255df909e6 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 1 Nov 2024 09:14:40 +0100 Subject: [PATCH 063/283] Fix midpoint repositioning on some configurations --- packages/excalidraw/element/elbowarrow.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index d6a6bc085ffa..423dbc0a17bc 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -138,16 +138,17 @@ export const updateElbowArrowPoints = ( updates?.fixedSegments ?? [], ); - const references: GlobalPoint[] = [pointFrom(arrow.x, arrow.y)]; + const { startDonglePosition, endDonglePosition } = getElbowArrowData( + arrow, + elementsMap, + updatedPoints, + ); + + const references: GlobalPoint[] = [startDonglePosition]; nextFixedSegments.forEach((segment) => { references.push(segment.anchor); }); - references.push( - pointFrom( - arrow.x + updatedPoints[updatedPoints.length - 1][0], - arrow.y + updatedPoints[updatedPoints.length - 1][1], - ), - ); + references.push(endDonglePosition); let previousFixedSegment: { point: GlobalPoint; From 45313959a2108fdeb7d63a1a31419eca83d8096f Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 1 Nov 2024 10:22:54 +0100 Subject: [PATCH 064/283] Fix segment jump in some configuration due to binding code --- packages/excalidraw/element/elbowarrow.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 423dbc0a17bc..76b910a13326 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -414,7 +414,9 @@ const getElbowArrowData = ( const [hoveredStartElement, hoveredEndElement] = options?.isDragging ? getHoveredElements(origStartGlobalPoint, origEndGlobalPoint, elementsMap) : [startElement, endElement]; - const startGlobalPoint = getGlobalPoint( + const startGlobalPoint = options?.startIsMidPoint + ? origStartGlobalPoint + : getGlobalPoint( arrow.startBinding?.fixedPoint, origStartGlobalPoint, origEndGlobalPoint, @@ -424,7 +426,9 @@ const getElbowArrowData = ( options?.isDragging, ); - const endGlobalPoint = getGlobalPoint( + const endGlobalPoint = options?.endIsMidPoint + ? origEndGlobalPoint + : getGlobalPoint( arrow.endBinding?.fixedPoint, origEndGlobalPoint, origStartGlobalPoint, From f6210b6a4b3d2a961787691c19f9961e9bbdab50 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 1 Nov 2024 10:25:31 +0100 Subject: [PATCH 065/283] Unified anchor and heading --- packages/excalidraw/element/elbowarrow.ts | 76 +++++++++-------------- 1 file changed, 28 insertions(+), 48 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 76b910a13326..f6cdba6bd330 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -127,7 +127,6 @@ export const updateElbowArrowPoints = ( }, options?: { isDragging?: boolean; - disableBinding?: boolean; }, ): ElementUpdate => { const updatedPoints = updates.points ?? arrow.points; @@ -168,6 +167,15 @@ export const updateElbowArrowPoints = ( let heading = segment.heading; let anchor = segment.anchor; + heading = headingIsHorizontal(heading) + ? previousFixedSegment.point[0] > anchor[0] + ? HEADING_RIGHT + : HEADING_LEFT + : previousFixedSegment.point[1] > anchor[1] + ? HEADING_DOWN + : HEADING_UP; + nextFixedSegments[segmentIdx].heading = heading; + // Allow shifting the focus point of both fixed segment borders // are far away in one direction, creating a "valley" otherwise const prevRef = references[segmentIdx]; @@ -185,6 +193,7 @@ export const updateElbowArrowPoints = ( anchor[1], ); } + nextFixedSegments[segmentIdx].anchor = anchor; } const el = { ...newElement({ @@ -198,14 +207,6 @@ export const updateElbowArrowPoints = ( } as Ordered; fakeElementsMap.set(el.id, el); - heading = headingIsHorizontal(heading) - ? previousFixedSegment.point[0] > anchor[0] - ? HEADING_RIGHT - : HEADING_LEFT - : previousFixedSegment.point[1] > anchor[1] - ? HEADING_DOWN - : HEADING_UP; - const endFixedPoint: [number, number] = compareHeading( heading, HEADING_DOWN, @@ -320,31 +321,10 @@ export const updateElbowArrowPoints = ( // i.e. we are the first point of the group excluding the first group currentGroupIdx = groupIdx; - const prevGroupLastPoint = points[index - 1]; - - const segmentHorizontal = headingIsHorizontal( - nextFixedSegments[currentGroupIdx - 1].heading, - ); - - const anchor = pointFrom( - segmentHorizontal - ? (prevGroupLastPoint[0] + point[0]) / 2 - : nextFixedSegments[currentGroupIdx - 1].anchor[0], - segmentHorizontal - ? nextFixedSegments[currentGroupIdx - 1].anchor[1] - : (prevGroupLastPoint[1] + point[1]) / 2, - ); - return { - anchor, + anchor: nextFixedSegments[currentGroupIdx - 1].anchor, index, - heading: segmentHorizontal - ? point[0] > prevGroupLastPoint[0] - ? HEADING_LEFT - : HEADING_RIGHT - : point[1] > prevGroupLastPoint[1] - ? HEADING_UP - : HEADING_DOWN, + heading: nextFixedSegments[currentGroupIdx - 1].heading, }; } @@ -417,26 +397,26 @@ const getElbowArrowData = ( const startGlobalPoint = options?.startIsMidPoint ? origStartGlobalPoint : getGlobalPoint( - arrow.startBinding?.fixedPoint, - origStartGlobalPoint, - origEndGlobalPoint, - elementsMap, - startElement, - hoveredStartElement, + arrow.startBinding?.fixedPoint, + origStartGlobalPoint, + origEndGlobalPoint, + elementsMap, + startElement, + hoveredStartElement, - options?.isDragging, - ); + options?.isDragging, + ); const endGlobalPoint = options?.endIsMidPoint ? origEndGlobalPoint : getGlobalPoint( - arrow.endBinding?.fixedPoint, - origEndGlobalPoint, - origStartGlobalPoint, - elementsMap, - endElement, - hoveredEndElement, - options?.isDragging, - ); + arrow.endBinding?.fixedPoint, + origEndGlobalPoint, + origStartGlobalPoint, + elementsMap, + endElement, + hoveredEndElement, + options?.isDragging, + ); const startHeading = getBindPointHeading( startGlobalPoint, endGlobalPoint, From 10172ce2fcf994bb6266f146e632e937f8083d65 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 1 Nov 2024 14:01:03 +0100 Subject: [PATCH 066/283] More stable midpoint heading selection --- packages/excalidraw/element/elbowarrow.ts | 41 ++++++++++++++++------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index f6cdba6bd330..28536bb5f2d6 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -19,6 +19,7 @@ import { toBrandedType, tupleToCoors, } from "../utils"; +import { debugDrawBounds, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -167,15 +168,29 @@ export const updateElbowArrowPoints = ( let heading = segment.heading; let anchor = segment.anchor; - heading = headingIsHorizontal(heading) - ? previousFixedSegment.point[0] > anchor[0] - ? HEADING_RIGHT - : HEADING_LEFT - : previousFixedSegment.point[1] > anchor[1] - ? HEADING_DOWN - : HEADING_UP; + anchor = pointFrom( + headingIsHorizontal(heading) + ? (startDonglePosition[0] + endDonglePosition[0]) / 2 + : anchor[0], + headingIsVertical(heading) + ? anchor[1] + : (startDonglePosition[1] + endDonglePosition[1]) / 2, + ); + heading = vectorToHeading( + vectorFromPoint( + anchor, + pointFrom( + headingIsVertical(heading) ? startDonglePosition[0] : anchor[0], + headingIsHorizontal(heading) ? anchor[1] : startDonglePosition[1], + ), + ), + ); nextFixedSegments[segmentIdx].heading = heading; + debugDrawPoint(startDonglePosition, { color: "green", permanent: false }); + debugDrawPoint(endDonglePosition, { color: "red", permanent: false }); + debugDrawPoint(anchor, { color: "blue", permanent: false }); + // Allow shifting the focus point of both fixed segment borders // are far away in one direction, creating a "valley" otherwise const prevRef = references[segmentIdx]; @@ -491,7 +506,7 @@ const getElbowArrowData = ( : startPointBounds, ); const commonBounds = commonAABB( - boundsOverlap + boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) ? [startPointBounds, endPointBounds] : [startElementBounds, endElementBounds], ); @@ -503,7 +518,7 @@ const getElbowArrowData = ( hoveredStartElement!.x + hoveredStartElement!.width / 2 + 1, hoveredStartElement!.y + hoveredStartElement!.height / 2 + 1, ] as Bounds) - : boundsOverlap + : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) ? startPointBounds : startElementBounds, options?.endIsMidPoint @@ -513,13 +528,13 @@ const getElbowArrowData = ( hoveredEndElement!.x + hoveredEndElement!.width / 2 + 1, hoveredEndElement!.y + hoveredEndElement!.height / 2 + 1, ] as Bounds) - : boundsOverlap + : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) ? endPointBounds : endElementBounds, commonBounds, options?.startIsMidPoint ? [0, 0, 0, 0] - : boundsOverlap + : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) ? offsetFromHeading( startHeading, !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, @@ -537,7 +552,7 @@ const getElbowArrowData = ( ), options?.endIsMidPoint ? [0, 0, 0, 0] - : boundsOverlap + : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) ? offsetFromHeading( endHeading, !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, @@ -553,7 +568,7 @@ const getElbowArrowData = ( : FIXED_BINDING_DISTANCE * 2), BASE_PADDING, ), - boundsOverlap, + boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint), hoveredStartElement && aabbForElement(hoveredStartElement), hoveredEndElement && aabbForElement(hoveredEndElement), options?.endIsMidPoint, From ce2b540a1c4623658ef9c918f16ffa250086649a Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 1 Nov 2024 16:09:11 +0100 Subject: [PATCH 067/283] Fix lint Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 28536bb5f2d6..c9856e6d9d24 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -19,7 +19,6 @@ import { toBrandedType, tupleToCoors, } from "../utils"; -import { debugDrawBounds, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -187,10 +186,6 @@ export const updateElbowArrowPoints = ( ); nextFixedSegments[segmentIdx].heading = heading; - debugDrawPoint(startDonglePosition, { color: "green", permanent: false }); - debugDrawPoint(endDonglePosition, { color: "red", permanent: false }); - debugDrawPoint(anchor, { color: "blue", permanent: false }); - // Allow shifting the focus point of both fixed segment borders // are far away in one direction, creating a "valley" otherwise const prevRef = references[segmentIdx]; From c5914a5a38d9d21a87eea4aee0dd4fc5949b3566 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 1 Nov 2024 16:11:09 +0100 Subject: [PATCH 068/283] More fixes --- packages/excalidraw/element/elbowarrow.ts | 87 ++++++++++++----------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 28536bb5f2d6..176b29d0a8e5 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -138,17 +138,8 @@ export const updateElbowArrowPoints = ( updates?.fixedSegments ?? [], ); - const { startDonglePosition, endDonglePosition } = getElbowArrowData( - arrow, - elementsMap, - updatedPoints, - ); - - const references: GlobalPoint[] = [startDonglePosition]; - nextFixedSegments.forEach((segment) => { - references.push(segment.anchor); - }); - references.push(endDonglePosition); + const { startDonglePosition, endDonglePosition, startGlobalPoint } = + getElbowArrowData(arrow, elementsMap, updatedPoints); let previousFixedSegment: { point: GlobalPoint; @@ -168,14 +159,6 @@ export const updateElbowArrowPoints = ( let heading = segment.heading; let anchor = segment.anchor; - anchor = pointFrom( - headingIsHorizontal(heading) - ? (startDonglePosition[0] + endDonglePosition[0]) / 2 - : anchor[0], - headingIsVertical(heading) - ? anchor[1] - : (startDonglePosition[1] + endDonglePosition[1]) / 2, - ); heading = vectorToHeading( vectorFromPoint( anchor, @@ -185,31 +168,44 @@ export const updateElbowArrowPoints = ( ), ), ); - nextFixedSegments[segmentIdx].heading = heading; - debugDrawPoint(startDonglePosition, { color: "green", permanent: false }); - debugDrawPoint(endDonglePosition, { color: "red", permanent: false }); - debugDrawPoint(anchor, { color: "blue", permanent: false }); - - // Allow shifting the focus point of both fixed segment borders - // are far away in one direction, creating a "valley" otherwise - const prevRef = references[segmentIdx]; - const nextRef = references[segmentIdx + 2]; - - if (prevRef && nextRef) { - if (headingIsVertical(heading)) { - anchor = pointFrom( - anchor[0], - (prevRef[1] + nextRef[1]) / 2, - ); - } else { - anchor = pointFrom( - (prevRef[0] + nextRef[0]) / 2, - anchor[1], - ); - } - nextFixedSegments[segmentIdx].anchor = anchor; + if ( + (headingIsHorizontal(heading) && + startDonglePosition[0] === endDonglePosition[0] && + Math.abs(anchor[0] - startGlobalPoint[0]) > 26.5) || + (headingIsVertical(heading) && + startDonglePosition[1] === endDonglePosition[1] && + Math.abs(anchor[1] - startGlobalPoint[1]) > 26.5) + ) { + heading = vectorToHeading( + vectorFromPoint( + pointFrom( + headingIsVertical(heading) ? anchor[0] : startGlobalPoint[0], + headingIsHorizontal(heading) ? anchor[1] : startGlobalPoint[1], + ), + anchor, + ), + ); } + nextFixedSegments[segmentIdx].heading = heading; + + anchor = pointFrom( + headingIsHorizontal(heading) + ? (startDonglePosition[0] + endDonglePosition[0]) / 2 + : anchor[0], + headingIsVertical(heading) + ? (startDonglePosition[1] + endDonglePosition[1]) / 2 + : anchor[1], + ); + + // debugDrawPoint(startDonglePosition, { color: "green", permanent: true }); + // debugDrawPoint(endDonglePosition, { color: "red", permanent: true }); + // debugDrawPoint(startGlobalPoint, { color: "blue", permanent: true }); + //debugDrawPoint(endGlobalPoint, { color: "blue", permanent: true }); + // debugDrawPoint(anchor, { color: "black", permanent: false }); + + nextFixedSegments[segmentIdx].anchor = anchor; + const el = { ...newElement({ type: "rectangle", @@ -585,6 +581,11 @@ const getElbowArrowData = ( endGlobalPoint, ); + // options?.endIsMidPoint && + // debugDrawBounds(dynamicAABBs[0], { color: "green", permanent: true }); + // options?.endIsMidPoint && + // debugDrawBounds(dynamicAABBs[1], { color: "red", permanent: true }); + return { dynamicAABBs, startDonglePosition, @@ -1425,7 +1426,7 @@ const getBindPointHeading = ( elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, hoveredElement: ExcalidrawBindableElement | null | undefined, origPoint: GlobalPoint, -) => +): Heading => getHeadingForElbowArrowSnap( p, otherPoint, From 5c0b9f5d59844e67251f4fd3116e3f091d751a93 Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Wed, 30 Oct 2024 13:40:24 +0200 Subject: [PATCH 069/283] fix: load font faces in Safari manually (#8693) --- packages/excalidraw/components/App.tsx | 23 +- packages/excalidraw/constants.ts | 1 + packages/excalidraw/fonts/Fonts.ts | 320 +++++++++++++++++++------ packages/excalidraw/scene/export.ts | 128 +--------- 4 files changed, 267 insertions(+), 205 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 68e69b383796..3f7f9f601bd7 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -49,7 +49,7 @@ import { } from "../appState"; import type { PastedMixedContent } from "../clipboard"; import { copyTextToSystemClipboard, parseClipboard } from "../clipboard"; -import { ARROW_TYPE, type EXPORT_IMAGE_TYPES } from "../constants"; +import { ARROW_TYPE, isSafari, type EXPORT_IMAGE_TYPES } from "../constants"; import { APP_NAME, CURSOR_TYPE, @@ -2322,11 +2322,11 @@ class App extends React.Component { // clear the shape and image cache so that any images in initialData // can be loaded fresh this.clearImageShapeCache(); - // FontFaceSet loadingdone event we listen on may not always - // fire (looking at you Safari), so on init we manually load all - // fonts and rerender scene text elements once done. This also - // seems faster even in browsers that do fire the loadingdone event. - this.fonts.loadSceneFonts(); + + // manually loading the font faces seems faster even in browsers that do fire the loadingdone event + this.fonts.loadSceneFonts().then((fontFaces) => { + this.fonts.onLoaded(fontFaces); + }); }; private isMobileBreakpoint = (width: number, height: number) => { @@ -2569,8 +2569,8 @@ class App extends React.Component { ), // rerender text elements on font load to fix #637 && #1553 addEventListener(document.fonts, "loadingdone", (event) => { - const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces; - this.fonts.onLoaded(loadedFontFaces); + const fontFaces = (event as FontFaceSetLoadEvent).fontfaces; + this.fonts.onLoaded(fontFaces); }), // Safari-only desktop pinch zoom addEventListener( @@ -3252,6 +3252,13 @@ class App extends React.Component { } }); + // paste event may not fire FontFace loadingdone event in Safari, hence loading font faces manually + if (isSafari) { + Fonts.loadElementsFonts(newElements).then((fontFaces) => { + this.fonts.onLoaded(fontFaces); + }); + } + if (opts.files) { this.addMissingFiles(opts.files); } diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 341316d12a95..6bd8f1e99f8b 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -2,6 +2,7 @@ import cssVariables from "./css/variables.module.scss"; import type { AppProps, AppState } from "./types"; import type { ExcalidrawElement, FontFamilyValues } from "./element/types"; import { COLOR_PALETTE } from "./colors"; + export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform); export const isAndroid = /\b(android)\b/i.test(navigator.userAgent); diff --git a/packages/excalidraw/fonts/Fonts.ts b/packages/excalidraw/fonts/Fonts.ts index 46b0f63c3dcf..31b5ad000dd2 100644 --- a/packages/excalidraw/fonts/Fonts.ts +++ b/packages/excalidraw/fonts/Fonts.ts @@ -3,11 +3,17 @@ import { FONT_FAMILY_FALLBACKS, CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT, + isSafari, + getFontFamilyFallbacks, } from "../constants"; import { isTextElement } from "../element"; -import { charWidth, getContainerElement } from "../element/textElement"; +import { + charWidth, + containsCJK, + getContainerElement, +} from "../element/textElement"; import { ShapeCache } from "../scene/ShapeCache"; -import { getFontString } from "../utils"; +import { getFontString, PromisePool, promiseTry } from "../utils"; import { ExcalidrawFontFace } from "./ExcalidrawFontFace"; import { CascadiaFontFaces } from "./Cascadia"; @@ -73,6 +79,13 @@ export class Fonts { this.scene = scene; } + /** + * Get all the font families for the given scene. + */ + public getSceneFamilies = () => { + return Fonts.getUniqueFamilies(this.scene.getNonDeletedElements()); + }; + /** * if we load a (new) font, it's likely that text elements using it have * already been rendered using a fallback font. Thus, we want invalidate @@ -81,7 +94,7 @@ export class Fonts { * Invalidates text elements and rerenders scene, provided that at least one * of the supplied fontFaces has not already been processed. */ - public onLoaded = (fontFaces: readonly FontFace[]) => { + public onLoaded = (fontFaces: readonly FontFace[]): void => { // bail if all fonts with have been processed. We're checking just a // subset of the font properties (though it should be enough), so it // can technically bail on a false positive. @@ -127,12 +140,40 @@ export class Fonts { /** * Load font faces for a given scene and trigger scene update. + * + * FontFaceSet loadingdone event we listen on may not always + * fire (looking at you Safari), so on init we manually load all + * fonts and rerender scene text elements once done. + * + * For Safari we make sure to check against each loaded font face + * with the unique characters per family in the scene, + * otherwise fonts might remain unloaded. */ public loadSceneFonts = async (): Promise => { const sceneFamilies = this.getSceneFamilies(); - const loaded = await Fonts.loadFontFaces(sceneFamilies); - this.onLoaded(loaded); - return loaded; + const charsPerFamily = isSafari + ? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements()) + : undefined; + + return Fonts.loadFontFaces(sceneFamilies, charsPerFamily); + }; + + /** + * Load font faces for passed elements - use when the scene is unavailable (i.e. export). + * + * For Safari we make sure to check against each loaded font face, + * with the unique characters per family in the elements + * otherwise fonts might remain unloaded. + */ + public static loadElementsFonts = async ( + elements: readonly ExcalidrawElement[], + ): Promise => { + const fontFamilies = Fonts.getUniqueFamilies(elements); + const charsPerFamily = isSafari + ? Fonts.getCharsPerFamily(elements) + : undefined; + + return Fonts.loadFontFaces(fontFamilies, charsPerFamily); }; /** @@ -144,17 +185,48 @@ export class Fonts { }; /** - * Load font faces for passed elements - use when the scene is unavailable (i.e. export). + * Generate CSS @font-face declarations for the given elements. */ - public static loadElementsFonts = async ( + public static async generateFontFaceDeclarations( elements: readonly ExcalidrawElement[], - ): Promise => { - const fontFamilies = Fonts.getElementsFamilies(elements); - return await Fonts.loadFontFaces(fontFamilies); - }; + ) { + const families = Fonts.getUniqueFamilies(elements); + const charsPerFamily = Fonts.getCharsPerFamily(elements); + + // for simplicity, assuming we have just one family with the CJK handdrawn fallback + const familyWithCJK = families.find((x) => + getFontFamilyFallbacks(x).includes(CJK_HAND_DRAWN_FALLBACK_FONT), + ); + + if (familyWithCJK) { + const characters = Fonts.getCharacters(charsPerFamily, familyWithCJK); + + if (containsCJK(characters)) { + const family = FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT]; + + // adding the same characters to the CJK handrawn family + charsPerFamily[family] = new Set(characters); + + // the order between the families and fallbacks is important, as fallbacks need to be defined first and in the reversed order + // so that they get overriden with the later defined font faces, i.e. in case they share some codepoints + families.unshift(FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT]); + } + } + + // don't trigger hundreds of concurrent requests (each performing fetch, creating a worker, etc.), + // instead go three requests at a time, in a controlled manner, without completely blocking the main thread + // and avoiding potential issues such as rate limits + const iterator = Fonts.fontFacesStylesGenerator(families, charsPerFamily); + const concurrency = 3; + const fontFaces = await new PromisePool(iterator, concurrency).all(); + + // dedup just in case (i.e. could be the same font faces with 0 glyphs) + return Array.from(new Set(fontFaces)); + } private static async loadFontFaces( fontFamilies: Array, + charsPerFamily?: Record>, ) { // add all registered font faces into the `document.fonts` (if not added already) for (const { fontFaces, metadata } of Fonts.registered.values()) { @@ -170,81 +242,96 @@ export class Fonts { } } - const loadedFontFaces = await Promise.all( - fontFamilies.map(async (fontFamily) => { - const fontString = getFontString({ - fontFamily, - fontSize: 16, - }); + // loading 10 font faces at a time, in a controlled manner + const iterator = Fonts.fontFacesLoader(fontFamilies, charsPerFamily); + const concurrency = 10; + const fontFaces = await new PromisePool(iterator, concurrency).all(); + return fontFaces.flat().filter(Boolean); + } + + private static *fontFacesLoader( + fontFamilies: Array, + charsPerFamily?: Record>, + ): Generator> { + for (const [index, fontFamily] of fontFamilies.entries()) { + const font = getFontString({ + fontFamily, + fontSize: 16, + }); + + // WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one! + // for Safari on init, we rather check with the "text" param, even though it's less efficient, as otherwise fonts might remain unloaded + const text = + isSafari && charsPerFamily + ? Fonts.getCharacters(charsPerFamily, fontFamily) + : ""; - // WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one! - if (!window.document.fonts.check(fontString)) { + if (!window.document.fonts.check(font, text)) { + yield promiseTry(async () => { try { // WARN: browser prioritizes loading only font faces with unicode ranges for characters which are present in the document (html & canvas), other font faces could stay unloaded // we might want to retry here, i.e. in case CDN is down, but so far I didn't experience any issues - maybe it handles retry-like logic under the hood - return await window.document.fonts.load(fontString); + const fontFaces = await window.document.fonts.load(font, text); + + return [index, fontFaces]; } catch (e) { // don't let it all fail if just one font fails to load console.error( - `Failed to load font "${fontString}" from urls "${Fonts.registered + `Failed to load font "${font}" from urls "${Fonts.registered .get(fontFamily) ?.fontFaces.map((x) => x.urls)}"`, e, ); } - } - - return Promise.resolve(); - }), - ); - - return loadedFontFaces.flat().filter(Boolean) as FontFace[]; + }); + } + } } - /** - * WARN: should be called just once on init, even across multiple instances. - */ - private static init() { - const fonts = { - registered: new Map< - ValueOf, - { metadata: FontMetadata; fontFaces: ExcalidrawFontFace[] } - >(), - }; - - const init = ( - family: keyof typeof FONT_FAMILY | keyof typeof FONT_FAMILY_FALLBACKS, - ...fontFacesDescriptors: ExcalidrawFontFaceDescriptor[] - ) => { - const fontFamily = - FONT_FAMILY[family as keyof typeof FONT_FAMILY] ?? - FONT_FAMILY_FALLBACKS[family as keyof typeof FONT_FAMILY_FALLBACKS]; - - // default to Excalifont metrics - const metadata = - FONT_METADATA[fontFamily] ?? FONT_METADATA[FONT_FAMILY.Excalifont]; + private static *fontFacesStylesGenerator( + families: Array, + charsPerFamily: Record>, + ): Generator> { + for (const [familyIndex, family] of families.entries()) { + const { fontFaces, metadata } = Fonts.registered.get(family) ?? {}; + + if (!Array.isArray(fontFaces)) { + console.error( + `Couldn't find registered fonts for font-family "${family}"`, + Fonts.registered, + ); + continue; + } - Fonts.register.call(fonts, family, metadata, ...fontFacesDescriptors); - }; + if (metadata?.local) { + // don't inline local fonts + continue; + } - init("Cascadia", ...CascadiaFontFaces); - init("Comic Shanns", ...ComicShannsFontFaces); - init("Excalifont", ...ExcalifontFontFaces); - // keeping for backwards compatibility reasons, uses system font (Helvetica on MacOS, Arial on Win) - init("Helvetica", ...HelveticaFontFaces); - // used for server-side pdf & png export instead of helvetica (technically does not need metrics, but kept in for consistency) - init("Liberation Sans", ...LiberationFontFaces); - init("Lilita One", ...LilitaFontFaces); - init("Nunito", ...NunitoFontFaces); - init("Virgil", ...VirgilFontFaces); + for (const [fontFaceIndex, fontFace] of fontFaces.entries()) { + yield promiseTry(async () => { + try { + const characters = Fonts.getCharacters(charsPerFamily, family); + const fontFaceCSS = await fontFace.toCSS(characters); - // fallback font faces - init(CJK_HAND_DRAWN_FALLBACK_FONT, ...XiaolaiFontFaces); - init(WINDOWS_EMOJI_FALLBACK_FONT, ...EmojiFontFaces); + if (!fontFaceCSS) { + return; + } - Fonts._initialized = true; + // giving a buffer of 10K font faces per family + const fontFaceOrder = familyIndex * 10_000 + fontFaceIndex; + const fontFaceTuple = [fontFaceOrder, fontFaceCSS] as const; - return fonts.registered; + return fontFaceTuple; + } catch (error) { + console.error( + `Couldn't transform font-face to css for family "${fontFace.fontFace.family}"`, + error, + ); + } + }); + } + } } /** @@ -288,17 +375,55 @@ export class Fonts { } /** - * Gets all the font families for the given scene. + * WARN: should be called just once on init, even across multiple instances. */ - public getSceneFamilies = () => { - return Fonts.getElementsFamilies(this.scene.getNonDeletedElements()); - }; + private static init() { + const fonts = { + registered: new Map< + ValueOf, + { metadata: FontMetadata; fontFaces: ExcalidrawFontFace[] } + >(), + }; - private static getAllFamilies() { - return Array.from(Fonts.registered.keys()); + const init = ( + family: keyof typeof FONT_FAMILY | keyof typeof FONT_FAMILY_FALLBACKS, + ...fontFacesDescriptors: ExcalidrawFontFaceDescriptor[] + ) => { + const fontFamily = + FONT_FAMILY[family as keyof typeof FONT_FAMILY] ?? + FONT_FAMILY_FALLBACKS[family as keyof typeof FONT_FAMILY_FALLBACKS]; + + // default to Excalifont metrics + const metadata = + FONT_METADATA[fontFamily] ?? FONT_METADATA[FONT_FAMILY.Excalifont]; + + Fonts.register.call(fonts, family, metadata, ...fontFacesDescriptors); + }; + + init("Cascadia", ...CascadiaFontFaces); + init("Comic Shanns", ...ComicShannsFontFaces); + init("Excalifont", ...ExcalifontFontFaces); + // keeping for backwards compatibility reasons, uses system font (Helvetica on MacOS, Arial on Win) + init("Helvetica", ...HelveticaFontFaces); + // used for server-side pdf & png export instead of helvetica (technically does not need metrics, but kept in for consistency) + init("Liberation Sans", ...LiberationFontFaces); + init("Lilita One", ...LilitaFontFaces); + init("Nunito", ...NunitoFontFaces); + init("Virgil", ...VirgilFontFaces); + + // fallback font faces + init(CJK_HAND_DRAWN_FALLBACK_FONT, ...XiaolaiFontFaces); + init(WINDOWS_EMOJI_FALLBACK_FONT, ...EmojiFontFaces); + + Fonts._initialized = true; + + return fonts.registered; } - private static getElementsFamilies( + /** + * Get all the unique font families for the given elements. + */ + private static getUniqueFamilies( elements: ReadonlyArray, ): Array { return Array.from( @@ -310,6 +435,51 @@ export class Fonts { }, new Set()), ); } + + /** + * Get all the unique characters per font family for the given scene. + */ + private static getCharsPerFamily( + elements: ReadonlyArray, + ): Record> { + const charsPerFamily: Record> = {}; + + for (const element of elements) { + if (!isTextElement(element)) { + continue; + } + + // gather unique codepoints only when inlining fonts + for (const char of element.originalText) { + if (!charsPerFamily[element.fontFamily]) { + charsPerFamily[element.fontFamily] = new Set(); + } + + charsPerFamily[element.fontFamily].add(char); + } + } + + return charsPerFamily; + } + + /** + * Get characters for a given family. + */ + private static getCharacters( + charsPerFamily: Record>, + family: number, + ) { + return charsPerFamily[family] + ? Array.from(charsPerFamily[family]).join("") + : ""; + } + + /** + * Get all registered font families. + */ + private static getAllFamilies() { + return Array.from(Fonts.registered.keys()); + } } /** diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 43e737be56b2..c4ab1b8657e0 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -9,14 +9,7 @@ import type { import type { Bounds } from "../element/bounds"; import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds"; import { renderSceneToSvg } from "../renderer/staticSvgScene"; -import { - arrayToMap, - distance, - getFontString, - PromisePool, - promiseTry, - toBrandedType, -} from "../utils"; +import { arrayToMap, distance, getFontString, toBrandedType } from "../utils"; import type { AppState, BinaryFiles } from "../types"; import { DEFAULT_EXPORT_PADDING, @@ -25,9 +18,6 @@ import { SVG_NS, THEME, THEME_FILTER, - FONT_FAMILY_FALLBACKS, - getFontFamilyFallbacks, - CJK_HAND_DRAWN_FALLBACK_FONT, } from "../constants"; import { getDefaultAppState } from "../appState"; import { serializeAsJSON } from "../data/json"; @@ -44,12 +34,11 @@ import { import { newTextElement } from "../element"; import { type Mutable } from "../utility-types"; import { newElementWith } from "../element/mutateElement"; -import { isFrameLikeElement, isTextElement } from "../element/typeChecks"; +import { isFrameLikeElement } from "../element/typeChecks"; import type { RenderableElementsMap } from "./types"; import { syncInvalidIndices } from "../fractionalIndex"; import { renderStaticScene } from "../renderer/staticScene"; import { Fonts } from "../fonts"; -import { containsCJK } from "../element/textElement"; const SVG_EXPORT_TAG = ``; @@ -375,7 +364,10 @@ export const exportToSvg = async ( `; } - const fontFaces = opts?.skipInliningFonts ? [] : await getFontFaces(elements); + const fontFaces = !opts?.skipInliningFonts + ? await Fonts.generateFontFaceDeclarations(elements) + : []; + const delimiter = "\n "; // 6 spaces svgRoot.innerHTML = ` @@ -454,111 +446,3 @@ export const getExportSize = ( return [width, height]; }; - -const getFontFaces = async ( - elements: readonly ExcalidrawElement[], -): Promise => { - const fontFamilies = new Set(); - const charsPerFamily: Record> = {}; - - for (const element of elements) { - if (!isTextElement(element)) { - continue; - } - - fontFamilies.add(element.fontFamily); - - // gather unique codepoints only when inlining fonts - for (const char of element.originalText) { - if (!charsPerFamily[element.fontFamily]) { - charsPerFamily[element.fontFamily] = new Set(); - } - - charsPerFamily[element.fontFamily].add(char); - } - } - - const orderedFamilies = Array.from(fontFamilies); - - // for simplicity, assuming we have just one family with the CJK handdrawn fallback - const familyWithCJK = orderedFamilies.find((x) => - getFontFamilyFallbacks(x).includes(CJK_HAND_DRAWN_FALLBACK_FONT), - ); - - if (familyWithCJK) { - const characters = getChars(charsPerFamily[familyWithCJK]); - - if (containsCJK(characters)) { - const family = FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT]; - - // adding the same characters to the CJK handrawn family - charsPerFamily[family] = new Set(characters); - - // the order between the families and fallbacks is important, as fallbacks need to be defined first and in the reversed order - // so that they get overriden with the later defined font faces, i.e. in case they share some codepoints - orderedFamilies.unshift( - FONT_FAMILY_FALLBACKS[CJK_HAND_DRAWN_FALLBACK_FONT], - ); - } - } - - const iterator = fontFacesIterator(orderedFamilies, charsPerFamily); - - // don't trigger hundreds of concurrent requests (each performing fetch, creating a worker, etc.), - // instead go three requests at a time, in a controlled manner, without completely blocking the main thread - // and avoiding potential issues such as rate limits - const concurrency = 3; - const fontFaces = await new PromisePool(iterator, concurrency).all(); - - // dedup just in case (i.e. could be the same font faces with 0 glyphs) - return Array.from(new Set(fontFaces)); -}; - -function* fontFacesIterator( - families: Array, - charsPerFamily: Record>, -): Generator> { - for (const [familyIndex, family] of families.entries()) { - const { fontFaces, metadata } = Fonts.registered.get(family) ?? {}; - - if (!Array.isArray(fontFaces)) { - console.error( - `Couldn't find registered fonts for font-family "${family}"`, - Fonts.registered, - ); - continue; - } - - if (metadata?.local) { - // don't inline local fonts - continue; - } - - for (const [fontFaceIndex, fontFace] of fontFaces.entries()) { - yield promiseTry(async () => { - try { - const characters = getChars(charsPerFamily[family]); - const fontFaceCSS = await fontFace.toCSS(characters); - - if (!fontFaceCSS) { - return; - } - - // giving a buffer of 10K font faces per family - const fontFaceOrder = familyIndex * 10_000 + fontFaceIndex; - const fontFaceTuple = [fontFaceOrder, fontFaceCSS] as const; - - return fontFaceTuple; - } catch (error) { - console.error( - `Couldn't transform font-face to css for family "${fontFace.fontFace.family}"`, - error, - ); - } - }); - } - } -} - -const getChars = (characterSet: Set) => - Array.from(characterSet).join(""); From 949f2dd114c7510f37f14632c9b46dcbf52729ec Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Wed, 30 Oct 2024 15:11:13 +0200 Subject: [PATCH 070/283] fix: fix trailing line whitespaces layout shift (#8714) --- .../excalidraw/element/textElement.test.ts | 35 ++++++++++++++++++ packages/excalidraw/element/textElement.ts | 37 ++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/element/textElement.test.ts b/packages/excalidraw/element/textElement.test.ts index 59727c22ff13..6275b762e368 100644 --- a/packages/excalidraw/element/textElement.test.ts +++ b/packages/excalidraw/element/textElement.test.ts @@ -47,6 +47,41 @@ describe("Test wrapText", () => { 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; diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 7618dba80460..9d72961b5086 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -681,7 +681,7 @@ const wrapLine = ( lines.push(...precedingLines); - // trailing line of the wrapped word might still be joined with next token/s + // trailing line of the wrapped word might -still be joined with next token/s currentLine = trailingLine; currentLineWidth = getLineWidth(trailingLine, font, true); iterator = tokenIterator.next(); @@ -697,12 +697,45 @@ const wrapLine = ( // iterator done, push the trailing line if exists if (currentLine) { - lines.push(currentLine.trimEnd()); + const trailingLine = trimTrailingLine(currentLine, font, maxWidth); + lines.push(trailingLine); } return lines; }; +// similarly to browsers, does not trim all whitespaces, but only those exceeding the maxWidth +const trimTrailingLine = (line: string, font: FontString, maxWidth: number) => { + const shouldTrimWhitespaces = getLineWidth(line, font, true) > 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, true); + + 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; +}; + export const wrapText = ( text: string, font: FontString, From 2664b300d5664d2c76ea61c24ffac2d23e320371 Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Wed, 30 Oct 2024 15:24:12 +0200 Subject: [PATCH 071/283] chore: simplify line-break regexes, separate text wrapping (#8715) --- packages/excalidraw/components/App.tsx | 2 +- packages/excalidraw/element/embeddable.ts | 2 +- packages/excalidraw/element/newElement.ts | 2 +- packages/excalidraw/element/resizeElements.ts | 2 +- .../excalidraw/element/textElement.test.ts | 671 +----------------- packages/excalidraw/element/textElement.ts | 361 +--------- .../excalidraw/element/textWrapping.test.ts | 633 +++++++++++++++++ packages/excalidraw/element/textWrapping.ts | 568 +++++++++++++++ packages/excalidraw/element/textWysiwyg.tsx | 2 +- packages/excalidraw/fonts/Fonts.ts | 7 +- .../tests/linearElementEditor.test.tsx | 2 +- 11 files changed, 1213 insertions(+), 1039 deletions(-) create mode 100644 packages/excalidraw/element/textWrapping.test.ts create mode 100644 packages/excalidraw/element/textWrapping.ts diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 3f7f9f601bd7..de630c212c22 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -342,7 +342,6 @@ import { isValidTextContainer, measureText, normalizeText, - wrapText, } from "../element/textElement"; import { showHyperlinkTooltip, @@ -463,6 +462,7 @@ import { vectorNormalize, } from "../../math"; import { cropElement } from "../element/cropElement"; +import { wrapText } from "../element/textWrapping"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts index eada31a5be23..9bc4a139b1c0 100644 --- a/packages/excalidraw/element/embeddable.ts +++ b/packages/excalidraw/element/embeddable.ts @@ -4,7 +4,7 @@ import type { ExcalidrawProps } from "../types"; import { getFontString, updateActiveTool } from "../utils"; import { setCursorForShape } from "../cursor"; import { newTextElement } from "./newElement"; -import { wrapText } from "./textElement"; +import { wrapText } from "./textWrapping"; import { isIframeElement } from "./typeChecks"; import type { ExcalidrawElement, diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 1e87f1898c0d..23a2749a3c1d 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -35,9 +35,9 @@ import { getResizedElementAbsoluteCoords } from "./bounds"; import { measureText, normalizeText, - wrapText, getBoundTextMaxWidth, } from "./textElement"; +import { wrapText } from "./textWrapping"; import { DEFAULT_ELEMENT_PROPS, DEFAULT_FONT_FAMILY, diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 17a1c2a7eea5..a91b015c47c8 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -53,10 +53,10 @@ import { handleBindTextResize, getBoundTextMaxWidth, getApproxMinLineHeight, - wrapText, measureText, getMinTextElementWidth, } from "./textElement"; +import { wrapText } from "./textWrapping"; import { LinearElementEditor } from "./linearElementEditor"; import { isInGroup } from "../groups"; import type { GlobalPoint } from "../../math"; diff --git a/packages/excalidraw/element/textElement.test.ts b/packages/excalidraw/element/textElement.test.ts index 6275b762e368..cfc078c81e26 100644 --- a/packages/excalidraw/element/textElement.test.ts +++ b/packages/excalidraw/element/textElement.test.ts @@ -1,4 +1,4 @@ -import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; +import { FONT_FAMILY } from "../constants"; import { getLineHeight } from "../fonts"; import { API } from "../tests/helpers/api"; import { @@ -6,677 +6,10 @@ import { getContainerCoords, getBoundTextMaxWidth, getBoundTextMaxHeight, - wrapText, detectLineHeight, getLineHeightInPx, - parseTokens, } from "./textElement"; -import type { ExcalidrawTextElementWithContainer, 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 = 200 - BOUND_TEXT_PADDING * 2; - 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: 80, - res: `Hello\nwhats\nup`, - }, - { - desc: "break all characters when width of each character is less than container width", - width: 25, - res: `H -e -l -l -o -w -h -a -t -s -u -p`, - }, - { - desc: "break words as per the width", - - width: 140, - res: `Hello whats\nup`, - }, - { - desc: "fit the container", - - width: 250, - res: "Hello whats up", - }, - { - desc: "should push the word if its equal to max width", - width: 60, - res: `Hello -whats -up`, - }, - ].forEach((data) => { - it(`should ${data.desc}`, () => { - const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); - expect(res).toEqual(data.res); - }); - }); - }); - - describe("When text contain new lines", () => { - const text = `Hello -whats up`; - [ - { - desc: "break all words when width of each word is less than container width", - width: 80, - res: `Hello\nwhats\nup`, - }, - { - desc: "break all characters when width of each character is less than container width", - width: 25, - res: `H -e -l -l -o -w -h -a -t -s -u -p`, - }, - { - desc: "break words as per the width", - - width: 150, - res: `Hello -whats up`, - }, - { - desc: "fit the container", - - width: 250, - res: `Hello -whats up`, - }, - ].forEach((data) => { - it(`should respect new lines and ${data.desc}`, () => { - const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); - 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: 170, - 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: 130, - res: `hellolongtex -tthisiswhats -upwithyouIam -typingggggan -dtypinggg -break it now`, - }, - { - desc: "fit the long text when container width is greater than text length and move the rest to next line", - - width: 600, - res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`, - }, - ].forEach((data) => { - it(`should ${data.desc}`, () => { - const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); - 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] World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`; - - // [ - // '《道', '德', '經》', '醫-', - // '醫', 'こ', 'ん', 'に', - // 'ち', 'は', '世', '界!', - // '안', '녕', '하', '세', - // '요', '세', '계;', '다.', - // '다...', '원/', '달', '(((다)))', - // '[[1]]', '〚({((한))>)〛', 'た…', '[Hello]', - // ' ', '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("で"); - 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("〚({((한))>)〛"); - - // Numbers and units - expect(tokens).toContain("¥3700.55"); - expect(tokens).toContain("090-"); - expect(tokens).toContain("1234-"); - expect(tokens).toContain("5678¥1,000"); - expect(tokens).toContain("$5,000"); - expect(tokens).toContain("1:"); - expect(tokens).toContain("30%"); - expect(tokens).toContain("¥110±"); - expect(tokens).toContain("¥570"); - expect(tokens).toContain("20℃"); - expect(tokens).toContain("9:30"); - expect(tokens).toContain("10:00"); - - // Punctuation and symbols - expect(tokens).toContain("〜"); - expect(tokens).toContain("〰"); - expect(tokens).toContain("#"); - }); - }); -}); +import type { ExcalidrawTextElementWithContainer } from "./types"; describe("Test measureText", () => { describe("Test getContainerCoords", () => { diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 9d72961b5086..8c4bc59882e0 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -16,12 +16,12 @@ import { BOUND_TEXT_PADDING, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, - ENV, 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"; @@ -31,172 +31,6 @@ import { } from "./containerCache"; import type { ExtractSetType } from "../utility-types"; -/** - * 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: ZWJ (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 do not understand the abbreviation (i.e. https://devina.io/redos-checker) - */ -const _EMOJI_CHAR = - /(\p{RI}\p{RI}|[\p{Extended_Pictographic}\p{Emoji_Presentation}](?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?(?:\u200D(?:\p{RI}\p{RI}|[\p{Emoji}](?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?))*)/u; - -/** - * Detect a CJK char, though does not include every possible char used in CJK texts, - * such as symbols and punctuations. - * - * By default every CJK is a breaking point, though CJK has additional breaking points, - * including full width punctuations or symbols (Chinese and Japanese) and western punctuations (Korean). - * - * Additional CJK breaking point rules: - * - expect a break before (lookahead), but not after (negative lookbehind), i.e. "(" or "(" - * - expect a break after (lookbehind), but not before (negative lookahead), i.e. ")" or ")" - * - expect a break always (lookahead and lookbehind), i.e. "〃" - */ -const _CJK_CHAR = - /\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}/u; - -/** - * Following characters break only with CJK, not with alphabetic characters. - * This is essential for Korean, as it uses alphabetic punctuation, but expects CJK-like breaking points. - * - * Hello((た)) → ["Hello", "((た))"] - * Hello((World)) → ["Hello((World))"] - */ -const _CJK_BREAK_NOT_AFTER_BUT_BEFORE = /<\(\[\{/u; -const _CJK_BREAK_NOT_BEFORE_BUT_AFTER = />\)\]\}.,:;\?!/u; -const _CJK_BREAK_ALWAYS = / 〃〜~〰#&*+-ー/=|¬ ̄¦/u; -const _CJK_SYMBOLS_AND_PUNCTUATION = - /()[]{}〈〉《》⦅⦆「」「」『』【】〖〗〔〕〘〙〚〛<>〝〞'〟・。゚゙,、.:;?!%ー/u; - -/** - * Following characters break with any character, even though are mostly used with CJK. - * - * Hello た。→ ["Hello", "た。"] - * ↑ DON'T BREAK "た。" (negative lookahead) - * Hello「た」 World → ["Hello", "「た」", "World"] - * ↑ DON'T BREAK "「た" (negative lookbehind) - * ↑ DON'T BREAK "た」"(negative lookahead) - * ↑ BREAK BEFORE "「" (lookahead) - * ↑ BREAK AFTER "」" (lookbehind) - */ -const _ANY_BREAK_NOT_AFTER_BUT_BEFORE = /([{〈《⦅「「『【〖〔〘〚<〝/u; -const _ANY_BREAK_NOT_BEFORE_BUT_AFTER = - /)]}〉》⦆」」』】〗〕〙〛>〞'〟・。゚゙,、.:;?!%±‥…\//u; - -/** - * Natural breaking points for any grammars. - * - * Hello-world - * ↑ BREAK AFTER "-" → ["Hello-", "world"] - * Hello world - * ↑ BREAK ALWAYS " " → ["Hello", " ", "world"] - */ -const _ANY_BREAK_AFTER = /-/u; -const _ANY_BREAK_ALWAYS = /\s/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 BREAK_LINE_REGEX_SIMPLE = new RegExp( - `${_EMOJI_CHAR.source}|([${_ANY_BREAK_ALWAYS.source}${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}${_ANY_BREAK_AFTER.source}])`, - "u", -); - -// Hello World → ["Hello", " World"] -// ↑ BREAK BEFORE " " -// HelloたWorld → ["Hello", "たWorld"] -// ↑ BREAK BEFORE "た" -// Hello「World」→ ["Hello", "「World」"] -// ↑ BREAK BEFORE "「" -const getLookaheadBreakingPoints = () => { - const ANY_BREAKING_POINT = `(? { - const ANY_BREAKING_POINT = `(?![${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}])(?<=[${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}${_ANY_BREAK_ALWAYS.source}${_ANY_BREAK_AFTER.source}])`; - const CJK_BREAKING_POINT = `(?![${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}${_CJK_BREAK_NOT_BEFORE_BUT_AFTER.source}${_ANY_BREAK_AFTER.source}])(?<=[${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}][${_CJK_BREAK_NOT_BEFORE_BUT_AFTER.source}]*)`; - return new RegExp(`(?:${ANY_BREAKING_POINT}|${CJK_BREAKING_POINT})`, "u"); -}; - -/** - * Break a line based on the whitespaces, CJK / emoji chars and language specific breaking points, - * like hyphen for alphabetic and various full-width codepoints for CJK - especially Japanese, e.g.: - * - * "Hello 世界。🌎🗺" → ["Hello", " ", "世", "界。", "🌎", "🗺"] - * "Hello-world" → ["Hello-", "world"] - * "「Hello World」" → ["「Hello", " ", "World」"] - */ -const getBreakLineRegexAdvanced = () => - new RegExp( - `${_EMOJI_CHAR.source}|${getLookaheadBreakingPoints().source}|${ - getLookbehindBreakingPoints().source - }`, - "u", - ); - -let cachedBreakLineRegex: RegExp | undefined; - -// Lazy-load for browsers that don't support "Lookbehind assertion" -const getBreakLineRegex = () => { - if (!cachedBreakLineRegex) { - try { - cachedBreakLineRegex = getBreakLineRegexAdvanced(); - } catch { - cachedBreakLineRegex = BREAK_LINE_REGEX_SIMPLE; - } - } - - return cachedBreakLineRegex; -}; - -const CJK_REGEX = new RegExp( - `[${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}${_CJK_SYMBOLS_AND_PUNCTUATION.source}]`, - "u", -); - -const EMOJI_REGEX = new RegExp(`${_EMOJI_CHAR.source}`, "u"); - -export const containsCJK = (text: string) => { - return CJK_REGEX.test(text); -}; - -export const containsEmoji = (text: string) => { - return EMOJI_REGEX.test(text); -}; - export const normalizeText = (text: string) => { return ( normalizeEOL(text) @@ -510,7 +344,7 @@ let canvas: HTMLCanvasElement | undefined; * * `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies. */ -const getLineWidth = ( +export const getLineWidth = ( text: string, font: FontString, forceAdvanceWidth?: true, @@ -575,197 +409,6 @@ export const getTextHeight = ( return getLineHeightInPx(fontSize, lineHeight) * lineCount; }; -export const parseTokens = (line: string) => { - const breakLineRegex = getBreakLineRegex(); - - // 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); -}; - -// 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 - ); -}; - -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!"); - } - } -}; - -const wrapWord = ( - word: string, - font: FontString, - maxWidth: number, -): Array => { - // multi-codepoint emojis are already broken apart and shouldn't be broken further - if (EMOJI_REGEX.test(word)) { - return [word]; - } - - satisfiesWordInvariant(word); - - const lines: Array = []; - 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; -}; - -const wrapLine = ( - line: string, - font: FontString, - maxWidth: number, -): string[] => { - const lines: Array = []; - 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, true); - - // 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, true); - 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 = trimTrailingLine(currentLine, font, maxWidth); - lines.push(trailingLine); - } - - return lines; -}; - -// similarly to browsers, does not trim all whitespaces, but only those exceeding the maxWidth -const trimTrailingLine = (line: string, font: FontString, maxWidth: number) => { - const shouldTrimWhitespaces = getLineWidth(line, font, true) > 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, true); - - 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; -}; - -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 = []; - const originalLines = text.split("\n"); - - for (const originalLine of originalLines) { - const currentLineWidth = getLineWidth(originalLine, font, true); - - if (currentLineWidth <= maxWidth) { - lines.push(originalLine); - continue; - } - - const wrappedLine = wrapLine(originalLine, font, maxWidth); - lines.push(...wrappedLine); - } - - return lines.join("\n"); -}; - export const charWidth = (() => { const cachedCharWidth: { [key: FontString]: Array } = {}; diff --git a/packages/excalidraw/element/textWrapping.test.ts b/packages/excalidraw/element/textWrapping.test.ts new file mode 100644 index 000000000000..6c7bcb819d1b --- /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 000000000000..597f62e15166 --- /dev/null +++ b/packages/excalidraw/element/textWrapping.ts @@ -0,0 +1,568 @@ +import { ENV } from "../constants"; +import { charWidth, getLineWidth } from "./textElement"; +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, + "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, + "PreceededBy" + >; + }, + /** + * Break before one or multiple characters of the same class. + */ + BeforeMany: (...regexes: RegExp[]) => { + const joined = Regex.join(...regexes); + const builder = () => Regex.build(`(?, + "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, + "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, + "NotFollowedBy" + >; + }, + /** + * Do not break after the given class of characters. + */ + NotAfter: (...regexes: RegExp[]) => { + const joined = Regex.join(...regexes); + const builder = () => Regex.build(`(?, + "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, + "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, + "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, + "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, + "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 = []; + const originalLines = text.split("\n"); + + for (const originalLine of originalLines) { + const currentLineWidth = getLineWidth(originalLine, font, true); + + 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 = []; + 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, true); + + // 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, true); + 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 => { + // multi-codepoint emojis are already broken apart and shouldn't be broken further + if (getEmojiRegex().test(word)) { + return [word]; + } + + satisfiesWordInvariant(word); + + const lines: Array = []; + 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, true) > 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, true); + + 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.tsx b/packages/excalidraw/element/textWysiwyg.tsx index 23778cb7b5b2..5663af8f8b6e 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -27,13 +27,13 @@ import { getTextWidth, normalizeText, redrawTextBoundingBox, - wrapText, getBoundTextMaxHeight, getBoundTextMaxWidth, computeContainerDimensionForBoundText, computeBoundTextPosition, getBoundTextElement, } from "./textElement"; +import { wrapText } from "./textWrapping"; import { actionDecreaseFontSize, actionIncreaseFontSize, diff --git a/packages/excalidraw/fonts/Fonts.ts b/packages/excalidraw/fonts/Fonts.ts index 31b5ad000dd2..3e307e7daaaa 100644 --- a/packages/excalidraw/fonts/Fonts.ts +++ b/packages/excalidraw/fonts/Fonts.ts @@ -7,11 +7,8 @@ import { getFontFamilyFallbacks, } from "../constants"; import { isTextElement } from "../element"; -import { - charWidth, - containsCJK, - getContainerElement, -} from "../element/textElement"; +import { charWidth, getContainerElement } from "../element/textElement"; +import { containsCJK } from "../element/textWrapping"; import { ShapeCache } from "../scene/ShapeCache"; import { getFontString, PromisePool, promiseTry } from "../utils"; import { ExcalidrawFontFace } from "./ExcalidrawFontFace"; diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index 64c37f923bcb..2d6e2c2404a4 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -19,7 +19,6 @@ import { LinearElementEditor } from "../element/linearElementEditor"; import { act, queryByTestId, queryByText } from "@testing-library/react"; import { getBoundTextElementPosition, - wrapText, getBoundTextMaxWidth, } from "../element/textElement"; import * as textElementUtils from "../element/textElement"; @@ -28,6 +27,7 @@ import { vi } from "vitest"; import { arrayToMap } from "../utils"; import type { GlobalPoint } from "../../math"; import { pointCenter, pointFrom } from "../../math"; +import { wrapText } from "../element/textWrapping"; const renderInteractiveScene = vi.spyOn( InteractiveCanvas, From f784545330ee867a748c8ebab1e2eccdd458decc Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 1 Nov 2024 18:45:33 +0100 Subject: [PATCH 072/283] Refining horizontal fixed segments Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index d7d622af48bc..71d097ad592a 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -162,7 +162,7 @@ export const updateElbowArrowPoints = ( vectorFromPoint( anchor, pointFrom( - headingIsVertical(heading) ? startDonglePosition[0] : anchor[0], + headingIsVertical(heading) ? anchor[0] : startDonglePosition[0], headingIsHorizontal(heading) ? anchor[1] : startDonglePosition[1], ), ), @@ -185,6 +185,13 @@ export const updateElbowArrowPoints = ( anchor, ), ); + } else if ( + (headingIsHorizontal(heading) && + startDonglePosition[0] > endDonglePosition[0]) || + (headingIsVertical(heading) && + startDonglePosition[1] > endDonglePosition[1]) + ) { + heading = flipHeading(heading); } nextFixedSegments[segmentIdx].heading = heading; From c8bf645ae163fa186890ce5cf7226c1e9786eff9 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sun, 3 Nov 2024 21:50:59 +0100 Subject: [PATCH 073/283] Simpler approach to remove hooks Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 43 ++++++----------------- packages/math/point.ts | 5 ++- 2 files changed, 14 insertions(+), 34 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 71d097ad592a..9a088487b77d 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -1,4 +1,5 @@ import { + pointDistanceSq, pointFrom, pointScaleFromOrigin, pointTranslate, @@ -137,8 +138,11 @@ export const updateElbowArrowPoints = ( updates?.fixedSegments ?? [], ); - const { startDonglePosition, endDonglePosition, startGlobalPoint } = - getElbowArrowData(arrow, elementsMap, updatedPoints); + const { startDonglePosition, endDonglePosition } = getElbowArrowData( + arrow, + elementsMap, + updatedPoints, + ); let previousFixedSegment: { point: GlobalPoint; @@ -158,38 +162,11 @@ export const updateElbowArrowPoints = ( let heading = segment.heading; let anchor = segment.anchor; - heading = vectorToHeading( - vectorFromPoint( - anchor, - pointFrom( - headingIsVertical(heading) ? anchor[0] : startDonglePosition[0], - headingIsHorizontal(heading) ? anchor[1] : startDonglePosition[1], - ), - ), - ); - if ( - (headingIsHorizontal(heading) && - startDonglePosition[0] === endDonglePosition[0] && - Math.abs(anchor[0] - startGlobalPoint[0]) > 26.5) || - (headingIsVertical(heading) && - startDonglePosition[1] === endDonglePosition[1] && - Math.abs(anchor[1] - startGlobalPoint[1]) > 26.5) - ) { - heading = vectorToHeading( - vectorFromPoint( - pointFrom( - headingIsVertical(heading) ? anchor[0] : startGlobalPoint[0], - headingIsHorizontal(heading) ? anchor[1] : startGlobalPoint[1], - ), - anchor, - ), - ); - } else if ( - (headingIsHorizontal(heading) && - startDonglePosition[0] > endDonglePosition[0]) || - (headingIsVertical(heading) && - startDonglePosition[1] > endDonglePosition[1]) + pointDistanceSq( + updatedPoints[segment.index], + updatedPoints[segment.index - 1], + ) < 1.3 ) { heading = flipHeading(heading); } diff --git a/packages/math/point.ts b/packages/math/point.ts index 61de8f139e5a..9031cc78c0d0 100644 --- a/packages/math/point.ts +++ b/packages/math/point.ts @@ -217,7 +217,10 @@ export function pointDistanceSq

( a: P, b: P, ): number { - return Math.hypot(b[0] - a[0], b[1] - a[1]); + const xDiff = b[0] - a[0]; + const yDiff = b[1] - a[1]; + + return xDiff * xDiff + yDiff * yDiff; } /** From 7b3f607eaa36936b6ab96106dd2a5392e3ec3964 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 4 Nov 2024 10:51:20 +0100 Subject: [PATCH 074/283] Fix midpoint hit issue Signed-off-by: Mark Tolmacs --- packages/excalidraw/components/App.tsx | 22 +++++++++++++ packages/excalidraw/element/elbowarrow.ts | 17 ++++++---- .../excalidraw/renderer/interactiveScene.ts | 31 +++++++------------ 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index de630c212c22..0e6228e1af5b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7901,6 +7901,17 @@ class App extends React.Component { elementsMap, this.state, ); + const element = LinearElementEditor.getElement( + this.state.selectedLinearElement.elementId, + elementsMap, + ); + if (element) { + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + this.state, + ); + } if ( this.state.selectedLinearElement.pointerDownState.segmentMidpoint @@ -8422,6 +8433,17 @@ class App extends React.Component { ) : []; + if ( + elementsWithinSelection.length === 1 && + isLinearElement(elementsWithinSelection[0]) + ) { + LinearElementEditor.updateEditorMidPointsCache( + elementsWithinSelection[0], + elementsMap, + this.state, + ); + } + this.setState((prevState) => { const nextSelectedElementIds = { ...(shouldReuseSelection && prevState.selectedElementIds), diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 9a088487b77d..fbb7d6d241f0 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -95,7 +95,7 @@ const BASE_PADDING = 40; const segmentListMerge = ( oldFixedSegments: readonly FixedSegment[], newFixedSegments: readonly FixedSegment[], -): FixedSegment[] => { +): Sequential => { let fixedSegments = Array.from(oldFixedSegments); newFixedSegments.forEach((segment) => { if (segment.anchor == null) { @@ -113,7 +113,9 @@ const segmentListMerge = ( } }); - return fixedSegments.sort((a, b) => a.index - b.index); + return fixedSegments.sort( + (a, b) => a.index - b.index, + ) as Sequential; }; /** @@ -133,7 +135,7 @@ export const updateElbowArrowPoints = ( const updatedPoints = updates.points ?? arrow.points; const fakeElementsMap = toBrandedType(new Map(elementsMap)); - const nextFixedSegments = segmentListMerge( + let nextFixedSegments = segmentListMerge( arrow.fixedSegments ?? [], updates?.fixedSegments ?? [], ); @@ -166,7 +168,7 @@ export const updateElbowArrowPoints = ( pointDistanceSq( updatedPoints[segment.index], updatedPoints[segment.index - 1], - ) < 1.3 + ) <= 4 ) { heading = flipHeading(heading); } @@ -298,7 +300,7 @@ export const updateElbowArrowPoints = ( const simplifiedPointGroups = getElbowArrowCornerPoints(rawPointGroups); let currentGroupIdx = 0; - const nfs = multiDimensionalArrayDeepFlatMapper< + nextFixedSegments = multiDimensionalArrayDeepFlatMapper< GlobalPoint, FixedSegment | null >(simplifiedPointGroups, (point, [groupIdx], points, index) => { @@ -320,7 +322,10 @@ export const updateElbowArrowPoints = ( (segment): segment is FixedSegment => segment != null, ) as Sequential; - return normalizeArrowElementUpdate(simplifiedPointGroups.flat(), nfs); + return normalizeArrowElementUpdate( + simplifiedPointGroups.flat(), + nextFixedSegments, + ); }; /** diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 603cf32259f7..e6950e0e646f 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -518,26 +518,17 @@ const renderLinearPointHandles = ( pointFrom(element.x + p[0], element.y + p[1]), ); globalPoint.slice(1, -2).forEach((p, idx) => { - if ( - !LinearElementEditor.isSegmentTooShort( - element, - p, - element.points[idx + 2], - appState.zoom, - ) - ) { - renderSingleLinearPoint( - context, - appState, - pointFrom( - (p[0] + globalPoint[idx + 2][0]) / 2, - (p[1] + globalPoint[idx + 2][1]) / 2, - ), - POINT_HANDLE_SIZE / 2, - false, - !fixedPoints[idx + 1], - ); - } + renderSingleLinearPoint( + context, + appState, + pointFrom( + (p[0] + globalPoint[idx + 2][0]) / 2, + (p[1] + globalPoint[idx + 2][1]) / 2, + ), + POINT_HANDLE_SIZE / 2, + false, + !fixedPoints[idx + 1], + ); }); } else { const midPoints = LinearElementEditor.getEditorMidPoints( From df0cea689e206a1e66cc54d335fa1dad6d26213a Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 4 Nov 2024 13:38:13 +0100 Subject: [PATCH 075/283] Fix randomly jumping multi-fixed segments --- packages/excalidraw/element/elbowarrow.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index fbb7d6d241f0..87d099b12a9d 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -174,12 +174,26 @@ export const updateElbowArrowPoints = ( } nextFixedSegments[segmentIdx].heading = heading; + const prevSegment = nextFixedSegments[segmentIdx - 1]; + const nextSegment = nextFixedSegments[segmentIdx + 1]; + const candidateStart = prevSegment + ? pointFrom( + arrow.x + updatedPoints[prevSegment.index][0], + arrow.y + updatedPoints[prevSegment.index][1], + ) + : startDonglePosition; + const candidateEnd = nextSegment + ? pointFrom( + arrow.x + updatedPoints[nextSegment.index][0], + arrow.y + updatedPoints[nextSegment.index][1], + ) + : endDonglePosition; anchor = pointFrom( headingIsHorizontal(heading) - ? (startDonglePosition[0] + endDonglePosition[0]) / 2 + ? (candidateStart[0] + candidateEnd[0]) / 2 : anchor[0], headingIsVertical(heading) - ? (startDonglePosition[1] + endDonglePosition[1]) / 2 + ? (candidateStart[1] + candidateEnd[1]) / 2 : anchor[1], ); nextFixedSegments[segmentIdx].anchor = anchor; From 07ce36a5bdb3f6668f4009df4060a94e8ce15794 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 4 Nov 2024 14:51:54 +0100 Subject: [PATCH 076/283] Revert font fix --- excalidraw-app/index.html | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 4d5af32d664f..83fac2932216 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -118,6 +118,12 @@ + <% } else { %> + + <% } %> + - <% } else { %> - - <% } %> - @@ -212,7 +212,6 @@

Excalidraw

- <% if (typeof PROD != 'undefined' && PROD == true) { %> - <% } %> From 77d96bd1a466eb8cf0a54c25c47ed6e254f4f3c4 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 4 Nov 2024 15:12:26 +0100 Subject: [PATCH 077/283] Revert explicit active marker for some events --- packages/excalidraw/components/App.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 0e6228e1af5b..b985f07aa1f7 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2556,7 +2556,6 @@ class App extends React.Component { this.excalidrawContainerRef.current, EVENT.WHEEL, this.handleWheel, - { passive: false }, ), addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false), addEventListener(document, EVENT.POINTER_UP, this.removePointer), // #3553 @@ -9903,7 +9902,6 @@ class App extends React.Component { this.interactiveCanvas.addEventListener( EVENT.TOUCH_START, this.onTouchStart, - { passive: false }, ); this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTouchEnd); // ----------------------------------------------------------------------- From 433dafe9d8bb5a658b5f62062394a07a26307ad7 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 4 Nov 2024 15:27:19 +0100 Subject: [PATCH 078/283] Remove explicit active handler --- packages/excalidraw/components/App.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b985f07aa1f7..5c50e5888999 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2621,7 +2621,6 @@ class App extends React.Component { this.excalidrawContainerRef.current, EVENT.WHEEL, this.handleWheel, - { passive: false }, ), addEventListener( this.excalidrawContainerRef.current, From a6ec3d1aa87521307501fe5d4cc2977c900aded3 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 4 Nov 2024 15:35:11 +0100 Subject: [PATCH 079/283] Remove empty line --- packages/excalidraw/components/App.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 5c50e5888999..40ec96948f66 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7972,7 +7972,6 @@ class App extends React.Component { }, }); } - if (!this.state.selectedLinearElement.isDragging) { this.setState({ selectedLinearElement: { From aa661b8260dda5d415293b743497845288c7705b Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 5 Nov 2024 14:26:44 +0100 Subject: [PATCH 080/283] Fix knot in elbow arrows --- packages/excalidraw/element/elbowarrow.ts | 30 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 87d099b12a9d..eebee85150fc 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -311,7 +311,9 @@ export const updateElbowArrowPoints = ( return raw; }); - const simplifiedPointGroups = getElbowArrowCornerPoints(rawPointGroups); + const simplifiedPointGroups = removeElbowArrowShortSegments( + getElbowArrowCornerPoints(rawPointGroups), + ); let currentGroupIdx = 0; nextFixedSegments = multiDimensionalArrayDeepFlatMapper< @@ -1322,10 +1324,11 @@ const getElbowArrowCornerPoints = ( ): GlobalPoint[][] => { const points = pointGroups.flat(); - let previousHorizontal = - Math.abs(points[0][1] - points[1][1]) < - Math.abs(points[0][0] - points[1][0]); if (points.length > 1) { + let previousHorizontal = + Math.abs(points[0][1] - points[1][1]) < + Math.abs(points[0][0] - points[1][0]); + return multiDimensionalArrayDeepFilter(pointGroups, (p, idx) => { // The very first and last points are always kept if (idx === 0 || idx === points.length - 1) { @@ -1348,6 +1351,25 @@ const getElbowArrowCornerPoints = ( return pointGroups; }; +const removeElbowArrowShortSegments = ( + pointGroups: GlobalPoint[][], +): GlobalPoint[][] => { + const points = pointGroups.flat(); + + if (points.length >= 4) { + return multiDimensionalArrayDeepFilter(pointGroups, (p, idx) => { + if (idx === 0 || idx === points.length - 1) { + return true; + } + + const prev = points[idx - 1]; + return pointDistanceSq(prev, p) >= 1; + }); + } + + return pointGroups; +}; + const neighborIndexToHeading = (idx: number): Heading => { switch (idx) { case 0: From d19c679ce33986b68a7964dddfe72dc98a4cb7b7 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 5 Nov 2024 17:03:13 +0100 Subject: [PATCH 081/283] Deep compare memoization, jest extension for point comparison, basic segment moving tests --- .../excalidraw/element/elbowarrow.test.tsx | 121 +++++++++++++++++- packages/excalidraw/package.json | 3 + packages/excalidraw/tests/test-utils.ts | 31 +++++ packages/excalidraw/utils.test.ts | 19 +++ packages/excalidraw/utils.ts | 27 ++++ yarn.lock | 35 ++--- 6 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 packages/excalidraw/utils.test.ts diff --git a/packages/excalidraw/element/elbowarrow.test.tsx b/packages/excalidraw/element/elbowarrow.test.tsx index d7c0ad686f30..bdf95f04c35a 100644 --- a/packages/excalidraw/element/elbowarrow.test.tsx +++ b/packages/excalidraw/element/elbowarrow.test.tsx @@ -23,6 +23,18 @@ const { h } = window; const mouse = new Pointer("mouse"); +// const expectPointsToBeCloseTo = ( +// received: readonly LocalPoint[], +// expected: number[][], +// precision?: number, +// ) => { +// expect(received.length).toBe(expected.length); +// received.forEach((point, index) => { +// expect(point[0]).toBeCloseTo(expected[index][0], precision); +// expect(point[1]).toBeCloseTo(expected[index][1], precision); +// }); +// }; + describe("elbow arrow routing", () => { it("can properly generate orthogonal arrow points", () => { const scene = new Scene(); @@ -187,9 +199,116 @@ describe("elbow arrow ui", () => { [0, 0], [35, 0], [35, 90], - [35, 90], // Note that coordinates are rounded above! [35, 165], [103, 165], ]); }); }); + +describe("elbow arrow segment move", () => { + beforeEach(async () => { + localStorage.clear(); + await render(); + + fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { + button: 2, + clientX: 1, + clientY: 1, + }); + const contextMenu = UI.queryContextMenu(); + fireEvent.click(queryByTestId(contextMenu!, "stats")!); + }); + + 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(200, 200); + mouse.click(); + + mouse.reset(); + mouse.moveTo(100, 100); + mouse.down(); + mouse.moveTo(105, 100); + mouse.up(); + + const arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawArrowElement; + + expect(arrow.points).toCloselyEqualPoints([ + [0, 0], + [105, 0], + [105, 148.55], + [200, 148.55], + [200, 200], + ]); + + mouse.reset(); + mouse.moveTo(105, 74.275); + mouse.doubleClick(); + + expect(arrow.points).toCloselyEqualPoints([ + [0, 0], + [0, -2], + [100, -2], + [100, 200], + [200, 200], + ]); + }); + + 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(105, 100); + mouse.up(); + + const arrow = h.scene.getSelectedElements( + h.state, + )[0] as ExcalidrawArrowElement; + + expect(arrow.points).toCloselyEqualPoints([ + [0, 0], + [100, 0], + [100, 200], + [190, 200], + ]); + + mouse.reset(); + mouse.moveTo(105, 74.275); + mouse.doubleClick(); + + expect(arrow.points).toCloselyEqualPoints([ + [0, 0], + [100, 0], + [100, 200], + [190, 200], + ]); + }); +}); diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 0f6f6b8a113e..862640333152 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -63,10 +63,12 @@ "@radix-ui/react-popover": "1.0.3", "@radix-ui/react-tabs": "1.0.2", "@tldraw/vec": "1.7.1", + "@types/deep-eql": "4.0.2", "browser-fs-access": "0.29.1", "canvas-roundrect-polyfill": "0.0.1", "clsx": "1.1.1", "cross-env": "7.0.3", + "deep-eql": "5.0.2", "es6-promise-pool": "2.5.0", "fractional-indexing": "3.2.0", "fuzzy": "0.1.3", @@ -117,6 +119,7 @@ "fonteditor-core": "2.4.1", "harfbuzzjs": "0.3.6", "import-meta-loader": "1.1.0", + "jest-diff": "29.7.0", "mini-css-extract-plugin": "2.6.1", "postcss-loader": "7.0.1", "sass-loader": "13.0.2", diff --git a/packages/excalidraw/tests/test-utils.ts b/packages/excalidraw/tests/test-utils.ts index 42cc7784fa62..4c0eacee684a 100644 --- a/packages/excalidraw/tests/test-utils.ts +++ b/packages/excalidraw/tests/test-utils.ts @@ -10,6 +10,7 @@ import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants"; import { getSelectedElements } from "../scene/selection"; import type { ExcalidrawElement } from "../element/types"; import { UI } from "./helpers/ui"; +import { diffStringsUnified } from "jest-diff"; const customQueries = { ...queries, @@ -246,6 +247,36 @@ expect.extend({ pass: false, }; }, + + toCloselyEqualPoints(received, expected, precision) { + if (!Array.isArray(received) || !Array.isArray(expected)) { + throw new Error("expected and received are not point arrays"); + } + + const COMPARE = 1 / Math.pow(10, precision || 2); + const pass = received.every( + (point, idx) => + Math.abs(expected[idx]?.[0] - point[0]) < COMPARE && + Math.abs(expected[idx]?.[1] - point[1]) < COMPARE, + ); + + if (!pass) { + return { + message: () => ` The provided array of points are not close enough. + +${diffStringsUnified( + JSON.stringify(expected, undefined, 2), + JSON.stringify(received, undefined, 2), +)}`, + pass: false, + }; + } + + return { + message: () => `expected ${received} to not be close to ${expected}`, + pass: true, + }; + }, }); /** diff --git a/packages/excalidraw/utils.test.ts b/packages/excalidraw/utils.test.ts new file mode 100644 index 000000000000..825e21ec78d7 --- /dev/null +++ b/packages/excalidraw/utils.test.ts @@ -0,0 +1,19 @@ +import { memo } from "./utils"; + +describe("utils", () => { + it("memo should memoize if the arguments are the same", () => { + const mock = jest.fn(); + const memoized = memo(mock); + memoized(1, 2, 3); + memoized(1, 2, 3); + expect(mock).toHaveBeenCalledTimes(1); + memoized(1, 2); + expect(mock).toHaveBeenCalledTimes(2); + memoized(1, 2, 3); + expect(mock).toHaveBeenCalledTimes(3); + memoized({ a: [1, 2, 3] }); + memoized({ a: [1, 2, 3] }); + memoized({ a: [1, 4, 3] }); + expect(mock).toHaveBeenCalledTimes(5); + }); +}); diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 1b32b1416739..c41020313e2e 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -18,6 +18,7 @@ import type { Zoom, } from "./types"; import type { MaybePromise, ResolutionType } from "./utility-types"; +import deepEql from "deep-eql"; let mockDateTime: string | null = null; @@ -984,6 +985,32 @@ export const memoize = , R extends any>( return ret as typeof func & { clear: () => void }; }; +export const memo = ( + func: (...args: P[]) => R, +): ((...args: P[]) => R) => { + let lastArgs: P[] | undefined; + let lastResult: R | "__empty__placeholder__excalidraw__" = + "__empty__placeholder__excalidraw__"; + + return (...args: P[]): R => { + if ( + lastArgs && + lastArgs.length === args.length && + deepEql(lastArgs, args) && + lastResult !== "__empty__placeholder__excalidraw__" + ) { + return lastResult; + } + + const result = func(...args); + + lastArgs = args; + lastResult = result; + + return result; + }; +}; + /** Checks if value is inside given collection. Useful for type-safety. */ export const isMemberOf = ( /** Set/Map/Array/Object */ diff --git a/yarn.lock b/yarn.lock index 9b3180535ae4..5cdc1def489c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3112,6 +3112,11 @@ dependencies: "@types/ms" "*" +"@types/deep-eql@4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + "@types/eslint-scope@^3.7.3": version "3.7.7" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" @@ -5280,6 +5285,11 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== +deep-eql@5.0.2, deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + deep-eql@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df" @@ -5287,11 +5297,6 @@ deep-eql@^3.0.1: dependencies: type-detect "^4.0.0" -deep-eql@^5.0.1: - version "5.0.2" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" - integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== - deep-is@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" @@ -7268,6 +7273,16 @@ jest-canvas-mock@~2.5.2: cssfontparser "^1.2.1" moo-color "^1.0.2" +jest-diff@29.7.0, jest-diff@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" + integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.6.3" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + jest-diff@^27.0.0: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def" @@ -7278,16 +7293,6 @@ jest-diff@^27.0.0: jest-get-type "^27.5.1" pretty-format "^27.5.1" -jest-diff@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" - integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== - dependencies: - chalk "^4.0.0" - diff-sequences "^29.6.3" - jest-get-type "^29.6.3" - pretty-format "^29.7.0" - jest-get-type@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1" From 4d48cd56b2dda92bb614b373e4212ea16af955f2 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 5 Nov 2024 17:21:53 +0100 Subject: [PATCH 082/283] Revert state change excess fix for another PR --- packages/excalidraw/components/App.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 40ec96948f66..b89c9c7204c0 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -8008,13 +8008,10 @@ class App extends React.Component { isFrameLikeElement(e), ); const topLayerFrame = this.getTopLayerFrameAtSceneCoords(pointerCoords); - const frameToHighlight = - topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null; - if (this.state.frameToHighlight !== frameToHighlight) { - flushSync(() => { - this.setState({ frameToHighlight }); - }); - } + this.setState({ + frameToHighlight: + topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null, + }); // Marking that click was used for dragging to check // if elements should be deselected on pointerup @@ -8161,9 +8158,7 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), ); - flushSync(() => { - this.setState({ snapLines }); - }); + this.setState({ snapLines }); // when we're editing the name of a frame, we want the user to be // able to select and interact with the text input From e313bc8ca25a4312ee7611badf560f4f755ee735 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 7 Nov 2024 14:34:53 +0100 Subject: [PATCH 083/283] Optimize elbow arrow point generation with memoization --- packages/excalidraw/element/elbowarrow.ts | 71 ++++++++++++++++------- packages/excalidraw/utils.ts | 10 ++-- 2 files changed, 55 insertions(+), 26 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index eebee85150fc..3ee5e030cabd 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -15,6 +15,7 @@ import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; import { isAnyTrue, + memo, multiDimensionalArrayDeepFilter, multiDimensionalArrayDeepFlatMapper, toBrandedType, @@ -118,6 +119,24 @@ const segmentListMerge = ( ) as Sequential; }; +const generatePoints = memo( + ( + state: ElbowArrowState, + points: readonly LocalPoint[], + segmentIdx: number, + totalSegments: number, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + options?: { + isDragging?: boolean; + }, + ) => + routeElbowArrow(state, elementsMap, points, { + ...options, + ...(segmentIdx !== 0 ? { startIsMidPoint: true } : {}), + ...(segmentIdx !== totalSegments ? { endIsMidPoint: true } : {}), + }) ?? [], +); + /** * */ @@ -168,7 +187,24 @@ export const updateElbowArrowPoints = ( pointDistanceSq( updatedPoints[segment.index], updatedPoints[segment.index - 1], - ) <= 4 + ) <= 4 || + (updatedPoints[segment.index - 2] && + compareHeading( + vectorToHeading( + vectorFromPoint( + updatedPoints[segment.index], + updatedPoints[segment.index - 1], + ), + ), + flipHeading( + vectorToHeading( + vectorFromPoint( + updatedPoints[segment.index - 1], + updatedPoints[segment.index - 2], + ), + ), + ), + )) ) { heading = flipHeading(heading); } @@ -300,19 +336,19 @@ export const updateElbowArrowPoints = ( ], ]); - const rawPointGroups = pointPairs.map(([state, points], idx) => { - const raw = - routeElbowArrow(state, fakeElementsMap, points, { - ...options, - ...(idx !== 0 ? { startIsMidPoint: true } : {}), - ...(idx !== pointPairs.length - 1 ? { endIsMidPoint: true } : {}), - }) ?? []; - - return raw; - }); - const simplifiedPointGroups = removeElbowArrowShortSegments( - getElbowArrowCornerPoints(rawPointGroups), + getElbowArrowCornerPoints( + pointPairs.map(([state, points], idx) => + generatePoints( + state, + points, + idx, + pointPairs.length - 1, + fakeElementsMap, + options, + ), + ), + ), ); let currentGroupIdx = 0; @@ -610,14 +646,7 @@ const getElbowArrowData = ( * @returns */ const routeElbowArrow = ( - arrow: { - x: number; - y: number; - startBinding: FixedPointBinding | null; - endBinding: FixedPointBinding | null; - startArrowhead: Arrowhead | null; - endArrowhead: Arrowhead | null; - }, + arrow: ElbowArrowState, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, nextPoints: readonly LocalPoint[], options?: { diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index c41020313e2e..66964364d9d5 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -985,14 +985,14 @@ export const memoize = , R extends any>( return ret as typeof func & { clear: () => void }; }; -export const memo = ( - func: (...args: P[]) => R, -): ((...args: P[]) => R) => { - let lastArgs: P[] | undefined; +export const memo =

, R>( + func: (...args: P) => R, +): ((...args: P) => R) => { + let lastArgs: P | undefined; let lastResult: R | "__empty__placeholder__excalidraw__" = "__empty__placeholder__excalidraw__"; - return (...args: P[]): R => { + return (...args: P): R => { if ( lastArgs && lastArgs.length === args.length && From e134dda5f19e702c9b58db4f7286a644f563d73b Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 7 Nov 2024 14:56:42 +0100 Subject: [PATCH 084/283] Refactor arrow type change action to fix inaccessible midpoint move --- .../excalidraw/actions/actionProperties.tsx | 274 +++++++++--------- 1 file changed, 144 insertions(+), 130 deletions(-) diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 68fc8a57fc33..8f844738f236 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1548,145 +1548,159 @@ export const actionChangeArrowType = register({ label: "Change arrow types", trackEvent: false, perform: (elements, appState, value, app) => { - return { - elements: changeProperty(elements, appState, (el) => { - if (!isArrowElement(el)) { - return el; - } - const newElement = newElementWith(el, { - roundness: - value === ARROW_TYPE.round - ? { - type: ROUNDNESS.PROPORTIONAL_RADIUS, - } - : null, - elbowed: value === ARROW_TYPE.elbow, - points: - value === ARROW_TYPE.elbow || el.elbowed - ? [el.points[0], el.points[el.points.length - 1]] - : el.points, - }); - - if (isElbowArrow(newElement)) { - const elementsMap = app.scene.getNonDeletedElementsMap(); + const newElements = changeProperty(elements, appState, (el) => { + if (!isArrowElement(el)) { + return el; + } + const newElement = newElementWith(el, { + roundness: + value === ARROW_TYPE.round + ? { + type: ROUNDNESS.PROPORTIONAL_RADIUS, + } + : null, + elbowed: value === ARROW_TYPE.elbow, + points: + value === ARROW_TYPE.elbow || el.elbowed + ? [el.points[0], el.points[el.points.length - 1]] + : el.points, + }); - app.dismissLinearEditor(); + if (isElbowArrow(newElement)) { + const elementsMap = app.scene.getNonDeletedElementsMap(); - const startGlobalPoint = - LinearElementEditor.getPointAtIndexGlobalCoordinates( - newElement, - 0, - elementsMap, - ); - const endGlobalPoint = - LinearElementEditor.getPointAtIndexGlobalCoordinates( - newElement, - -1, - elementsMap, - ); - const startHoveredElement = - !newElement.startBinding && - getHoveredElementForBinding( - tupleToCoors(startGlobalPoint), - elements, - elementsMap, - true, - ); - const endHoveredElement = - !newElement.endBinding && - getHoveredElementForBinding( - tupleToCoors(endGlobalPoint), - elements, - elementsMap, - true, - ); - const startElement = startHoveredElement - ? startHoveredElement - : newElement.startBinding && - (elementsMap.get( - newElement.startBinding.elementId, - ) as ExcalidrawBindableElement); - const endElement = endHoveredElement - ? endHoveredElement - : newElement.endBinding && - (elementsMap.get( - newElement.endBinding.elementId, - ) as ExcalidrawBindableElement); - - const finalStartPoint = startHoveredElement - ? bindPointToSnapToElementOutline( - startGlobalPoint, - endGlobalPoint, - startHoveredElement, - elementsMap, - ) - : startGlobalPoint; - const finalEndPoint = endHoveredElement - ? bindPointToSnapToElementOutline( - endGlobalPoint, - startGlobalPoint, - endHoveredElement, - elementsMap, - ) - : endGlobalPoint; + app.dismissLinearEditor(); - startHoveredElement && - bindLinearElement( - newElement, + const startGlobalPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + newElement, + 0, + elementsMap, + ); + const endGlobalPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + newElement, + -1, + elementsMap, + ); + const startHoveredElement = + !newElement.startBinding && + getHoveredElementForBinding( + tupleToCoors(startGlobalPoint), + elements, + elementsMap, + true, + ); + const endHoveredElement = + !newElement.endBinding && + getHoveredElementForBinding( + tupleToCoors(endGlobalPoint), + elements, + elementsMap, + true, + ); + const startElement = startHoveredElement + ? startHoveredElement + : newElement.startBinding && + (elementsMap.get( + newElement.startBinding.elementId, + ) as ExcalidrawBindableElement); + const endElement = endHoveredElement + ? endHoveredElement + : newElement.endBinding && + (elementsMap.get( + newElement.endBinding.elementId, + ) as ExcalidrawBindableElement); + + const finalStartPoint = startHoveredElement + ? bindPointToSnapToElementOutline( + startGlobalPoint, + endGlobalPoint, startHoveredElement, - "start", elementsMap, - ); - endHoveredElement && - bindLinearElement( - newElement, + ) + : startGlobalPoint; + const finalEndPoint = endHoveredElement + ? bindPointToSnapToElementOutline( + endGlobalPoint, + startGlobalPoint, endHoveredElement, - "end", elementsMap, - ); + ) + : endGlobalPoint; - mutateElement(newElement, { - points: [finalStartPoint, finalEndPoint].map( - (p): LocalPoint => - pointFrom(p[0] - newElement.x, p[1] - newElement.y), - ), - ...(startElement && newElement.startBinding - ? { - startBinding: { - // @ts-ignore TS cannot discern check above - ...newElement.startBinding!, - ...calculateFixedPointForElbowArrowBinding( - newElement, - startElement, - "start", - elementsMap, - ), - }, - } - : {}), - ...(endElement && newElement.endBinding - ? { - endBinding: { - // @ts-ignore TS cannot discern check above - ...newElement.endBinding, - ...calculateFixedPointForElbowArrowBinding( - newElement, - endElement, - "end", - elementsMap, - ), - }, - } - : {}), - }); - } + startHoveredElement && + bindLinearElement( + newElement, + startHoveredElement, + "start", + elementsMap, + ); + endHoveredElement && + bindLinearElement(newElement, endHoveredElement, "end", elementsMap); + + mutateElement(newElement, { + points: [finalStartPoint, finalEndPoint].map( + (p): LocalPoint => + pointFrom(p[0] - newElement.x, p[1] - newElement.y), + ), + ...(startElement && newElement.startBinding + ? { + startBinding: { + // @ts-ignore TS cannot discern check above + ...newElement.startBinding!, + ...calculateFixedPointForElbowArrowBinding( + newElement, + startElement, + "start", + elementsMap, + ), + }, + } + : {}), + ...(endElement && newElement.endBinding + ? { + endBinding: { + // @ts-ignore TS cannot discern check above + ...newElement.endBinding, + ...calculateFixedPointForElbowArrowBinding( + newElement, + endElement, + "end", + elementsMap, + ), + }, + } + : {}), + }); - return newElement; - }), - appState: { - ...appState, - currentItemArrowType: value, - }, + LinearElementEditor.updateEditorMidPointsCache( + newElement, + elementsMap, + app.state, + ); + } + + return newElement; + }); + + const newState = { + ...appState, + currentItemArrowType: value, + }; + const selectedId = appState.selectedLinearElement?.elementId; + if (selectedId) { + const selected = newElements.find((el) => el.id === selectedId); + if (selected) { + newState.selectedLinearElement = new LinearElementEditor( + selected as ExcalidrawLinearElement, + ); + } + } + + return { + elements: newElements, + appState: newState, storeAction: StoreAction.CAPTURE, }; }, From 1ae816861b56b2f083efea37544f750e51d5ba56 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 7 Nov 2024 15:01:53 +0100 Subject: [PATCH 085/283] Add comment to trigger CI again --- packages/excalidraw/actions/actionProperties.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 8f844738f236..7f7786a74caa 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1688,6 +1688,9 @@ export const actionChangeArrowType = register({ ...appState, currentItemArrowType: value, }; + + // Change the arrow type and update any other state settings for + // the arrow. const selectedId = appState.selectedLinearElement?.elementId; if (selectedId) { const selected = newElements.find((el) => el.id === selectedId); From 6538baa7c9a47fbc2247b2dca59cc1c7fffa0de8 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 11 Nov 2024 13:00:19 +0100 Subject: [PATCH 086/283] Short segment is not consistently handled --- packages/excalidraw/components/App.tsx | 8 +++++ .../excalidraw/renderer/interactiveScene.ts | 31 ++++++++++++------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b89c9c7204c0..006221511140 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7957,6 +7957,7 @@ class App extends React.Component { linearElementEditor, this.scene, ); + if (didDrag) { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; @@ -8000,6 +8001,13 @@ class App extends React.Component { ) { const selectedElements = this.scene.getSelectedElements(this.state); + if ( + selectedElements.length === 1 && + isElbowArrow(selectedElements[0]) + ) { + return; + } + if (selectedElements.every((element) => element.locked)) { return; } diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index e6950e0e646f..714e7373d832 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -518,17 +518,26 @@ const renderLinearPointHandles = ( pointFrom(element.x + p[0], element.y + p[1]), ); globalPoint.slice(1, -2).forEach((p, idx) => { - renderSingleLinearPoint( - context, - appState, - pointFrom( - (p[0] + globalPoint[idx + 2][0]) / 2, - (p[1] + globalPoint[idx + 2][1]) / 2, - ), - POINT_HANDLE_SIZE / 2, - false, - !fixedPoints[idx + 1], - ); + if ( + !LinearElementEditor.isSegmentTooShort( + element, + p, + globalPoint[idx + 2], + appState.zoom, + ) + ) { + renderSingleLinearPoint( + context, + appState, + pointFrom( + (p[0] + globalPoint[idx + 2][0]) / 2, + (p[1] + globalPoint[idx + 2][1]) / 2, + ), + POINT_HANDLE_SIZE / 2, + false, + !fixedPoints[idx + 1], + ); + } }); } else { const midPoints = LinearElementEditor.getEditorMidPoints( From 1f9e8540af7333488269f50c508f3a4d3f2e6866 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 11 Nov 2024 16:43:23 +0100 Subject: [PATCH 087/283] Fix unselect --- packages/excalidraw/components/App.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 006221511140..17382a2c3554 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -9242,7 +9242,13 @@ class App extends React.Component { } } + // const midPointSelected = + // (this.state.selectedLinearElement?.pointerDownState.segmentMidpoint + // .index || -1) < 0; + if ( + // not elbow midpoint dragged + !(hitElement && isElbowArrow(hitElement)) && // not dragged !pointerDownState.drag.hasOccurred && // not resized From 076377f02db1d4a559bb5b1e5afe7dfd34f398bf Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 11 Nov 2024 16:53:19 +0100 Subject: [PATCH 088/283] Fix stale midpoint cache after midpoint delete --- packages/excalidraw/components/App.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 17382a2c3554..714d50e0e757 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -5307,6 +5307,12 @@ class App extends React.Component { mutateElement(selectedElements[0], { fixedSegments }); + LinearElementEditor.updateEditorMidPointsCache( + selectedElements[0], + elementsMap, + this.state, + ); + return; } } From b3123eff95c4bf3d0a5874bea93fb10ba72f30ef Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 11 Nov 2024 16:57:17 +0100 Subject: [PATCH 089/283] Double click adds label to elbow arrows --- packages/excalidraw/components/App.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 714d50e0e757..61f75a041a74 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -5281,7 +5281,9 @@ class App extends React.Component { return; } else if ( isElbowArrow(selectedElements[0]) && - this.state.selectedLinearElement + this.state.selectedLinearElement && + (this.state.selectedLinearElement?.pointerDownState?.segmentMidpoint + ?.index || -1) > -1 ) { // Delete fixed segment point this.store.shouldCaptureIncrement(); From 9f1084fd5b8503d1d860b47c400643f0e896acc4 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 11 Nov 2024 19:11:52 +0100 Subject: [PATCH 090/283] Update midpoint segment cache after scene restore --- packages/excalidraw/components/App.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 61f75a041a74..a85ffe765035 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -189,6 +189,7 @@ import type { NonDeletedSceneElementsMap, Sequential, FixedSegment, + ExcalidrawElbowArrowElement, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -2327,6 +2328,17 @@ class App extends React.Component { this.fonts.loadSceneFonts().then((fontFaces) => { this.fonts.onLoaded(fontFaces); }); + + // Elbow arrow segment midpoint cache needs to be updated after the scene (re)load + scene.elements.forEach((element) => { + if (isElbowArrow(element)) { + LinearElementEditor.updateEditorMidPointsCache( + element, + this.scene.getElementsMapIncludingDeleted(), + this.state, + ); + } + }); }; private isMobileBreakpoint = (width: number, height: number) => { From 45a8c036851e225e0447612e24f62c4a4f72eb59 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 11 Nov 2024 19:12:28 +0100 Subject: [PATCH 091/283] Remove unneeded type import --- packages/excalidraw/components/App.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index a85ffe765035..62956b6567a6 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -189,7 +189,6 @@ import type { NonDeletedSceneElementsMap, Sequential, FixedSegment, - ExcalidrawElbowArrowElement, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { From 3752c3952f552d813a691c3d9f1b995f5c18f7c8 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 12 Nov 2024 20:32:28 +0100 Subject: [PATCH 092/283] First and last midpoints are fixable Signed-off-by: Mark Tolmacs --- packages/excalidraw/components/App.tsx | 21 ++++-------- .../excalidraw/element/linearElementEditor.ts | 33 +++---------------- .../excalidraw/renderer/interactiveScene.ts | 16 ++++----- 3 files changed, 19 insertions(+), 51 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 62956b6567a6..b1c33542daff 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -6130,22 +6130,15 @@ class App extends React.Component { this.state, this.scene.getNonDeletedElementsMap(), ); - const segmentMidPointIndex = - (segmentMidPointHoveredCoords && - LinearElementEditor.getSegmentMidPointIndex( - linearElementEditor, - this.state, - segmentMidPointHoveredCoords, - elementsMap, - )) ?? - -1; if ( - (!elementIsElbowArrow && - (hoverPointIndex >= 0 || segmentMidPointHoveredCoords)) || - (elementIsElbowArrow && - segmentMidPointIndex > 1 && - segmentMidPointIndex < element.points.length - 1) + hoverPointIndex >= 0 || + segmentMidPointHoveredCoords + // (!elementIsElbowArrow && + // (hoverPointIndex >= 0 || segmentMidPointHoveredCoords)) || + // (elementIsElbowArrow && + // segmentMidPointIndex > 0 && + // segmentMidPointIndex < element.points.length) ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } else if ( diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index f7465a15610a..b2581cf01d39 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1248,29 +1248,9 @@ export class LinearElementEditor { }, ) { const { points } = element; - let targets = Array.from(targetPoints); + const targets = Array.from(targetPoints); const indices = targets.map((p) => p.index).sort(); - if (isElbowArrow(element)) { - // Do not allow modifying the first segment - if (indices[0] === 0 && indices[1] === 1) { - targets = targets.slice(2); - } - // Do not allow modifying the last segment - if ( - indices[indices.length - 1] !== undefined && - indices[indices.length - 1] === element.points.length - 1 && - indices[indices.length - 2] !== undefined && - indices[indices.length - 2] === element.points.length - 2 - ) { - targets = targets.slice(0, -2); - } - // If no point remains to modify, return - if (targets.length < 1) { - return; - } - } - // 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 @@ -1881,12 +1861,7 @@ export class LinearElementEditor { elementsMap, ); - if ( - !element || - !segmentIdx || - segmentIdx === 1 || - segmentIdx === element.points.length - 1 - ) { + if (!element || !segmentIdx) { return linearElementEditor; } @@ -1917,7 +1892,7 @@ export class LinearElementEditor { LinearElementEditor.movePoints(element, [ { - index: segmentIdx! - 1, + index: segmentIdx - 1, point: pointFrom( (!isHorizontal ? pointerCoords.x : startPoint[0]) - element.x, (isHorizontal ? pointerCoords.y : startPoint[1]) - element.y, @@ -1925,7 +1900,7 @@ export class LinearElementEditor { isDragging: true, }, { - index: segmentIdx!, + index: segmentIdx, point: pointFrom( (!isHorizontal ? pointerCoords.x : endPoint[0]) - element.x, (isHorizontal ? pointerCoords.y : endPoint[1]) - element.y, diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 714e7373d832..79acd03e4bcd 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -508,21 +508,21 @@ const renderLinearPointHandles = ( renderSingleLinearPoint(context, appState, point, radius, isSelected); }); - //Rendering segment mid points + // Rendering segment mid points if (isElbowArrow(element)) { const indices = element.fixedSegments?.map((s) => s.index) ?? []; const fixedPoints = element.points.slice(0, -1).map((p, i) => { return indices.includes(i + 1); }); - const globalPoint = element.points.map((p) => + const globalPoints = element.points.map((p) => pointFrom(element.x + p[0], element.y + p[1]), ); - globalPoint.slice(1, -2).forEach((p, idx) => { + globalPoints.slice(0, -1).forEach((p, idx) => { if ( !LinearElementEditor.isSegmentTooShort( element, p, - globalPoint[idx + 2], + globalPoints[idx + 1], appState.zoom, ) ) { @@ -530,12 +530,12 @@ const renderLinearPointHandles = ( context, appState, pointFrom( - (p[0] + globalPoint[idx + 2][0]) / 2, - (p[1] + globalPoint[idx + 2][1]) / 2, + (p[0] + globalPoints[idx + 1][0]) / 2, + (p[1] + globalPoints[idx + 1][1]) / 2, ), POINT_HANDLE_SIZE / 2, false, - !fixedPoints[idx + 1], + !fixedPoints[idx], ); } }); @@ -550,7 +550,7 @@ const renderLinearPointHandles = ( !(isElbowArrow(element) && (idx === 0 || idx === midPoints.length - 1)), ); - midPoints.forEach((segmentMidPoint, segmentIdx) => { + midPoints.forEach((segmentMidPoint) => { if (appState.editingLinearElement || points.length === 2) { renderSingleLinearPoint( context, From b55c6a7ff4dfb341887a51121288e313efd81754 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 13 Nov 2024 09:39:03 +0100 Subject: [PATCH 093/283] Simplify flip action Signed-off-by: Mark Tolmacs --- .../excalidraw/actions/actionFlip.test.tsx | 6 ++--- packages/excalidraw/actions/actionFlip.ts | 24 +++---------------- packages/excalidraw/components/App.tsx | 10 +------- 3 files changed, 7 insertions(+), 33 deletions(-) diff --git a/packages/excalidraw/actions/actionFlip.test.tsx b/packages/excalidraw/actions/actionFlip.test.tsx index cfcf53f33c22..10bf94cea133 100644 --- a/packages/excalidraw/actions/actionFlip.test.tsx +++ b/packages/excalidraw/actions/actionFlip.test.tsx @@ -70,9 +70,9 @@ describe("flipping re-centers selection", () => { API.executeAction(actionFlipHorizontal); API.executeAction(actionFlipHorizontal); - const rec1 = h.elements.find((el) => el.id === "rec1"); - expect(rec1?.x).toBeCloseTo(100, 1); - expect(rec1?.y).toBeCloseTo(100, 1); + const rec1 = h.elements.find((el) => el.id === "rec1")!; + expect(rec1.x).toBeCloseTo(100, 0); + expect(rec1.y).toBeCloseTo(100, 1); const rec2 = h.elements.find((el) => el.id === "rec2"); expect(rec2?.x).toBeCloseTo(220, 1); diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 34388a0aeff4..7ec44a795506 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -160,33 +160,15 @@ const flipElements = ( // "move" across the canvas because of how arrows can bump against the "wall" // of the selection, so we need to center the group back to the original // position so that repeated flips don't accumulate the offset - - const { elbowArrows, otherElements } = selectedElements.reduce( - ( - acc: { - elbowArrows: ExcalidrawElbowArrowElement[]; - otherElements: ExcalidrawElement[]; - }, - element, - ) => - isElbowArrow(element) - ? { ...acc, elbowArrows: acc.elbowArrows.concat(element) } - : { ...acc, otherElements: acc.otherElements.concat(element) }, - { elbowArrows: [], otherElements: [] }, - ); - const { midX: newMidX, midY: newMidY } = getCommonBoundingBox(selectedElements); const [diffX, diffY] = [midX - newMidX, midY - newMidY]; - otherElements.forEach((element) => + selectedElements.forEach((element) => { mutateElement(element, { x: element.x + diffX, y: element.y + diffY, - }), - ); - elbowArrows.forEach((element) => - mutateElement(element, { points: element.points }, false), - ); + }); + }); // --------------------------------------------------------------------------- return selectedElements; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b1c33542daff..e6785d282036 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -6131,15 +6131,7 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), ); - if ( - hoverPointIndex >= 0 || - segmentMidPointHoveredCoords - // (!elementIsElbowArrow && - // (hoverPointIndex >= 0 || segmentMidPointHoveredCoords)) || - // (elementIsElbowArrow && - // segmentMidPointIndex > 0 && - // segmentMidPointIndex < element.points.length) - ) { + if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } else if ( !elementIsElbowArrow && From 1ff89d07a3881eb9d5169e8c0bd190d0027bd83d Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 13 Nov 2024 09:39:22 +0100 Subject: [PATCH 094/283] Fix lint Signed-off-by: Mark Tolmacs --- packages/excalidraw/actions/actionFlip.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 7ec44a795506..f99129a2dd03 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -3,7 +3,6 @@ import { getSelectedElements } from "../scene"; import { getNonDeletedElements } from "../element"; import type { ExcalidrawArrowElement, - ExcalidrawElbowArrowElement, ExcalidrawElement, NonDeleted, NonDeletedSceneElementsMap, @@ -20,11 +19,7 @@ import { import { updateFrameMembershipOfSelectedElements } from "../frame"; import { flipHorizontal, flipVertical } from "../components/icons"; import { StoreAction } from "../store"; -import { - isArrowElement, - isElbowArrow, - isLinearElement, -} from "../element/typeChecks"; +import { isArrowElement, isLinearElement } from "../element/typeChecks"; import { mutateElement, newElementWith } from "../element/mutateElement"; export const actionFlipHorizontal = register({ From fc9b574e53b0ef58834e573fa0b5c09ab07d1c08 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 13 Nov 2024 14:10:52 +0100 Subject: [PATCH 095/283] Fix flipping --- .../excalidraw/actions/actionFlip.test.tsx | 1 + packages/excalidraw/actions/actionFlip.ts | 47 +++++++++++-------- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/packages/excalidraw/actions/actionFlip.test.tsx b/packages/excalidraw/actions/actionFlip.test.tsx index 10bf94cea133..95954d0e00c8 100644 --- a/packages/excalidraw/actions/actionFlip.test.tsx +++ b/packages/excalidraw/actions/actionFlip.test.tsx @@ -4,6 +4,7 @@ import { render } from "../tests/test-utils"; import { API } from "../tests/helpers/api"; import { pointFrom } from "../../math"; import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip"; +import { getCommonBoundingBox } from "../element/bounds"; const { h } = window; diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index f99129a2dd03..867a64c7ade0 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -3,6 +3,7 @@ import { getSelectedElements } from "../scene"; import { getNonDeletedElements } from "../element"; import type { ExcalidrawArrowElement, + ExcalidrawElbowArrowElement, ExcalidrawElement, NonDeleted, NonDeletedSceneElementsMap, @@ -15,12 +16,19 @@ import { getCommonBoundingBox } from "../element/bounds"; import { bindOrUnbindLinearElements, isBindingEnabled, + updateBoundElements, } from "../element/binding"; import { updateFrameMembershipOfSelectedElements } from "../frame"; import { flipHorizontal, flipVertical } from "../components/icons"; import { StoreAction } from "../store"; -import { isArrowElement, isLinearElement } from "../element/typeChecks"; +import { + isArrowElement, + isElbowArrow, + isLinearElement, +} from "../element/typeChecks"; import { mutateElement, newElementWith } from "../element/mutateElement"; +import { debugDrawPoint } from "../visualdebug"; +import { pointFrom, type LocalPoint } from "../../math"; export const actionFlipHorizontal = register({ name: "flipHorizontal", @@ -126,12 +134,25 @@ const flipElements = ( }); } - const { minX, minY, maxX, maxY, midX, midY } = - getCommonBoundingBox(selectedElements); + const { elbowArrows, otherElements } = selectedElements.reduce( + ( + acc: { + elbowArrows: ExcalidrawElbowArrowElement[]; + otherElements: ExcalidrawElement[]; + }, + element, + ) => + isElbowArrow(element) + ? { ...acc, elbowArrows: acc.elbowArrows.concat(element) } + : { ...acc, otherElements: acc.otherElements.concat(element) }, + { elbowArrows: [], otherElements: [] }, + ); + + const { minX, minY, maxX, maxY } = getCommonBoundingBox(otherElements); resizeMultipleElements( elementsMap, - selectedElements, + otherElements, elementsMap, "nw", true, @@ -150,21 +171,9 @@ const flipElements = ( [], ); - // --------------------------------------------------------------------------- - // flipping arrow elements (and potentially other) makes the selection group - // "move" across the canvas because of how arrows can bump against the "wall" - // of the selection, so we need to center the group back to the original - // position so that repeated flips don't accumulate the offset - const { midX: newMidX, midY: newMidY } = - getCommonBoundingBox(selectedElements); - const [diffX, diffY] = [midX - newMidX, midY - newMidY]; - selectedElements.forEach((element) => { - mutateElement(element, { - x: element.x + diffX, - y: element.y + diffY, - }); - }); - // --------------------------------------------------------------------------- + elbowArrows.forEach((elbowArrow) => + mutateElement(elbowArrow, { points: elbowArrow.points }), + ); return selectedElements; }; From 92a61b3db671cb893a50860736a77549c5c8cd76 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 13 Nov 2024 14:11:19 +0100 Subject: [PATCH 096/283] Fix lint --- packages/excalidraw/actions/actionFlip.test.tsx | 1 - packages/excalidraw/actions/actionFlip.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/packages/excalidraw/actions/actionFlip.test.tsx b/packages/excalidraw/actions/actionFlip.test.tsx index 95954d0e00c8..10bf94cea133 100644 --- a/packages/excalidraw/actions/actionFlip.test.tsx +++ b/packages/excalidraw/actions/actionFlip.test.tsx @@ -4,7 +4,6 @@ import { render } from "../tests/test-utils"; import { API } from "../tests/helpers/api"; import { pointFrom } from "../../math"; import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip"; -import { getCommonBoundingBox } from "../element/bounds"; const { h } = window; diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 867a64c7ade0..cc7cb9492bd7 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -16,7 +16,6 @@ import { getCommonBoundingBox } from "../element/bounds"; import { bindOrUnbindLinearElements, isBindingEnabled, - updateBoundElements, } from "../element/binding"; import { updateFrameMembershipOfSelectedElements } from "../frame"; import { flipHorizontal, flipVertical } from "../components/icons"; @@ -27,8 +26,6 @@ import { isLinearElement, } from "../element/typeChecks"; import { mutateElement, newElementWith } from "../element/mutateElement"; -import { debugDrawPoint } from "../visualdebug"; -import { pointFrom, type LocalPoint } from "../../math"; export const actionFlipHorizontal = register({ name: "flipHorizontal", From b3d35f38aa967679a0e0bdd2abd603519edbb945 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 13 Nov 2024 19:26:47 +0100 Subject: [PATCH 097/283] Drag cloning elbow arrows --- packages/excalidraw/components/App.tsx | 111 ++++++++++--------- packages/excalidraw/element/dragElements.ts | 3 - packages/excalidraw/element/elbowarrow.ts | 1 - packages/excalidraw/element/mutateElement.ts | 4 +- 4 files changed, 62 insertions(+), 57 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index e6785d282036..962566c81f5e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -186,7 +186,6 @@ import type { MagicGenerationData, ExcalidrawNonSelectionElement, ExcalidrawArrowElement, - NonDeletedSceneElementsMap, Sequential, FixedSegment, } from "../element/types"; @@ -291,7 +290,6 @@ import { getDateTime, isShallowEqual, arrayToMap, - toBrandedType, } from "../utils"; import { createSrcDoc, @@ -441,7 +439,6 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize"; import { getVisibleSceneBounds } from "../element/bounds"; import { isMaybeMermaidDefinition } from "../mermaid"; import NewElementCanvas from "./canvases/NewElementCanvas"; -import { updateElbowArrowPoints } from "../element/elbowarrow"; import { FlowChartCreator, FlowChartNavigator, @@ -3138,45 +3135,45 @@ class App extends React.Component { retainSeed?: boolean; fitToContent?: boolean; }) => { - let elements = opts.elements.map((el, _, elements) => { - if (isElbowArrow(el)) { - const startEndElements = [ - el.startBinding && - elements.find((l) => l.id === el.startBinding?.elementId), - el.endBinding && - elements.find((l) => l.id === el.endBinding?.elementId), - ]; - const startBinding = startEndElements[0] ? el.startBinding : null; - const endBinding = startEndElements[1] ? el.endBinding : null; - return { - ...el, - ...updateElbowArrowPoints( - { - ...el, - startBinding, - endBinding, - }, - toBrandedType( - new Map( - startEndElements - .filter((x) => x != null) - .map( - (el) => - [el!.id, el] as [ - string, - Ordered, - ], - ), - ), - ), - { points: el.points }, - ), - }; - } - - return el; - }); - elements = restoreElements(elements, null, undefined); + // let elements = opts.elements.map((el, _, elements) => { + // if (isElbowArrow(el)) { + // const startEndElements = [ + // el.startBinding && + // elements.find((l) => l.id === el.startBinding?.elementId), + // el.endBinding && + // elements.find((l) => l.id === el.endBinding?.elementId), + // ]; + // const startBinding = startEndElements[0] ? el.startBinding : null; + // const endBinding = startEndElements[1] ? el.endBinding : null; + // return { + // ...el, + // ...updateElbowArrowPoints( + // { + // ...el, + // startBinding, + // endBinding, + // }, + // toBrandedType( + // new Map( + // startEndElements + // .filter((x) => x != null) + // .map( + // (el) => + // [el!.id, el] as [ + // string, + // Ordered, + // ], + // ), + // ), + // ), + // { points: el.points }, + // ), + // }; + // } + + // return el; + // }); + const elements = restoreElements(opts.elements, null, undefined); const [minX, minY, maxX, maxY] = getCommonBounds(elements); const elementsCenterX = distance(minX, maxX) / 2; @@ -6131,11 +6128,17 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), ); - if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) { + if ( + (!elementIsElbowArrow && hoverPointIndex >= 0) || + segmentMidPointHoveredCoords + ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } else if ( - !elementIsElbowArrow && - this.hitElement(scenePointerX, scenePointerY, element) + this.hitElement(scenePointerX, scenePointerY, element) && + (!elementIsElbowArrow || + (elementIsElbowArrow && + !element.startBinding && + !element.endBinding)) ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } @@ -8005,12 +8008,12 @@ class App extends React.Component { ) { const selectedElements = this.scene.getSelectedElements(this.state); - if ( - selectedElements.length === 1 && - isElbowArrow(selectedElements[0]) - ) { - return; - } + // if ( + // selectedElements.length === 1 && + // isElbowArrow(selectedElements[0]) + // ) { + // return; + // } if (selectedElements.every((element) => element.locked)) { return; @@ -8248,6 +8251,12 @@ class App extends React.Component { x: origElement.x, y: origElement.y, }); + if (isElbowArrow(element)) { + mutateElement(element, { + startBinding: null, + endBinding: null, + }); + } // put duplicated element to pointerDownState.originalElements // so that we can snap to the duplicated element without releasing diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index f120256158db..4f90703e3a77 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -44,9 +44,6 @@ export const dragSelectedElements = ( } const selectedElements = _selectedElements; - // .filter( - // (el) => !(isElbowArrow(el) && el.startBinding && el.endBinding), - // ); // 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 diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 3ee5e030cabd..0882a33d2ad8 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -388,7 +388,6 @@ export const updateElbowArrowPoints = ( * @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.disableBinding - Indicates if binding should be disabled. * @param options.startIsMidPoint - Indicates if the start point is a midpoint. * @param options.endIsMidPoint - Indicates if the end point is a midpoint. * diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index 6d6326508ec6..95b3d7b8a9ed 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -37,8 +37,8 @@ export const mutateElement = >( const { points, fileId } = updates as any; if ( - typeof points !== "undefined" || - Object.hasOwn(updates, "fixedSegments") + typeof points !== "undefined" + //|| Object.hasOwn(updates, "fixedSegments") ) { if (isElbowArrow(element)) { const mergedElementsMap = toBrandedType( From 5409e9e7e47c4df3af85d8602ffbdd37c6fb3e50 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 13 Nov 2024 19:32:43 +0100 Subject: [PATCH 098/283] Fix fixed segment removal --- packages/excalidraw/element/dragElements.ts | 19 ++++++++++++------- .../excalidraw/element/linearElementEditor.ts | 1 - packages/excalidraw/element/mutateElement.ts | 6 ++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index 4f90703e3a77..d86cb6942853 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -78,13 +78,18 @@ export const dragSelectedElements = ( elementsToUpdate.forEach((element) => { updateElementCoords(pointerDownState, element, adjustedOffset); if (isElbowArrow(element)) { - mutateElement(element, { - fixedSegments: LinearElementEditor.restoreFixedSegments( - element, - element.x, - element.y, - ), - }); + mutateElement( + element, + { + fixedSegments: LinearElementEditor.restoreFixedSegments( + element, + element.x, + element.y, + ), + }, + true, + true, + ); } if (!isArrowElement(element)) { // skip arrow labels since we calculate its position during render diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index b2581cf01d39..cae0d9fdf363 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1563,7 +1563,6 @@ export class LinearElementEditor { updates, true, options?.isDragging, - false, options?.changedElements, ); } else { diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index 95b3d7b8a9ed..928ad38eb4b9 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -27,7 +27,6 @@ export const mutateElement = >( updates: ElementUpdate, informMutation = true, isDragging = false, - disableBinding = false, changedElements?: Map, ): TElement => { let didChange = false; @@ -37,8 +36,8 @@ export const mutateElement = >( const { points, fileId } = updates as any; if ( - typeof points !== "undefined" - //|| Object.hasOwn(updates, "fixedSegments") + typeof points !== "undefined" || + (!isDragging && Object.hasOwn(updates, "fixedSegments")) ) { if (isElbowArrow(element)) { const mergedElementsMap = toBrandedType( @@ -62,7 +61,6 @@ export const mutateElement = >( updates, { isDragging, - disableBinding, }, ), }; From 6d658e15eb4b0ceb3aa2d9ffeca751cbaa7101f8 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 13 Nov 2024 19:39:12 +0100 Subject: [PATCH 099/283] Duplication action fix --- .../excalidraw/actions/actionDuplicateSelection.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index d19bfa59d717..baf3e9ceff77 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -20,7 +20,11 @@ import { bindTextToShapeAfterDuplication, getBoundTextElement, } from "../element/textElement"; -import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks"; +import { + isBoundToContainer, + isElbowArrow, + isFrameLikeElement, +} from "../element/typeChecks"; import { normalizeElementOrder } from "../element/sortElements"; import { DuplicateIcon } from "../components/icons"; import { @@ -102,6 +106,12 @@ const duplicateElements = ( { x: element.x + DEFAULT_GRID_SIZE / 2, y: element.y + DEFAULT_GRID_SIZE / 2, + ...(isElbowArrow(element) + ? { + startBinding: null, + endBinding: null, + } + : {}), }, ); duplicatedElementsMap.set(newElement.id, newElement); From 22971c0eccd71b661183cee18b1560554b8e7cb4 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 14 Nov 2024 14:07:56 +0100 Subject: [PATCH 100/283] Broken multi-fixed segments --- packages/excalidraw/element/elbowarrow.ts | 214 +++++++++++++--------- 1 file changed, 127 insertions(+), 87 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 0882a33d2ad8..98c2a3852974 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -123,17 +123,17 @@ const generatePoints = memo( ( state: ElbowArrowState, points: readonly LocalPoint[], - segmentIdx: number, - totalSegments: number, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + startIsMidPoint?: Heading, + endIsMidPoint?: Heading, options?: { isDragging?: boolean; }, ) => routeElbowArrow(state, elementsMap, points, { ...options, - ...(segmentIdx !== 0 ? { startIsMidPoint: true } : {}), - ...(segmentIdx !== totalSegments ? { endIsMidPoint: true } : {}), + startIsMidPoint, + endIsMidPoint, }) ?? [], ); @@ -342,9 +342,11 @@ export const updateElbowArrowPoints = ( generatePoints( state, points, - idx, - pointPairs.length - 1, fakeElementsMap, + idx === 0 ? undefined : nextFixedSegments[idx - 1].heading, + idx === pointPairs.length - 1 + ? undefined + : nextFixedSegments[idx].heading, options, ), ), @@ -416,8 +418,8 @@ const getElbowArrowData = ( nextPoints: readonly LocalPoint[], options?: { isDragging?: boolean; - startIsMidPoint?: boolean; - endIsMidPoint?: boolean; + startIsMidPoint?: Heading; + endIsMidPoint?: Heading; }, ) => { const origStartGlobalPoint: GlobalPoint = pointTranslate< @@ -538,70 +540,78 @@ const getElbowArrowData = ( ? [startPointBounds, endPointBounds] : [startElementBounds, endElementBounds], ); - const dynamicAABBs = generateDynamicAABBs( - options?.startIsMidPoint - ? ([ - hoveredStartElement!.x + hoveredStartElement!.width / 2 - 1, - hoveredStartElement!.y + hoveredStartElement!.height / 2 - 1, - hoveredStartElement!.x + hoveredStartElement!.width / 2 + 1, - hoveredStartElement!.y + hoveredStartElement!.height / 2 + 1, - ] as Bounds) - : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) - ? startPointBounds - : startElementBounds, - options?.endIsMidPoint - ? ([ - hoveredEndElement!.x + hoveredEndElement!.width / 2 - 1, - hoveredEndElement!.y + hoveredEndElement!.height / 2 - 1, - hoveredEndElement!.x + hoveredEndElement!.width / 2 + 1, - hoveredEndElement!.y + hoveredEndElement!.height / 2 + 1, - ] as Bounds) - : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) - ? endPointBounds - : endElementBounds, - commonBounds, - options?.startIsMidPoint - ? [0, 0, 0, 0] - : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) - ? 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, + + const dynamicAABBs = !(options?.startIsMidPoint || options?.endIsMidPoint) + ? 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), + ) + : generateSegmentedDynamicAABBs( + padAABB( + startElementBounds, + options?.startIsMidPoint + ? [0, 0, 0, 0] + : offsetFromHeading( + startHeading, + BASE_PADDING - + (arrow.startArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2), + BASE_PADDING, + ), ), - options?.endIsMidPoint - ? [0, 0, 0, 0] - : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) - ? 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, + padAABB( + endElementBounds, + options?.endIsMidPoint + ? [0, 0, 0, 0] + : offsetFromHeading( + endHeading, + BASE_PADDING - + (arrow.endArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2), + BASE_PADDING, + ), ), - boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint), - hoveredStartElement && aabbForElement(hoveredStartElement), - hoveredEndElement && aabbForElement(hoveredEndElement), - options?.endIsMidPoint, - options?.startIsMidPoint, - ); + options.startIsMidPoint, + options.endIsMidPoint, + ); const startDonglePosition = getDonglePosition( dynamicAABBs[0], startHeading, @@ -613,11 +623,6 @@ const getElbowArrowData = ( endGlobalPoint, ); - // options?.endIsMidPoint && - // debugDrawBounds(dynamicAABBs[0], { color: "green", permanent: true }); - // options?.endIsMidPoint && - // debugDrawBounds(dynamicAABBs[1], { color: "red", permanent: true }); - return { dynamicAABBs, startDonglePosition, @@ -650,8 +655,8 @@ const routeElbowArrow = ( nextPoints: readonly LocalPoint[], options?: { isDragging?: boolean; - startIsMidPoint?: boolean; - endIsMidPoint?: boolean; + startIsMidPoint?: Heading; + endIsMidPoint?: Heading; }, ): GlobalPoint[] | null => { const { @@ -874,6 +879,43 @@ const pathTo = (start: Node, node: Node) => { const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]); +const padAABB = (bounds: Bounds, offset: [number, number, number, number]) => + [ + bounds[0] - offset[3], + bounds[1] - offset[0], + bounds[2] + offset[1], + bounds[3] + offset[2], + ] as Bounds; + +const generateSegmentedDynamicAABBs = ( + a: Bounds, + b: Bounds, + startIsMidPoint?: Heading, + endIsMidPoint?: Heading, +) => { + let first: Bounds = a; + let second: Bounds = b; + + if (startIsMidPoint) { + first = [ + Math.min(a[0], b[2]), + Math.min(a[1], b[3]), + Math.max(a[2], b[0]), + Math.max(a[3], b[1]), + ]; + } + if (endIsMidPoint) { + second = [ + Math.min(b[0], a[2]), + Math.min(b[1], a[3]), + Math.max(b[2], a[0]), + Math.max(b[3], a[1]), + ]; + } + + return [first, second]; +}; + /** * Create dynamically resizing, always touching * bounding boxes having a minimum extent represented @@ -888,8 +930,6 @@ const generateDynamicAABBs = ( disableSideHack?: boolean, startElementBounds?: Bounds | null, endElementBounds?: Bounds | null, - disableSlideUnderForFirst?: boolean, - disableSlideUnderForSecond?: boolean, ): Bounds[] => { const startEl = startElementBounds ?? a; const endEl = endElementBounds ?? b; @@ -900,28 +940,28 @@ const generateDynamicAABBs = ( const first = [ a[0] > b[2] - ? !disableSlideUnderForFirst && (a[1] > b[3] || a[3] < b[1]) + ? 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] - ? !disableSlideUnderForFirst && (a[0] > b[2] || a[2] < b[0]) + ? 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] - ? !disableSlideUnderForFirst && (a[1] > b[3] || a[3] < b[1]) + ? 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] - ? !disableSlideUnderForFirst && (a[0] > b[2] || a[2] < b[0]) + ? 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] @@ -930,28 +970,28 @@ const generateDynamicAABBs = ( ] as Bounds; const second = [ b[0] > a[2] - ? !disableSlideUnderForSecond && (b[1] > a[3] || b[3] < a[1]) + ? 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] - ? !disableSlideUnderForSecond && (b[0] > a[2] || b[2] < a[0]) + ? 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] - ? !disableSlideUnderForSecond && (b[1] > a[3] || b[3] < a[1]) + ? 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] - ? !disableSlideUnderForSecond && (b[0] > a[2] || b[2] < a[0]) + ? 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] From ca2396d29e49a32500b7e6535465d0c5fb65a691 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sat, 16 Nov 2024 20:00:15 +0100 Subject: [PATCH 101/283] Revert "Broken multi-fixed segments" This reverts commit 22971c0eccd71b661183cee18b1560554b8e7cb4. Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 214 +++++++++------------- 1 file changed, 87 insertions(+), 127 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 98c2a3852974..0882a33d2ad8 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -123,17 +123,17 @@ const generatePoints = memo( ( state: ElbowArrowState, points: readonly LocalPoint[], + segmentIdx: number, + totalSegments: number, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - startIsMidPoint?: Heading, - endIsMidPoint?: Heading, options?: { isDragging?: boolean; }, ) => routeElbowArrow(state, elementsMap, points, { ...options, - startIsMidPoint, - endIsMidPoint, + ...(segmentIdx !== 0 ? { startIsMidPoint: true } : {}), + ...(segmentIdx !== totalSegments ? { endIsMidPoint: true } : {}), }) ?? [], ); @@ -342,11 +342,9 @@ export const updateElbowArrowPoints = ( generatePoints( state, points, + idx, + pointPairs.length - 1, fakeElementsMap, - idx === 0 ? undefined : nextFixedSegments[idx - 1].heading, - idx === pointPairs.length - 1 - ? undefined - : nextFixedSegments[idx].heading, options, ), ), @@ -418,8 +416,8 @@ const getElbowArrowData = ( nextPoints: readonly LocalPoint[], options?: { isDragging?: boolean; - startIsMidPoint?: Heading; - endIsMidPoint?: Heading; + startIsMidPoint?: boolean; + endIsMidPoint?: boolean; }, ) => { const origStartGlobalPoint: GlobalPoint = pointTranslate< @@ -540,78 +538,70 @@ const getElbowArrowData = ( ? [startPointBounds, endPointBounds] : [startElementBounds, endElementBounds], ); - - const dynamicAABBs = !(options?.startIsMidPoint || options?.endIsMidPoint) - ? 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), - ) - : generateSegmentedDynamicAABBs( - padAABB( - startElementBounds, - options?.startIsMidPoint - ? [0, 0, 0, 0] - : offsetFromHeading( - startHeading, - BASE_PADDING - - (arrow.startArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2), - BASE_PADDING, - ), + const dynamicAABBs = generateDynamicAABBs( + options?.startIsMidPoint + ? ([ + hoveredStartElement!.x + hoveredStartElement!.width / 2 - 1, + hoveredStartElement!.y + hoveredStartElement!.height / 2 - 1, + hoveredStartElement!.x + hoveredStartElement!.width / 2 + 1, + hoveredStartElement!.y + hoveredStartElement!.height / 2 + 1, + ] as Bounds) + : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) + ? startPointBounds + : startElementBounds, + options?.endIsMidPoint + ? ([ + hoveredEndElement!.x + hoveredEndElement!.width / 2 - 1, + hoveredEndElement!.y + hoveredEndElement!.height / 2 - 1, + hoveredEndElement!.x + hoveredEndElement!.width / 2 + 1, + hoveredEndElement!.y + hoveredEndElement!.height / 2 + 1, + ] as Bounds) + : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) + ? endPointBounds + : endElementBounds, + commonBounds, + options?.startIsMidPoint + ? [0, 0, 0, 0] + : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) + ? 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, ), - padAABB( - endElementBounds, - options?.endIsMidPoint - ? [0, 0, 0, 0] - : offsetFromHeading( - endHeading, - BASE_PADDING - - (arrow.endArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2), - BASE_PADDING, - ), + options?.endIsMidPoint + ? [0, 0, 0, 0] + : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) + ? 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, ), - options.startIsMidPoint, - options.endIsMidPoint, - ); + boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint), + hoveredStartElement && aabbForElement(hoveredStartElement), + hoveredEndElement && aabbForElement(hoveredEndElement), + options?.endIsMidPoint, + options?.startIsMidPoint, + ); const startDonglePosition = getDonglePosition( dynamicAABBs[0], startHeading, @@ -623,6 +613,11 @@ const getElbowArrowData = ( endGlobalPoint, ); + // options?.endIsMidPoint && + // debugDrawBounds(dynamicAABBs[0], { color: "green", permanent: true }); + // options?.endIsMidPoint && + // debugDrawBounds(dynamicAABBs[1], { color: "red", permanent: true }); + return { dynamicAABBs, startDonglePosition, @@ -655,8 +650,8 @@ const routeElbowArrow = ( nextPoints: readonly LocalPoint[], options?: { isDragging?: boolean; - startIsMidPoint?: Heading; - endIsMidPoint?: Heading; + startIsMidPoint?: boolean; + endIsMidPoint?: boolean; }, ): GlobalPoint[] | null => { const { @@ -879,43 +874,6 @@ const pathTo = (start: Node, node: Node) => { const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]); -const padAABB = (bounds: Bounds, offset: [number, number, number, number]) => - [ - bounds[0] - offset[3], - bounds[1] - offset[0], - bounds[2] + offset[1], - bounds[3] + offset[2], - ] as Bounds; - -const generateSegmentedDynamicAABBs = ( - a: Bounds, - b: Bounds, - startIsMidPoint?: Heading, - endIsMidPoint?: Heading, -) => { - let first: Bounds = a; - let second: Bounds = b; - - if (startIsMidPoint) { - first = [ - Math.min(a[0], b[2]), - Math.min(a[1], b[3]), - Math.max(a[2], b[0]), - Math.max(a[3], b[1]), - ]; - } - if (endIsMidPoint) { - second = [ - Math.min(b[0], a[2]), - Math.min(b[1], a[3]), - Math.max(b[2], a[0]), - Math.max(b[3], a[1]), - ]; - } - - return [first, second]; -}; - /** * Create dynamically resizing, always touching * bounding boxes having a minimum extent represented @@ -930,6 +888,8 @@ const generateDynamicAABBs = ( disableSideHack?: boolean, startElementBounds?: Bounds | null, endElementBounds?: Bounds | null, + disableSlideUnderForFirst?: boolean, + disableSlideUnderForSecond?: boolean, ): Bounds[] => { const startEl = startElementBounds ?? a; const endEl = endElementBounds ?? b; @@ -940,28 +900,28 @@ const generateDynamicAABBs = ( const first = [ a[0] > b[2] - ? a[1] > b[3] || a[3] < b[1] + ? !disableSlideUnderForFirst && (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] + ? !disableSlideUnderForFirst && (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] + ? !disableSlideUnderForFirst && (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] + ? !disableSlideUnderForFirst && (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] @@ -970,28 +930,28 @@ const generateDynamicAABBs = ( ] as Bounds; const second = [ b[0] > a[2] - ? b[1] > a[3] || b[3] < a[1] + ? !disableSlideUnderForSecond && (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] + ? !disableSlideUnderForSecond && (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] + ? !disableSlideUnderForSecond && (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] + ? !disableSlideUnderForSecond && (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] From a8c13a376f21e5306a44cd826b8cdf7240786dc7 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sun, 17 Nov 2024 22:45:30 +0100 Subject: [PATCH 102/283] Simpler calc Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 205 +++++++++++++++------- 1 file changed, 137 insertions(+), 68 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 0882a33d2ad8..bcb33a3b6b95 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -538,70 +538,107 @@ const getElbowArrowData = ( ? [startPointBounds, endPointBounds] : [startElementBounds, endElementBounds], ); - const dynamicAABBs = generateDynamicAABBs( - options?.startIsMidPoint - ? ([ - hoveredStartElement!.x + hoveredStartElement!.width / 2 - 1, - hoveredStartElement!.y + hoveredStartElement!.height / 2 - 1, - hoveredStartElement!.x + hoveredStartElement!.width / 2 + 1, - hoveredStartElement!.y + hoveredStartElement!.height / 2 + 1, - ] as Bounds) - : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) - ? startPointBounds - : startElementBounds, - options?.endIsMidPoint - ? ([ - hoveredEndElement!.x + hoveredEndElement!.width / 2 - 1, - hoveredEndElement!.y + hoveredEndElement!.height / 2 - 1, - hoveredEndElement!.x + hoveredEndElement!.width / 2 + 1, - hoveredEndElement!.y + hoveredEndElement!.height / 2 + 1, - ] as Bounds) - : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) - ? endPointBounds - : endElementBounds, - commonBounds, - options?.startIsMidPoint - ? [0, 0, 0, 0] - : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) - ? 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, - ), - options?.endIsMidPoint - ? [0, 0, 0, 0] - : boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) - ? offsetFromHeading( - endHeading, - !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, - 0, + const dynamicAABBs = + options?.startIsMidPoint || options?.endIsMidPoint + ? generateSegmentedDynamicAABBs( + padAABB( + startElementBounds, + options?.startIsMidPoint + ? [0, 0, 0, 0] + : offsetFromHeading( + startHeading, + BASE_PADDING - + (arrow.startArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2), + BASE_PADDING, + ), + ), + padAABB( + endElementBounds, + options?.endIsMidPoint + ? [0, 0, 0, 0] + : offsetFromHeading( + endHeading, + BASE_PADDING - + (arrow.endArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2), + BASE_PADDING, + ), + ), + options?.startIsMidPoint, + options?.endIsMidPoint, ) - : offsetFromHeading( - endHeading, - !hoveredStartElement && !hoveredEndElement - ? 0 - : BASE_PADDING - - (arrow.endArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2), - BASE_PADDING, - ), - boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint), - hoveredStartElement && aabbForElement(hoveredStartElement), - hoveredEndElement && aabbForElement(hoveredEndElement), - options?.endIsMidPoint, - options?.startIsMidPoint, - ); + : generateDynamicAABBs( + options?.startIsMidPoint + ? ([ + hoveredStartElement!.x + hoveredStartElement!.width / 2 - 1, + hoveredStartElement!.y + hoveredStartElement!.height / 2 - 1, + hoveredStartElement!.x + hoveredStartElement!.width / 2 + 1, + hoveredStartElement!.y + hoveredStartElement!.height / 2 + 1, + ] as Bounds) + : boundsOverlap && + !(options?.startIsMidPoint || options?.endIsMidPoint) + ? startPointBounds + : startElementBounds, + options?.endIsMidPoint + ? ([ + hoveredEndElement!.x + hoveredEndElement!.width / 2 - 1, + hoveredEndElement!.y + hoveredEndElement!.height / 2 - 1, + hoveredEndElement!.x + hoveredEndElement!.width / 2 + 1, + hoveredEndElement!.y + hoveredEndElement!.height / 2 + 1, + ] as Bounds) + : boundsOverlap && + !(options?.startIsMidPoint || options?.endIsMidPoint) + ? endPointBounds + : endElementBounds, + commonBounds, + options?.startIsMidPoint + ? [0, 0, 0, 0] + : boundsOverlap && + !(options?.startIsMidPoint || options?.endIsMidPoint) + ? 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, + ), + options?.endIsMidPoint + ? [0, 0, 0, 0] + : boundsOverlap && + !(options?.startIsMidPoint || options?.endIsMidPoint) + ? 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 && + !(options?.startIsMidPoint || options?.endIsMidPoint), + hoveredStartElement && aabbForElement(hoveredStartElement), + hoveredEndElement && aabbForElement(hoveredEndElement), + options?.endIsMidPoint, + options?.startIsMidPoint, + ); const startDonglePosition = getDonglePosition( dynamicAABBs[0], startHeading, @@ -613,11 +650,6 @@ const getElbowArrowData = ( endGlobalPoint, ); - // options?.endIsMidPoint && - // debugDrawBounds(dynamicAABBs[0], { color: "green", permanent: true }); - // options?.endIsMidPoint && - // debugDrawBounds(dynamicAABBs[1], { color: "red", permanent: true }); - return { dynamicAABBs, startDonglePosition, @@ -874,6 +906,43 @@ const pathTo = (start: Node, node: Node) => { const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]); +const padAABB = (bounds: Bounds, offset: [number, number, number, number]) => + [ + bounds[0] - offset[3], + bounds[1] - offset[0], + bounds[2] + offset[1], + bounds[3] + offset[2], + ] as Bounds; + +const generateSegmentedDynamicAABBs = ( + a: Bounds, + b: Bounds, + startIsMidPoint?: boolean, + endIsMidPoint?: boolean, +) => { + let first: Bounds = a; + let second: Bounds = b; + + if (startIsMidPoint) { + first = [ + Math.min(a[0], b[2]), + Math.min(a[1], b[3]), + Math.max(a[2], b[0]), + Math.max(a[3], b[1]), + ]; + } + if (endIsMidPoint) { + second = [ + Math.min(b[0], a[2]), + Math.min(b[1], a[3]), + Math.max(b[2], a[0]), + Math.max(b[3], a[1]), + ]; + } + + return [first, second]; +}; + /** * Create dynamically resizing, always touching * bounding boxes having a minimum extent represented From dfbee40da7666930a12a1590237777f8e1582b86 Mon Sep 17 00:00:00 2001 From: Hamir Mahal Date: Mon, 11 Nov 2024 03:05:55 -0800 Subject: [PATCH 103/283] fix: usage of `node12 which is deprecated` (#8791) --- .github/workflows/semantic-pr-title.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/semantic-pr-title.yml b/.github/workflows/semantic-pr-title.yml index 969d23640703..34a6413fe2f2 100644 --- a/.github/workflows/semantic-pr-title.yml +++ b/.github/workflows/semantic-pr-title.yml @@ -11,6 +11,6 @@ jobs: semantic: runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v3.0.0 + - uses: amannn/action-semantic-pull-request@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 1f6d2ff2f21ac3da8e150e607c990fb14a663d86 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Mon, 11 Nov 2024 23:56:00 +0530 Subject: [PATCH 104/283] fix: cleanup scripts and support upto node 22 (#8794) --- excalidraw-app/package.json | 2 +- package.json | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/excalidraw-app/package.json b/excalidraw-app/package.json index 69fc1ec48883..53bf8e3a1ac0 100644 --- a/excalidraw-app/package.json +++ b/excalidraw-app/package.json @@ -23,7 +23,7 @@ ] }, "engines": { - "node": ">=18.0.0" + "node": "18.0.0 - 22.x.x" }, "dependencies": { "@excalidraw/random-username": "1.0.0", diff --git a/package.json b/package.json index df08cc2786b9..3bd87196e893 100644 --- a/package.json +++ b/package.json @@ -55,15 +55,9 @@ "build:app": "yarn --cwd ./excalidraw-app build:app", "build:version": "yarn --cwd ./excalidraw-app build:version", "build": "yarn --cwd ./excalidraw-app build", - "fix:code": "yarn test:code --fix", - "fix:other": "yarn prettier --write", - "fix": "yarn fix:other && yarn fix:code", - "locales-coverage": "node scripts/build-locales-coverage.js", - "locales-coverage:description": "node scripts/locales-coverage-description.js", - "prepare": "husky install", - "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", + "build:preview": "yarn --cwd ./excalidraw-app build:preview", "start": "yarn --cwd ./excalidraw-app start", - "start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o", + "start:production": "yarn --cwd ./excalidraw-app start:production", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false", "test:app": "vitest", "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", @@ -74,9 +68,15 @@ "test:coverage": "vitest --coverage", "test:coverage:watch": "vitest --coverage --watch", "test:ui": "yarn test --ui --coverage.enabled=true", + "fix:code": "yarn test:code --fix", + "fix:other": "yarn prettier --write", + "fix": "yarn fix:other && yarn fix:code", + "locales-coverage": "node scripts/build-locales-coverage.js", + "locales-coverage:description": "node scripts/locales-coverage-description.js", + "prepare": "husky install", + "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "autorelease": "node scripts/autorelease.js", "prerelease:excalidraw": "node scripts/prerelease.js", - "build:preview": "yarn build && vite preview --port 5000", "release:excalidraw": "node scripts/release.js", "rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/*/{build,dist}", "rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules", From 635cb1d0367b020f4aaf48342e2a1a35993a8e9e Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 18 Nov 2024 11:41:16 +0100 Subject: [PATCH 105/283] Fix simplification ordering issue --- packages/excalidraw/element/elbowarrow.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index bcb33a3b6b95..a0bf0e6e6b9b 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -336,8 +336,8 @@ export const updateElbowArrowPoints = ( ], ]); - const simplifiedPointGroups = removeElbowArrowShortSegments( - getElbowArrowCornerPoints( + const simplifiedPointGroups = getElbowArrowCornerPoints( + removeElbowArrowShortSegments( pointPairs.map(([state, points], idx) => generatePoints( state, @@ -1426,7 +1426,7 @@ const getElbowArrowCornerPoints = ( Math.abs(points[0][1] - points[1][1]) < Math.abs(points[0][0] - points[1][0]); - return multiDimensionalArrayDeepFilter(pointGroups, (p, idx) => { + const ret = multiDimensionalArrayDeepFilter(pointGroups, (p, idx) => { // The very first and last points are always kept if (idx === 0 || idx === points.length - 1) { return true; @@ -1443,6 +1443,8 @@ const getElbowArrowCornerPoints = ( previousHorizontal = nextHorizontal; return true; }); + + return ret; } return pointGroups; From 1e8c76bd8de1d04a90b76e70f7be16d3c5b85f9d Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 18 Nov 2024 13:37:37 +0100 Subject: [PATCH 106/283] Small refactor --- packages/excalidraw/element/elbowarrow.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index a0bf0e6e6b9b..be2725c4708c 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -180,9 +180,8 @@ export const updateElbowArrowPoints = ( const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = nextFixedSegments.map((segment, segmentIdx) => { + // Determine if we need to flip the heading for visual appeal let heading = segment.heading; - let anchor = segment.anchor; - if ( pointDistanceSq( updatedPoints[segment.index], @@ -210,6 +209,8 @@ export const updateElbowArrowPoints = ( } nextFixedSegments[segmentIdx].heading = heading; + // Calculate new anchor point (sliding anchor) + let anchor = segment.anchor; const prevSegment = nextFixedSegments[segmentIdx - 1]; const nextSegment = nextFixedSegments[segmentIdx + 1]; const candidateStart = prevSegment From a8e483042e0987c5317fb6bd1115915ebd1d2686 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 18 Nov 2024 19:10:33 +0100 Subject: [PATCH 107/283] New approach --- packages/excalidraw/element/elbowarrow.ts | 226 ++++++++++-------- .../excalidraw/element/linearElementEditor.ts | 2 +- 2 files changed, 126 insertions(+), 102 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index be2725c4708c..58bd90481235 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -1,3 +1,4 @@ +import type { LineSegment } from "../../math"; import { pointDistanceSq, pointFrom, @@ -21,6 +22,7 @@ import { toBrandedType, tupleToCoors, } from "../utils"; +import { debugDrawBounds, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -106,7 +108,11 @@ const segmentListMerge = ( const idx = fixedSegments.findIndex((s) => s.index === segment.index); if (idx > -1) { // Modify segment request - fixedSegments[idx] = segment; + fixedSegments[idx] = { + anchor: segment.anchor, + heading: fixedSegments[idx].heading, + index: segment.index, + }; } else { // Add segment request fixedSegments.push(segment); @@ -137,6 +143,42 @@ const generatePoints = memo( }) ?? [], ); +const pointCloseToSegment = ( + isHorizontal: boolean, + a: GlobalPoint, + b: GlobalPoint, + anchor: GlobalPoint, + sensitivity: number = 4, +) => { + if (isHorizontal) { + return ( + ((a[0] <= anchor[0] && b[0] > anchor[0]) || + (b[0] < anchor[0] && a[0] >= anchor[0])) && + Math.abs(a[1] - anchor[1]) < sensitivity + ); + } + + return ( + ((a[1] <= anchor[1] && b[1] > anchor[1]) || + (b[1] < anchor[1] && a[1] >= anchor[1])) && + Math.abs(a[0] - anchor[0]) < sensitivity + ); +}; + +const anchorPointToSegmentIndex = ( + points: GlobalPoint[], + anchor: GlobalPoint, +) => + points.slice(1).findIndex((next, idx) => { + const prev = points[idx]; + return pointCloseToSegment( + Math.abs(prev[1] - next[1]) < 1, + prev, + next, + anchor, + ); + }) + 1; + /** * */ @@ -154,7 +196,8 @@ export const updateElbowArrowPoints = ( const updatedPoints = updates.points ?? arrow.points; const fakeElementsMap = toBrandedType(new Map(elementsMap)); - let nextFixedSegments = segmentListMerge( + + const nextFixedSegments = segmentListMerge( arrow.fixedSegments ?? [], updates?.fixedSegments ?? [], ); @@ -165,76 +208,69 @@ export const updateElbowArrowPoints = ( updatedPoints, ); - let previousFixedSegment: { - point: GlobalPoint; - fixedPoint: FixedPointBinding["fixedPoint"] | null; - elementId: string | null; + let segmentStart: { + startPoint: GlobalPoint; + startFixedPoint: FixedPointBinding["fixedPoint"] | null; + startElementId: string | null; } = { - point: pointFrom( + startPoint: pointFrom( arrow.x + updatedPoints[0][0], arrow.y + updatedPoints[0][1], ), - fixedPoint: arrow.startBinding?.fixedPoint ?? null, - elementId: arrow.startBinding?.elementId ?? null, + startFixedPoint: arrow.startBinding?.fixedPoint ?? null, + startElementId: arrow.startBinding?.elementId ?? null, }; - const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = - nextFixedSegments.map((segment, segmentIdx) => { + nextFixedSegments.map((segmentEnd, segmentIdx) => { // Determine if we need to flip the heading for visual appeal - let heading = segment.heading; - if ( - pointDistanceSq( - updatedPoints[segment.index], - updatedPoints[segment.index - 1], - ) <= 4 || - (updatedPoints[segment.index - 2] && - compareHeading( - vectorToHeading( - vectorFromPoint( - updatedPoints[segment.index], - updatedPoints[segment.index - 1], - ), - ), - flipHeading( - vectorToHeading( - vectorFromPoint( - updatedPoints[segment.index - 1], - updatedPoints[segment.index - 2], - ), - ), - ), - )) - ) { - heading = flipHeading(heading); - } + const startHandle: GlobalPoint = headingIsHorizontal(segmentEnd.heading) + ? pointFrom( + nextFixedSegments[segmentIdx - 1]?.anchor[0] ?? + startDonglePosition[0], + segmentEnd.anchor[1], + ) + : pointFrom( + segmentEnd.anchor[0], + nextFixedSegments[segmentIdx - 1]?.anchor[1] ?? + startDonglePosition[1], + ); + const endHandle: GlobalPoint = headingIsHorizontal(segmentEnd.heading) + ? pointFrom( + nextFixedSegments[segmentIdx + 1]?.anchor[0] ?? + endDonglePosition[0], + segmentEnd.anchor[1], + ) + : pointFrom( + segmentEnd.anchor[0], + nextFixedSegments[segmentIdx + 1]?.anchor[1] ?? + endDonglePosition[1], + ); + const heading = headingIsHorizontal(segmentEnd.heading) + ? startHandle[0] < endHandle[0] + ? HEADING_LEFT + : HEADING_RIGHT + : startHandle[1] < endHandle[1] + ? HEADING_UP + : HEADING_DOWN; nextFixedSegments[segmentIdx].heading = heading; + // debugDrawPoint(startHandle, { color: "red" }); + // debugDrawPoint(endHandle, { color: "green" }); + // Calculate new anchor point (sliding anchor) - let anchor = segment.anchor; - const prevSegment = nextFixedSegments[segmentIdx - 1]; - const nextSegment = nextFixedSegments[segmentIdx + 1]; - const candidateStart = prevSegment + const anchor = headingIsHorizontal(heading) ? pointFrom( - arrow.x + updatedPoints[prevSegment.index][0], - arrow.y + updatedPoints[prevSegment.index][1], + (startHandle[0] + endHandle[0]) / 2, + segmentEnd.anchor[1], ) - : startDonglePosition; - const candidateEnd = nextSegment - ? pointFrom( - arrow.x + updatedPoints[nextSegment.index][0], - arrow.y + updatedPoints[nextSegment.index][1], - ) - : endDonglePosition; - anchor = pointFrom( - headingIsHorizontal(heading) - ? (candidateStart[0] + candidateEnd[0]) / 2 - : anchor[0], - headingIsVertical(heading) - ? (candidateStart[1] + candidateEnd[1]) / 2 - : anchor[1], - ); + : pointFrom( + segmentEnd.anchor[0], + (startHandle[1] + endHandle[1]) / 2, + ); nextFixedSegments[segmentIdx].anchor = anchor; + // debugDrawPoint(anchor); + const el = { ...newElement({ type: "rectangle", @@ -246,6 +282,7 @@ export const updateElbowArrowPoints = ( index: "DONOTSYNC" as FractionalIndex, } as Ordered; fakeElementsMap.set(el.id, el); + debugDrawBounds(aabbForElement(el), { color: "green" }); const endFixedPoint: [number, number] = compareHeading( heading, @@ -259,18 +296,18 @@ export const updateElbowArrowPoints = ( : [1, 0.5]; const state = { - x: previousFixedSegment.point[0], - y: previousFixedSegment.point[1], + x: segmentStart.startPoint[0], + y: segmentStart.startPoint[1], startArrowhead: segmentIdx === 0 ? arrow.startArrowhead : null, endArrowhead: null as Arrowhead | null, startBinding: segmentIdx === 0 ? arrow.startBinding : { - elementId: previousFixedSegment.elementId!, + elementId: segmentStart.startElementId!, focus: 0, gap: 0, - fixedPoint: previousFixedSegment.fixedPoint!, + fixedPoint: segmentStart.startFixedPoint!, }, endBinding: { elementId: el.id, @@ -294,32 +331,35 @@ export const updateElbowArrowPoints = ( el, ); const endLocalPoint = pointFrom( - segmentEndGlobalPoint[0] - previousFixedSegment.point[0], - segmentEndGlobalPoint[1] - previousFixedSegment.point[1], + segmentEndGlobalPoint[0] - segmentStart.startPoint[0], + segmentEndGlobalPoint[1] - segmentStart.startPoint[1], ); - previousFixedSegment = { - point: getGlobalFixedPointForBindableElement(nextStartFixedPoint, el), - fixedPoint: nextStartFixedPoint, - elementId: el.id, + segmentStart = { + startPoint: getGlobalFixedPointForBindableElement( + nextStartFixedPoint, + el, + ), + startFixedPoint: nextStartFixedPoint, + startElementId: el.id, }; return [state, [pointFrom(0, 0), endLocalPoint]]; }); pointPairs.push([ { - x: previousFixedSegment.point[0], - y: previousFixedSegment.point[1], + x: segmentStart.startPoint[0], + y: segmentStart.startPoint[1], startArrowhead: null, endArrowhead: arrow.endArrowhead, startBinding: nextFixedSegments.length === 0 ? arrow.startBinding : { - elementId: previousFixedSegment.elementId!, + elementId: segmentStart.startElementId!, focus: 0, gap: 0, - fixedPoint: previousFixedSegment.fixedPoint!, + fixedPoint: segmentStart.startFixedPoint!, }, endBinding: arrow.endBinding, }, @@ -329,15 +369,15 @@ export const updateElbowArrowPoints = ( pointFrom( arrow.x + updatedPoints[updatedPoints.length - 1][0] - - previousFixedSegment.point[0], + segmentStart.startPoint[0], arrow.y + updatedPoints[updatedPoints.length - 1][1] - - previousFixedSegment.point[1], + segmentStart.startPoint[1], ), ], ]); - const simplifiedPointGroups = getElbowArrowCornerPoints( + const simplifiedPoints = getElbowArrowCornerPoints( removeElbowArrowShortSegments( pointPairs.map(([state, points], idx) => generatePoints( @@ -350,34 +390,18 @@ export const updateElbowArrowPoints = ( ), ), ), - ); - - let currentGroupIdx = 0; - nextFixedSegments = multiDimensionalArrayDeepFlatMapper< - GlobalPoint, - FixedSegment | null - >(simplifiedPointGroups, (point, [groupIdx], points, index) => { - if (currentGroupIdx < groupIdx) { - // Watch for the case when point group idx changes, - // that's where we need to generate a fixed segment - // i.e. we are the first point of the group excluding the first group - currentGroupIdx = groupIdx; - - return { - anchor: nextFixedSegments[currentGroupIdx - 1].anchor, - index, - heading: nextFixedSegments[currentGroupIdx - 1].heading, - }; - } - - return null; - }).filter( - (segment): segment is FixedSegment => segment != null, - ) as Sequential; + ).flat(); return normalizeArrowElementUpdate( - simplifiedPointGroups.flat(), - nextFixedSegments, + simplifiedPoints, + nextFixedSegments.map((segment, idx) => { + segment.index = + anchorPointToSegmentIndex(simplifiedPoints, segment.anchor) ?? -1; + console.log(segment.index, JSON.stringify(segment.anchor)); + debugDrawPoint(segment.anchor, { permanent: true }); + segment.index === -1 && console.error(idx, "No segment found?"); + return segment; + }) as Sequential, ); }; diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index cae0d9fdf363..e83da1a816c0 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1860,7 +1860,7 @@ export class LinearElementEditor { elementsMap, ); - if (!element || !segmentIdx) { + if (!element || !segmentIdx || segmentIdx < 1) { return linearElementEditor; } From cab7653ff1d67633855cfbf357e0f88b70d44834 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 19 Nov 2024 16:33:53 +0100 Subject: [PATCH 108/283] anchor to segment differently --- packages/excalidraw/element/elbowarrow.ts | 73 +++++++++++------------ 1 file changed, 36 insertions(+), 37 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 58bd90481235..7d14a6dd8fbc 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -1,5 +1,7 @@ import type { LineSegment } from "../../math"; import { + distanceToLineSegment, + lineSegment, pointDistanceSq, pointFrom, pointScaleFromOrigin, @@ -143,42 +145,35 @@ const generatePoints = memo( }) ?? [], ); -const pointCloseToSegment = ( - isHorizontal: boolean, - a: GlobalPoint, - b: GlobalPoint, +const anchorPointToSegmentIndex = ( + points: GlobalPoint[], anchor: GlobalPoint, - sensitivity: number = 4, + anchorIsHorizontal: boolean, ) => { - if (isHorizontal) { - return ( - ((a[0] <= anchor[0] && b[0] > anchor[0]) || - (b[0] < anchor[0] && a[0] >= anchor[0])) && - Math.abs(a[1] - anchor[1]) < sensitivity - ); - } + let closestDistance = Infinity; + return points + .slice(1) + .map( + (point, idx) => + [ + distanceToLineSegment(anchor, lineSegment(points[idx], point)), + Math.abs(points[idx][1] - point[1]) < 1, + idx, + ] as [number, boolean, number], + ) + .filter( + ([_, segmentIsHorizontal]) => segmentIsHorizontal === anchorIsHorizontal, + ) + .reduce((acc, [distance, _, idx]) => { + if (distance < closestDistance) { + closestDistance = distance; + return idx + 1; + } - return ( - ((a[1] <= anchor[1] && b[1] > anchor[1]) || - (b[1] < anchor[1] && a[1] >= anchor[1])) && - Math.abs(a[0] - anchor[0]) < sensitivity - ); + return acc; + }, 0); }; -const anchorPointToSegmentIndex = ( - points: GlobalPoint[], - anchor: GlobalPoint, -) => - points.slice(1).findIndex((next, idx) => { - const prev = points[idx]; - return pointCloseToSegment( - Math.abs(prev[1] - next[1]) < 1, - prev, - next, - anchor, - ); - }) + 1; - /** * */ @@ -220,6 +215,7 @@ export const updateElbowArrowPoints = ( startFixedPoint: arrow.startBinding?.fixedPoint ?? null, startElementId: arrow.startBinding?.elementId ?? null, }; + const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = nextFixedSegments.map((segmentEnd, segmentIdx) => { // Determine if we need to flip the heading for visual appeal @@ -269,7 +265,7 @@ export const updateElbowArrowPoints = ( ); nextFixedSegments[segmentIdx].anchor = anchor; - // debugDrawPoint(anchor); + debugDrawPoint(anchor); const el = { ...newElement({ @@ -282,7 +278,6 @@ export const updateElbowArrowPoints = ( index: "DONOTSYNC" as FractionalIndex, } as Ordered; fakeElementsMap.set(el.id, el); - debugDrawBounds(aabbForElement(el), { color: "green" }); const endFixedPoint: [number, number] = compareHeading( heading, @@ -395,10 +390,14 @@ export const updateElbowArrowPoints = ( return normalizeArrowElementUpdate( simplifiedPoints, nextFixedSegments.map((segment, idx) => { - segment.index = - anchorPointToSegmentIndex(simplifiedPoints, segment.anchor) ?? -1; - console.log(segment.index, JSON.stringify(segment.anchor)); - debugDrawPoint(segment.anchor, { permanent: true }); + segment.index = anchorPointToSegmentIndex( + simplifiedPoints, + segment.anchor, + headingIsHorizontal(segment.heading), + ); + if (segment.index === 0) { + debugDrawPoint(segment.anchor, { color: "red", permanent: true }); + } segment.index === -1 && console.error(idx, "No segment found?"); return segment; }) as Sequential, From d443dc2f6e83a4442129dda72c6c2ed5210beae0 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 19 Nov 2024 17:51:11 +0100 Subject: [PATCH 109/283] Start/end fix fix Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 20 +++++++++++++++---- .../excalidraw/element/linearElementEditor.ts | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 7d14a6dd8fbc..aa09fffee9c2 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -1,4 +1,3 @@ -import type { LineSegment } from "../../math"; import { distanceToLineSegment, lineSegment, @@ -188,7 +187,7 @@ export const updateElbowArrowPoints = ( isDragging?: boolean; }, ): ElementUpdate => { - const updatedPoints = updates.points ?? arrow.points; + const updatedPoints = Array.from(updates.points ?? arrow.points); const fakeElementsMap = toBrandedType(new Map(elementsMap)); @@ -203,6 +202,12 @@ export const updateElbowArrowPoints = ( updatedPoints, ); + // Start segment is getting fixed, need to fix the first point to origin + if (nextFixedSegments[0]?.index === 1) { + nextFixedSegments[0].index = 2; + updatedPoints.unshift(pointFrom(0, 0)); + } + let segmentStart: { startPoint: GlobalPoint; startFixedPoint: FixedPointBinding["fixedPoint"] | null; @@ -389,16 +394,23 @@ export const updateElbowArrowPoints = ( return normalizeArrowElementUpdate( simplifiedPoints, - nextFixedSegments.map((segment, idx) => { + nextFixedSegments.map((segment) => { segment.index = anchorPointToSegmentIndex( simplifiedPoints, segment.anchor, headingIsHorizontal(segment.heading), ); + + // TODO: Debug only, remove if (segment.index === 0) { + console.warn("Segment index is 0!"); debugDrawPoint(segment.anchor, { color: "red", permanent: true }); } - segment.index === -1 && console.error(idx, "No segment found?"); + + if (segment.index === 1) { + segment.index = 2; + } + return segment; }) as Sequential, ); diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index e83da1a816c0..cae0d9fdf363 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1860,7 +1860,7 @@ export class LinearElementEditor { elementsMap, ); - if (!element || !segmentIdx || segmentIdx < 1) { + if (!element || !segmentIdx) { return linearElementEditor; } From 5cae7ed462af9e25cca1f6491fee96db00cf2353 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 19 Nov 2024 17:58:51 +0100 Subject: [PATCH 110/283] End fix fix Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index aa09fffee9c2..837fa56f7371 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -202,7 +202,18 @@ export const updateElbowArrowPoints = ( updatedPoints, ); - // Start segment is getting fixed, need to fix the first point to origin + // End segment is getting fixed - must happen before start segment move check! + if ( + nextFixedSegments[nextFixedSegments.length - 1]?.index === + updatedPoints.length - 1 + ) { + nextFixedSegments[nextFixedSegments.length - 1].index = + updatedPoints.length - 2; + updatedPoints.push(arrow.points[arrow.points.length - 1]); + console.log("End segment is getting fixed"); + } + + // Start segment is getting fixed if (nextFixedSegments[0]?.index === 1) { nextFixedSegments[0].index = 2; updatedPoints.unshift(pointFrom(0, 0)); @@ -411,6 +422,11 @@ export const updateElbowArrowPoints = ( segment.index = 2; } + if (segment.index === simplifiedPoints.length - 1) { + console.log("Finalize end fixed segment"); + segment.index = simplifiedPoints.length - 2; + } + return segment; }) as Sequential, ); From 88f37bc009781cbdb07e32f04984317e0eab75fd Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 19 Nov 2024 18:32:52 +0100 Subject: [PATCH 111/283] Reduce minimum segment removal distance Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 837fa56f7371..2e12c1aa1dfd 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -1,6 +1,7 @@ import { distanceToLineSegment, lineSegment, + pointDistance, pointDistanceSq, pointFrom, pointScaleFromOrigin, @@ -210,7 +211,6 @@ export const updateElbowArrowPoints = ( nextFixedSegments[nextFixedSegments.length - 1].index = updatedPoints.length - 2; updatedPoints.push(arrow.points[arrow.points.length - 1]); - console.log("End segment is getting fixed"); } // Start segment is getting fixed @@ -231,6 +231,8 @@ export const updateElbowArrowPoints = ( startFixedPoint: arrow.startBinding?.fixedPoint ?? null, startElementId: arrow.startBinding?.elementId ?? null, }; + debugDrawPoint(startDonglePosition, { color: "red" }); + debugDrawPoint(endDonglePosition, { color: "green" }); const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = nextFixedSegments.map((segmentEnd, segmentIdx) => { @@ -266,9 +268,6 @@ export const updateElbowArrowPoints = ( : HEADING_DOWN; nextFixedSegments[segmentIdx].heading = heading; - // debugDrawPoint(startHandle, { color: "red" }); - // debugDrawPoint(endHandle, { color: "green" }); - // Calculate new anchor point (sliding anchor) const anchor = headingIsHorizontal(heading) ? pointFrom( @@ -281,6 +280,8 @@ export const updateElbowArrowPoints = ( ); nextFixedSegments[segmentIdx].anchor = anchor; + debugDrawPoint(startHandle, { color: "red" }); + debugDrawPoint(endHandle, { color: "green" }); debugDrawPoint(anchor); const el = { @@ -423,7 +424,6 @@ export const updateElbowArrowPoints = ( } if (segment.index === simplifiedPoints.length - 1) { - console.log("Finalize end fixed segment"); segment.index = simplifiedPoints.length - 2; } @@ -1514,7 +1514,7 @@ const removeElbowArrowShortSegments = ( } const prev = points[idx - 1]; - return pointDistanceSq(prev, p) >= 1; + return pointDistance(prev, p) > 0.2; }); } From 907307acd421189aeef1e23690983db8a45b7031 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 19 Nov 2024 18:33:44 +0100 Subject: [PATCH 112/283] Fix lint Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 2e12c1aa1dfd..0e9e5f8c1456 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -2,7 +2,6 @@ import { distanceToLineSegment, lineSegment, pointDistance, - pointDistanceSq, pointFrom, pointScaleFromOrigin, pointTranslate, @@ -20,11 +19,10 @@ import { isAnyTrue, memo, multiDimensionalArrayDeepFilter, - multiDimensionalArrayDeepFlatMapper, toBrandedType, tupleToCoors, } from "../utils"; -import { debugDrawBounds, debugDrawPoint } from "../visualdebug"; +import { debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -45,7 +43,6 @@ import { HEADING_RIGHT, HEADING_UP, headingIsHorizontal, - headingIsVertical, vectorToHeading, } from "./heading"; import type { ElementUpdate } from "./mutateElement"; From afcadb1684558af9b35cbb9e31d6a9bc417a0ff1 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 19 Nov 2024 18:43:38 +0100 Subject: [PATCH 113/283] Test fixes Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.test.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.test.tsx b/packages/excalidraw/element/elbowarrow.test.tsx index bdf95f04c35a..d229dbc70e97 100644 --- a/packages/excalidraw/element/elbowarrow.test.tsx +++ b/packages/excalidraw/element/elbowarrow.test.tsx @@ -199,6 +199,7 @@ describe("elbow arrow ui", () => { [0, 0], [35, 0], [35, 90], + [35, 90], [35, 165], [103, 165], ]); @@ -239,11 +240,12 @@ describe("elbow arrow segment move", () => { h.state, )[0] as ExcalidrawArrowElement; + console.log(arrow.points); expect(arrow.points).toCloselyEqualPoints([ [0, 0], - [105, 0], - [105, 148.55], - [200, 148.55], + [105.00002, 0], + [105.00002, 188], + [200, 188], [200, 200], ]); @@ -253,9 +255,9 @@ describe("elbow arrow segment move", () => { expect(arrow.points).toCloselyEqualPoints([ [0, 0], - [0, -2], - [100, -2], - [100, 200], + [105.00002, 0], + [105.00002, 188], + [200, 188], [200, 200], ]); }); From 79bfd58c095ff7185e6389847de31037e934af6c Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 19 Nov 2024 18:44:13 +0100 Subject: [PATCH 114/283] Lint fix Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/excalidraw/element/elbowarrow.test.tsx b/packages/excalidraw/element/elbowarrow.test.tsx index d229dbc70e97..5e3129eaf295 100644 --- a/packages/excalidraw/element/elbowarrow.test.tsx +++ b/packages/excalidraw/element/elbowarrow.test.tsx @@ -240,7 +240,6 @@ describe("elbow arrow segment move", () => { h.state, )[0] as ExcalidrawArrowElement; - console.log(arrow.points); expect(arrow.points).toCloselyEqualPoints([ [0, 0], [105.00002, 0], From c8974069c8d70ff9b1d80c2f3104b203c50af5a7 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 20 Nov 2024 19:42:35 +0100 Subject: [PATCH 115/283] Fix midpoint click misses Signed-off-by: Mark Tolmacs --- packages/excalidraw/components/App.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 962566c81f5e..972473bdbb3e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -6925,9 +6925,16 @@ class App extends React.Component { ); } } else { - if (this.state.selectedLinearElement) { - const linearElementEditor = - this.state.editingLinearElement || this.state.selectedLinearElement; + let linearElementEditor = + this.state.editingLinearElement || this.state.selectedLinearElement; + const elementUnderCursor = this.getElementAtPosition( + pointerDownState.origin.x, + pointerDownState.origin.y, + ); + if (elementUnderCursor && isElbowArrow(elementUnderCursor)) { + linearElementEditor = new LinearElementEditor(elementUnderCursor); + } + if (linearElementEditor) { const ret = LinearElementEditor.handlePointerDown( event, this, From e3d773ae2c275de9165fc6f5e9192c364d0960e7 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 20 Nov 2024 18:54:37 +0100 Subject: [PATCH 116/283] Added more midpoint cache updates Signed-off-by: Mark Tolmacs --- packages/excalidraw/actions/actionFlip.ts | 12 ++++-- packages/excalidraw/components/App.tsx | 40 ++++++++++++++----- .../excalidraw/element/linearElementEditor.ts | 35 ++++++++++++++++ packages/excalidraw/element/resizeElements.ts | 2 +- 4 files changed, 74 insertions(+), 15 deletions(-) diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index cc7cb9492bd7..777d4eab1fd3 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -26,6 +26,7 @@ import { isLinearElement, } from "../element/typeChecks"; import { mutateElement, newElementWith } from "../element/mutateElement"; +import { LinearElementEditor } from "../element/linearElementEditor"; export const actionFlipHorizontal = register({ name: "flipHorizontal", @@ -168,9 +169,14 @@ const flipElements = ( [], ); - elbowArrows.forEach((elbowArrow) => - mutateElement(elbowArrow, { points: elbowArrow.points }), - ); + elbowArrows.forEach((elbowArrow) => { + mutateElement(elbowArrow, { points: elbowArrow.points }); + LinearElementEditor.updateEditorMidPointsCache( + elbowArrow, + elementsMap, + app.state, + ); + }); return selectedElements; }; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 972473bdbb3e..b7deb5342dd9 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -6964,7 +6964,6 @@ class App extends React.Component { pointerDownState.origin.x, pointerDownState.origin.y, ); - if ( this.state.croppingElementId && pointerDownState.hit.element?.id !== this.state.croppingElementId @@ -7848,7 +7847,6 @@ class App extends React.Component { } } const elementsMap = this.scene.getNonDeletedElementsMap(); - if (this.state.selectedLinearElement) { const linearElementEditor = this.state.editingLinearElement || this.state.selectedLinearElement; @@ -8015,13 +8013,6 @@ class App extends React.Component { ) { const selectedElements = this.scene.getSelectedElements(this.state); - // if ( - // selectedElements.length === 1 && - // isElbowArrow(selectedElements[0]) - // ) { - // return; - // } - if (selectedElements.every((element) => element.locked)) { return; } @@ -8193,6 +8184,15 @@ class App extends React.Component { snapOffset, event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), ); + selectedElements + .filter(isElbowArrow) + .forEach((element) => + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + this.state, + ), + ); this.setState({ selectedElementsAreBeingDragged: true, @@ -8710,6 +8710,13 @@ class App extends React.Component { pressures, lastCommittedPoint: pointFrom(dx, dy), }); + if (isElbowArrow(newElement)) { + LinearElementEditor.updateEditorMidPointsCache( + newElement, + elementsMap, + this.state, + ); + } this.actionManager.executeAction(actionFinalize); @@ -8778,6 +8785,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), ); } + this.setState({ suggestedBindings: [], startBoundElement: null }); if (!activeTool.locked) { resetCursor(this.interactiveCanvas); @@ -9052,10 +9060,10 @@ class App extends React.Component { this.state.selectedLinearElement?.elementId !== hitElement?.id && isLinearElement(hitElement) ) { - const selectedELements = this.scene.getSelectedElements(this.state); + const selectedElements = this.scene.getSelectedElements(this.state); // set selectedLinearElement when no other element selected except // the one we've hit - if (selectedELements.length === 1) { + if (selectedElements.length === 1) { this.setState({ selectedLinearElement: new LinearElementEditor(hitElement), }); @@ -10492,6 +10500,16 @@ class App extends React.Component { this.setState({ flippedFixedPointBindings }), ); + selectedElements + .filter(isElbowArrow) + .forEach((element) => + LinearElementEditor.updateEditorMidPointsCache( + element, + this.scene.getNonDeletedElementsMap(), + this.state, + ), + ); + if (transformed) { const suggestedBindings = getSuggestedBindingsForArrows( selectedElements, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index cae0d9fdf363..83aa69f33901 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -309,6 +309,13 @@ export class LinearElementEditor { isDragging: selectedIndex === lastClickedPoint, }, ]); + if (isElbowArrow(element)) { + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + app.state, + ); + } } else { const newDraggingPointPosition = LinearElementEditor.createPointAt( element, @@ -344,6 +351,13 @@ export class LinearElementEditor { }; }), ); + if (isElbowArrow(element)) { + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + app.state, + ); + } } const boundTextElement = getBoundTextElement(element, elementsMap); @@ -434,6 +448,13 @@ export class LinearElementEditor { : element.points[0], }, ]); + if (isElbowArrow(element)) { + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + appState, + ); + } } const bindingElement = isBindingEnabled(appState) @@ -941,6 +962,13 @@ export class LinearElementEditor { point: newPoint, }, ]); + if (isElbowArrow(element)) { + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + appState, + ); + } } else { LinearElementEditor.addPoints(element, [{ point: newPoint }]); } @@ -1177,6 +1205,13 @@ export class LinearElementEditor { point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30), }, ]); + if (isElbowArrow(element)) { + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + appState, + ); + } } return { diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index a91b015c47c8..4fc27be503ad 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -460,7 +460,7 @@ const resizeSingleTextElement = ( } }; -export const resizeSingleElement = ( +const resizeSingleElement = ( originalElements: PointerDownState["originalElements"], shouldMaintainAspectRatio: boolean, element: NonDeletedExcalidrawElement, From 66fe0a28d5a5c775c01c22c700344d7e93db795f Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 21 Nov 2024 14:59:06 +0100 Subject: [PATCH 117/283] Override too short fixed segment --- packages/excalidraw/element/elbowarrow.ts | 10 +++------ .../excalidraw/renderer/interactiveScene.ts | 21 ++++++++++++++++--- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 0e9e5f8c1456..463a8d326748 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -101,17 +101,13 @@ const segmentListMerge = ( let fixedSegments = Array.from(oldFixedSegments); newFixedSegments.forEach((segment) => { if (segment.anchor == null) { - // Delete segment request + // Delete segment fixedSegments = fixedSegments.filter((s) => s.index !== segment.index); } else { const idx = fixedSegments.findIndex((s) => s.index === segment.index); if (idx > -1) { - // Modify segment request - fixedSegments[idx] = { - anchor: segment.anchor, - heading: fixedSegments[idx].heading, - index: segment.index, - }; + // Update segment + fixedSegments[idx] = segment; } else { // Add segment request fixedSegments.push(segment); diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 79acd03e4bcd..7f380c3eafc1 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -81,6 +81,7 @@ import { type Radians, } from "../../math"; import { getCornerRadius } from "../shapes"; +import { headingIsHorizontal } from "../element/heading"; const renderLinearElementPointHighlight = ( context: CanvasRenderingContext2D, @@ -510,9 +511,18 @@ const renderLinearPointHandles = ( // Rendering segment mid points if (isElbowArrow(element)) { - const indices = element.fixedSegments?.map((s) => s.index) ?? []; const fixedPoints = element.points.slice(0, -1).map((p, i) => { - return indices.includes(i + 1); + const fixedSegmentIdx = + element.fixedSegments?.findIndex( + (fixedSegment) => fixedSegment.index === i + 1, + ) ?? -1; + if (fixedSegmentIdx > -1) { + return headingIsHorizontal( + element.fixedSegments![fixedSegmentIdx].heading, + ); + } + + return null; }); const globalPoints = element.points.map((p) => pointFrom(element.x + p[0], element.y + p[1]), @@ -526,6 +536,9 @@ const renderLinearPointHandles = ( appState.zoom, ) ) { + const isHorizontal = + Math.abs(p[0] - globalPoints[idx + 1][0]) > + Math.abs(p[1] - globalPoints[idx + 1][1]); renderSingleLinearPoint( context, appState, @@ -535,7 +548,9 @@ const renderLinearPointHandles = ( ), POINT_HANDLE_SIZE / 2, false, - !fixedPoints[idx], + !(fixedPoints[idx] !== null + ? fixedPoints[idx] === isHorizontal + : false), ); } }); From 244e6c2d24f74053330220a23f5e8651b03a9395 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 21 Nov 2024 15:19:56 +0100 Subject: [PATCH 118/283] Fix disconnecting elbow arrow with fixed segment --- packages/excalidraw/element/elbowarrow.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 463a8d326748..6abd312a15bc 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -194,6 +194,7 @@ export const updateElbowArrowPoints = ( arrow, elementsMap, updatedPoints, + options, ); // End segment is getting fixed - must happen before start segment move check! From 1eb6bc527042415c04f4cf7390d0bcad80660cab Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 21 Nov 2024 16:28:36 +0100 Subject: [PATCH 119/283] Skip inverted segments --- packages/excalidraw/components/App.tsx | 1 + packages/excalidraw/element/elbowarrow.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 50d7cfa007a7..f3b49a184df6 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2564,6 +2564,7 @@ class App extends React.Component { this.excalidrawContainerRef.current, EVENT.WHEEL, this.handleWheel, + { passive: false }, ), addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false), addEventListener(document, EVENT.POINTER_UP, this.removePointer, { diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 6abd312a15bc..7b9adf958fb3 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -1508,7 +1508,7 @@ const removeElbowArrowShortSegments = ( } const prev = points[idx - 1]; - return pointDistance(prev, p) > 0.2; + return pointDistance(prev, p) > 0.3; }); } From 953e1bae2a58ec50a60b27e12a91fd6e9f8cbef2 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 21 Nov 2024 16:36:08 +0100 Subject: [PATCH 120/283] Fix lint --- packages/excalidraw/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 3245d10ebd4f..922fb121dbb0 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -137,4 +137,4 @@ "build:example": "node ../../scripts/buildExample.mjs", "size": "yarn build:umd && size-limit" } -} \ No newline at end of file +} From 946d573a9d248caaa6dffc946f4b2faa6f74e5c4 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 21 Nov 2024 18:15:38 +0100 Subject: [PATCH 121/283] Drag distance checking --- packages/excalidraw/components/App.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index f3b49a184df6..a48a85bcfce6 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7917,7 +7917,11 @@ class App extends React.Component { return; } else if ( linearElementEditor.elbowed && - linearElementEditor.pointerDownState.segmentMidpoint.index + linearElementEditor.pointerDownState.segmentMidpoint.index && + pointDistance( + pointFrom(pointerCoords.x, pointerCoords.y), + pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), + ) >= DRAGGING_THRESHOLD ) { const [gridX, gridY] = getGridPoint( pointerCoords.x, From 244beef5bb9da783e7d77325604ba00c307d8946 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 21 Nov 2024 20:36:07 +0100 Subject: [PATCH 122/283] Aligned segment jump fix --- packages/excalidraw/components/App.tsx | 5 +- .../excalidraw/element/elbowarrow.test.tsx | 20 +- packages/excalidraw/element/elbowarrow.ts | 46 +---- .../excalidraw/element/linearElementEditor.ts | 187 +++++++++++++----- 4 files changed, 156 insertions(+), 102 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index a48a85bcfce6..8ce402f42cc5 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7933,6 +7933,7 @@ class App extends React.Component { { x: gridX, y: gridY }, elementsMap, this.state, + linearElementEditor.pointerDownState.segmentMidpoint.added, ); const element = LinearElementEditor.getElement( this.state.selectedLinearElement.elementId, @@ -7948,7 +7949,9 @@ class App extends React.Component { if ( this.state.selectedLinearElement.pointerDownState.segmentMidpoint - .index !== ret.pointerDownState.segmentMidpoint.index + .index !== ret.pointerDownState.segmentMidpoint.index || + this.state.selectedLinearElement.pointerDownState.segmentMidpoint + .added !== ret.pointerDownState.segmentMidpoint.added ) { flushSync(() => { if (this.state.selectedLinearElement) { diff --git a/packages/excalidraw/element/elbowarrow.test.tsx b/packages/excalidraw/element/elbowarrow.test.tsx index 5e3129eaf295..f4808cad48b1 100644 --- a/packages/excalidraw/element/elbowarrow.test.tsx +++ b/packages/excalidraw/element/elbowarrow.test.tsx @@ -242,9 +242,9 @@ describe("elbow arrow segment move", () => { expect(arrow.points).toCloselyEqualPoints([ [0, 0], - [105.00002, 0], - [105.00002, 188], - [200, 188], + [0, -2], + [100, -2], + [100, 200], [200, 200], ]); @@ -254,9 +254,9 @@ describe("elbow arrow segment move", () => { expect(arrow.points).toCloselyEqualPoints([ [0, 0], - [105.00002, 0], - [105.00002, 188], - [200, 188], + [0, -2], + [100, -2], + [100, 200], [200, 200], ]); }); @@ -296,8 +296,8 @@ describe("elbow arrow segment move", () => { expect(arrow.points).toCloselyEqualPoints([ [0, 0], - [100, 0], - [100, 200], + [95, 0], + [95, 200], [190, 200], ]); @@ -307,8 +307,8 @@ describe("elbow arrow segment move", () => { expect(arrow.points).toCloselyEqualPoints([ [0, 0], - [100, 0], - [100, 200], + [95, 0], + [95, 200], [190, 200], ]); }); diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 7b9adf958fb3..0f828c473d74 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -22,7 +22,6 @@ import { toBrandedType, tupleToCoors, } from "../utils"; -import { debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -94,32 +93,6 @@ type ElbowArrowState = { const BASE_PADDING = 40; -const segmentListMerge = ( - oldFixedSegments: readonly FixedSegment[], - newFixedSegments: readonly FixedSegment[], -): Sequential => { - let fixedSegments = Array.from(oldFixedSegments); - newFixedSegments.forEach((segment) => { - if (segment.anchor == null) { - // Delete segment - fixedSegments = fixedSegments.filter((s) => s.index !== segment.index); - } else { - const idx = fixedSegments.findIndex((s) => s.index === segment.index); - if (idx > -1) { - // Update segment - fixedSegments[idx] = segment; - } else { - // Add segment request - fixedSegments.push(segment); - } - } - }); - - return fixedSegments.sort( - (a, b) => a.index - b.index, - ) as Sequential; -}; - const generatePoints = memo( ( state: ElbowArrowState, @@ -185,10 +158,9 @@ export const updateElbowArrowPoints = ( const fakeElementsMap = toBrandedType(new Map(elementsMap)); - const nextFixedSegments = segmentListMerge( - arrow.fixedSegments ?? [], - updates?.fixedSegments ?? [], - ); + const nextFixedSegments = (updates.fixedSegments ?? arrow.fixedSegments ?? []) + // Delete segment if anchor is null + .filter((segment) => segment.anchor != null); const { startDonglePosition, endDonglePosition } = getElbowArrowData( arrow, @@ -225,8 +197,6 @@ export const updateElbowArrowPoints = ( startFixedPoint: arrow.startBinding?.fixedPoint ?? null, startElementId: arrow.startBinding?.elementId ?? null, }; - debugDrawPoint(startDonglePosition, { color: "red" }); - debugDrawPoint(endDonglePosition, { color: "green" }); const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = nextFixedSegments.map((segmentEnd, segmentIdx) => { @@ -274,10 +244,6 @@ export const updateElbowArrowPoints = ( ); nextFixedSegments[segmentIdx].anchor = anchor; - debugDrawPoint(startHandle, { color: "red" }); - debugDrawPoint(endHandle, { color: "green" }); - debugDrawPoint(anchor); - const el = { ...newElement({ type: "rectangle", @@ -407,12 +373,6 @@ export const updateElbowArrowPoints = ( headingIsHorizontal(segment.heading), ); - // TODO: Debug only, remove - if (segment.index === 0) { - console.warn("Segment index is 0!"); - debugDrawPoint(segment.anchor, { color: "red", permanent: true }); - } - if (segment.index === 1) { segment.index = 2; } diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 83aa69f33901..41056c15b36f 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -36,7 +36,7 @@ import { getHoveredElementForBinding, isBindingEnabled, } from "./binding"; -import { invariant, tupleToCoors } from "../utils"; +import { invariant, toBrandedType, tupleToCoors } from "../utils"; import { isBindingElement, isElbowArrow, @@ -74,7 +74,10 @@ import { HEADING_LEFT, HEADING_RIGHT, HEADING_UP, + headingIsHorizontal, + headingIsVertical, } from "./heading"; +import { debugDrawPoint } from "../visualdebug"; const editorMidPointsCache: { version: number | null; @@ -1280,6 +1283,7 @@ export class LinearElementEditor { }, options?: { changedElements?: Map; + elbowArrowSegmentOverride?: boolean; }, ) { const { points } = element; @@ -1321,23 +1325,35 @@ export class LinearElementEditor { }); if (isElbowArrow(element)) { - otherUpdates = { - ...otherUpdates, - fixedSegments: indices - // The segment id being fixed is always the last point index of the - // arrow segment, so it's always > 0. Also segments should always - // be 2 points. - .filter((i, _, a) => a.findIndex((t) => t === i - 1) > -1) - // Map segments to mid points of the given segment represented by - // the two points, and the direction. The segment idx we will need - // to know if a given segment is manually moved. - // NOTE: Segment indices are not permanent, the arrow update - // might simplify the arrow and remove/merge segments. - .map((idx) => { - if ( - Math.abs(nextPoints[idx][0] - nextPoints[idx - 1][0]) < - Math.abs(nextPoints[idx][1] - nextPoints[idx - 1][1]) - ) { + targets.forEach((target) => { + debugDrawPoint( + pointFrom( + element.x + target.point[0], + element.y + target.point[1], + ), + ); + }); + const newFixedSegments = indices + // The segment id being fixed is always the last point index of the + // arrow segment, so it's always > 0. Also segments should always + // be 2 points. + .filter((i, _, a) => a.findIndex((t) => t === i - 1) > -1) + // Map segments to mid points of the given segment represented by + // the two points, and the direction. The segment idx we will need + // to know if a given segment is manually moved. + // NOTE: Segment indices are not permanent, the arrow update + // might simplify the arrow and remove/merge segments. + .map((idx) => { + // When moved segment lines up with previous or next segment + // The heading direction flips, so we use the old direction + // to override and ensure a consistent heading during the + // full drag operation. + const origHeading = element.fixedSegments?.find( + (s) => s.index === idx, + )?.heading; + const override = options?.elbowArrowSegmentOverride ?? false; + if (override && origHeading) { + if (headingIsVertical(origHeading)) { return { anchor: pointFrom( element.x + nextPoints[idx][0], @@ -1347,11 +1363,7 @@ export class LinearElementEditor { nextPoints[idx - 1][1]) / 2, ), - heading: - element.y + nextPoints[idx][1] > - element.y + nextPoints[idx - 1][1] - ? HEADING_UP - : HEADING_DOWN, + heading: origHeading, index: idx, }; } @@ -1365,14 +1377,81 @@ export class LinearElementEditor { 2, element.y + nextPoints[idx][1], ), + heading: origHeading, + index: idx, + }; + } + // If this is the first render of the mid segment dragging + // then calculate the direction freely. + if ( + Math.abs(nextPoints[idx][0] - nextPoints[idx - 1][0]) < + Math.abs(nextPoints[idx][1] - nextPoints[idx - 1][1]) + ) { + return { + anchor: pointFrom( + element.x + nextPoints[idx][0], + (element.y + + nextPoints[idx][1] + + element.y + + nextPoints[idx - 1][1]) / + 2, + ), heading: - element.x + nextPoints[idx][0] > - element.x + nextPoints[idx - 1][0] - ? HEADING_LEFT - : HEADING_RIGHT, + element.y + nextPoints[idx][1] > + element.y + nextPoints[idx - 1][1] + ? HEADING_UP + : HEADING_DOWN, index: idx, }; - }) as Sequential, + } + + return { + anchor: pointFrom( + (element.x + + nextPoints[idx][0] + + element.x + + nextPoints[idx - 1][0]) / + 2, + element.y + nextPoints[idx][1], + ), + heading: + element.x + nextPoints[idx][0] > + element.x + nextPoints[idx - 1][0] + ? HEADING_LEFT + : HEADING_RIGHT, + index: idx, + }; + }) as Sequential; + + let fixedSegments = + element.fixedSegments ?? toBrandedType>([]); + newFixedSegments.forEach((segment) => { + if (segment.anchor == null) { + // Delete segment + fixedSegments = fixedSegments.filter( + (s) => s.index !== segment.index, + ) as Sequential; + } else { + const idx = + fixedSegments?.findIndex((s) => s.index === segment.index) ?? -1; + if (idx > -1) { + // Update segment + fixedSegments[idx] = { + anchor: segment.anchor, + heading: options?.elbowArrowSegmentOverride + ? fixedSegments[idx].heading + : segment.heading, + index: segment.index, + }; + } else { + // Add segment request + fixedSegments.push(segment); + } + } + }); + otherUpdates = { + ...otherUpdates, + fixedSegments: fixedSegments.length > 0 ? fixedSegments : null, }; } @@ -1883,6 +1962,7 @@ export class LinearElementEditor { pointerCoords: { x: number; y: number }, elementsMap: ElementsMap, appState: AppState, + added: boolean, ): LinearElementEditor { if (!linearElementEditor.elbowed) { return linearElementEditor; @@ -1920,28 +2000,38 @@ export class LinearElementEditor { element.x + element.points[segmentIdx][0], element.y + element.points[segmentIdx][1], ); + const origHeading = element.fixedSegments?.find( + (s) => s.index === segmentIdx, + )?.heading; const isHorizontal = - Math.abs(startPoint[1] - endPoint[1]) < - Math.abs(startPoint[0] - endPoint[0]); + added && origHeading + ? headingIsHorizontal(origHeading) + : Math.abs(startPoint[1] - endPoint[1]) < + Math.abs(startPoint[0] - endPoint[0]); - LinearElementEditor.movePoints(element, [ - { - index: segmentIdx - 1, - point: pointFrom( - (!isHorizontal ? pointerCoords.x : startPoint[0]) - element.x, - (isHorizontal ? pointerCoords.y : startPoint[1]) - element.y, - ), - isDragging: true, - }, - { - index: segmentIdx, - point: pointFrom( - (!isHorizontal ? pointerCoords.x : endPoint[0]) - element.x, - (isHorizontal ? pointerCoords.y : endPoint[1]) - element.y, - ), - isDragging: true, - }, - ]); + LinearElementEditor.movePoints( + element, + [ + { + index: segmentIdx - 1, + point: pointFrom( + (!isHorizontal ? pointerCoords.x : startPoint[0]) - element.x, + (isHorizontal ? pointerCoords.y : startPoint[1]) - element.y, + ), + isDragging: true, + }, + { + index: segmentIdx, + point: pointFrom( + (!isHorizontal ? pointerCoords.x : endPoint[0]) - element.x, + (isHorizontal ? pointerCoords.y : endPoint[1]) - element.y, + ), + isDragging: true, + }, + ], + undefined, + { elbowArrowSegmentOverride: added }, + ); LinearElementEditor.updateEditorMidPointsCache( element, @@ -1958,6 +2048,7 @@ export class LinearElementEditor { segmentMidpoint: { ...linearElementEditor.pointerDownState.segmentMidpoint, index: newIndex, // Update index for the next frame + added: true, }, }, }; From 83347f5193c599a1cefdc4b598572a8550dbd3dc Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 21 Nov 2024 21:35:18 +0100 Subject: [PATCH 123/283] Fix multiple segment mixup --- packages/excalidraw/element/elbowarrow.ts | 4 +++- packages/excalidraw/tests/cropElement.test.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 0f828c473d74..88b90c81521f 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -160,7 +160,9 @@ export const updateElbowArrowPoints = ( const nextFixedSegments = (updates.fixedSegments ?? arrow.fixedSegments ?? []) // Delete segment if anchor is null - .filter((segment) => segment.anchor != null); + .filter((segment) => segment.anchor != null) + // Due to the fixedSegment index sorting is essential! + .sort((a, b) => a.index - b.index); const { startDonglePosition, endDonglePosition } = getElbowArrowData( arrow, diff --git a/packages/excalidraw/tests/cropElement.test.tsx b/packages/excalidraw/tests/cropElement.test.tsx index 9b03c5261c01..2130727b9706 100644 --- a/packages/excalidraw/tests/cropElement.test.tsx +++ b/packages/excalidraw/tests/cropElement.test.tsx @@ -193,7 +193,7 @@ describe("Crop an image", () => { UI.crop(image, "nw", naturalWidth, naturalHeight, [-150, -100], true); expect(image.width).toEqual(image.height); // max height should be reached - expect(image.height).toEqual(initialHeight); + expect(image.height).toBeCloseTo(initialHeight, 10); expect(image.width).toBe(initialHeight); }); }); From 48f39a582b7b7f0fc0a1cb41f6a9cc5107487b40 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sat, 23 Nov 2024 17:30:57 +0100 Subject: [PATCH 124/283] Another reimplementation --- packages/excalidraw/element/elbowarrow.ts | 115 ++++++++++++++---- .../excalidraw/element/linearElementEditor.ts | 9 -- 2 files changed, 92 insertions(+), 32 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 88b90c81521f..aa99f9549a6b 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -102,6 +102,7 @@ const generatePoints = memo( elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, options?: { isDragging?: boolean; + heading?: Heading; }, ) => routeElbowArrow(state, elementsMap, points, { @@ -168,7 +169,11 @@ export const updateElbowArrowPoints = ( arrow, elementsMap, updatedPoints, - options, + { + ...options, + startIsMidPoint: nextFixedSegments.length !== 0, + endIsMidPoint: nextFixedSegments.length !== 0, + }, ); // End segment is getting fixed - must happen before start segment move check! @@ -225,6 +230,7 @@ export const updateElbowArrowPoints = ( nextFixedSegments[segmentIdx + 1]?.anchor[1] ?? endDonglePosition[1], ); + const heading = headingIsHorizontal(segmentEnd.heading) ? startHandle[0] < endHandle[0] ? HEADING_LEFT @@ -360,7 +366,10 @@ export const updateElbowArrowPoints = ( idx, pointPairs.length - 1, fakeElementsMap, - options, + { + ...options, + heading: nextFixedSegments[idx]?.heading, + }, ), ), ), @@ -426,6 +435,7 @@ const getElbowArrowData = ( isDragging?: boolean; startIsMidPoint?: boolean; endIsMidPoint?: boolean; + heading?: Heading; }, ) => { const origStartGlobalPoint: GlobalPoint = pointTranslate< @@ -482,6 +492,46 @@ const getElbowArrowData = ( hoveredEndElement, origEndGlobalPoint, ); + const fixedStartDongle = + startHeading === HEADING_UP + ? pointFrom( + startGlobalPoint[0], + startGlobalPoint[1] + BASE_PADDING, + ) + : startHeading === HEADING_RIGHT + ? pointFrom( + startGlobalPoint[0] + BASE_PADDING, + startGlobalPoint[1], + ) + : startHeading === HEADING_DOWN + ? pointFrom( + startGlobalPoint[0], + startGlobalPoint[1] - BASE_PADDING, + ) + : pointFrom( + startGlobalPoint[0] - BASE_PADDING, + startGlobalPoint[1], + ); + const fixedEndDongle = + endHeading === HEADING_UP + ? pointFrom( + endGlobalPoint[0], + endGlobalPoint[1] + BASE_PADDING, + ) + : endHeading === HEADING_RIGHT + ? pointFrom( + endGlobalPoint[0] + BASE_PADDING, + endGlobalPoint[1], + ) + : endHeading === HEADING_DOWN + ? pointFrom( + endGlobalPoint[0], + endGlobalPoint[1] - BASE_PADDING, + ) + : pointFrom( + endGlobalPoint[0] - BASE_PADDING, + endGlobalPoint[1], + ); const startPointBounds = [ startGlobalPoint[0] - 2, startGlobalPoint[1] - 2, @@ -577,6 +627,9 @@ const getElbowArrowData = ( ), options?.startIsMidPoint, options?.endIsMidPoint, + fixedStartDongle, + fixedEndDongle, + options?.heading, ) : generateDynamicAABBs( options?.startIsMidPoint @@ -647,6 +700,7 @@ const getElbowArrowData = ( options?.endIsMidPoint, options?.startIsMidPoint, ); + const startDonglePosition = getDonglePosition( dynamicAABBs[0], startHeading, @@ -692,6 +746,7 @@ const routeElbowArrow = ( isDragging?: boolean; startIsMidPoint?: boolean; endIsMidPoint?: boolean; + heading?: Heading; }, ): GlobalPoint[] | null => { const { @@ -925,30 +980,44 @@ const padAABB = (bounds: Bounds, offset: [number, number, number, number]) => const generateSegmentedDynamicAABBs = ( a: Bounds, b: Bounds, - startIsMidPoint?: boolean, - endIsMidPoint?: boolean, -) => { - let first: Bounds = a; - let second: Bounds = b; - - if (startIsMidPoint) { - first = [ - Math.min(a[0], b[2]), - Math.min(a[1], b[3]), - Math.max(a[2], b[0]), - Math.max(a[3], b[1]), - ]; + startIsMidPoint: boolean | undefined, + endIsMidPoint: boolean | undefined, + startDongle: GlobalPoint | undefined, + endDongle: GlobalPoint | undefined, + heading: Heading | undefined, +): Bounds[] => { + let first = a; + let second = b; + + if (startIsMidPoint && heading && startDongle && endDongle) { + if (headingIsHorizontal(heading)) { + if (startDongle[0] < endDongle[0]) { + first = [startDongle[0], a[1], endDongle[0], a[3]]; + } else { + first = [endDongle[0], a[1], startDongle[0], a[3]]; + } + } else if (startDongle[1] < endDongle[1]) { + first = [a[0], startDongle[1], a[2], endDongle[1]]; + } else { + first = [a[0], endDongle[1], a[2], startDongle[1]]; + } } - if (endIsMidPoint) { - second = [ - Math.min(b[0], a[2]), - Math.min(b[1], a[3]), - Math.max(b[2], a[0]), - Math.max(b[3], a[1]), - ]; + + if (endIsMidPoint && heading && startDongle && endDongle) { + if (headingIsHorizontal(heading)) { + if (startDongle[0] < endDongle[0]) { + second = [startDongle[0], b[1], endDongle[0], b[3]]; + } else { + second = [endDongle[0], b[1], startDongle[0], b[3]]; + } + } else if (startDongle[1] < endDongle[1]) { + second = [b[0], startDongle[1], b[2], endDongle[1]]; + } else { + second = [b[0], endDongle[1], b[2], startDongle[1]]; + } } - return [first, second]; + return [first, second] as Bounds[]; }; /** diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 41056c15b36f..9623c3610a39 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -77,7 +77,6 @@ import { headingIsHorizontal, headingIsVertical, } from "./heading"; -import { debugDrawPoint } from "../visualdebug"; const editorMidPointsCache: { version: number | null; @@ -1325,14 +1324,6 @@ export class LinearElementEditor { }); if (isElbowArrow(element)) { - targets.forEach((target) => { - debugDrawPoint( - pointFrom( - element.x + target.point[0], - element.y + target.point[1], - ), - ); - }); const newFixedSegments = indices // The segment id being fixed is always the last point index of the // arrow segment, so it's always > 0. Also segments should always From dbc56e28b191c7861ece7c8f5c3035fbf1e05f0e Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sat, 23 Nov 2024 19:39:00 +0100 Subject: [PATCH 125/283] Fix freestanding --- packages/excalidraw/element/elbowarrow.ts | 114 +++++++++++----------- 1 file changed, 59 insertions(+), 55 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index aa99f9549a6b..a55c922134c1 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -165,16 +165,12 @@ export const updateElbowArrowPoints = ( // Due to the fixedSegment index sorting is essential! .sort((a, b) => a.index - b.index); - const { startDonglePosition, endDonglePosition } = getElbowArrowData( - arrow, - elementsMap, - updatedPoints, - { + const { startDonglePosition, endDonglePosition, startHeading, endHeading } = + getElbowArrowData(arrow, elementsMap, updatedPoints, { ...options, startIsMidPoint: nextFixedSegments.length !== 0, endIsMidPoint: nextFixedSegments.length !== 0, - }, - ); + }); // End segment is getting fixed - must happen before start segment move check! if ( @@ -205,30 +201,59 @@ export const updateElbowArrowPoints = ( startElementId: arrow.startBinding?.elementId ?? null, }; + const anchorStartDongleOffsets = [ + startDonglePosition[0] - BASE_PADDING, + startDonglePosition[1] - BASE_PADDING, + startDonglePosition[0] + BASE_PADDING, + startDonglePosition[0] + BASE_PADDING, + ] as Bounds; + const anchorEndDongleOffsets = [ + endDonglePosition[0] - BASE_PADDING, + endDonglePosition[1] - BASE_PADDING, + endDonglePosition[0] + BASE_PADDING, + endDonglePosition[0] + BASE_PADDING, + ] as Bounds; + const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = nextFixedSegments.map((segmentEnd, segmentIdx) => { // Determine if we need to flip the heading for visual appeal const startHandle: GlobalPoint = headingIsHorizontal(segmentEnd.heading) ? pointFrom( nextFixedSegments[segmentIdx - 1]?.anchor[0] ?? - startDonglePosition[0], + getDonglePosition( + anchorStartDongleOffsets, + startHeading, + startDonglePosition, + )[0], segmentEnd.anchor[1], ) : pointFrom( segmentEnd.anchor[0], nextFixedSegments[segmentIdx - 1]?.anchor[1] ?? - startDonglePosition[1], + getDonglePosition( + anchorStartDongleOffsets, + startHeading, + startDonglePosition, + )[1], ); const endHandle: GlobalPoint = headingIsHorizontal(segmentEnd.heading) ? pointFrom( nextFixedSegments[segmentIdx + 1]?.anchor[0] ?? - endDonglePosition[0], + getDonglePosition( + anchorEndDongleOffsets, + endHeading, + endDonglePosition, + )[0], segmentEnd.anchor[1], ) : pointFrom( segmentEnd.anchor[0], nextFixedSegments[segmentIdx + 1]?.anchor[1] ?? - endDonglePosition[1], + getDonglePosition( + anchorEndDongleOffsets, + endHeading, + endDonglePosition, + )[1], ); const heading = headingIsHorizontal(segmentEnd.heading) @@ -492,46 +517,6 @@ const getElbowArrowData = ( hoveredEndElement, origEndGlobalPoint, ); - const fixedStartDongle = - startHeading === HEADING_UP - ? pointFrom( - startGlobalPoint[0], - startGlobalPoint[1] + BASE_PADDING, - ) - : startHeading === HEADING_RIGHT - ? pointFrom( - startGlobalPoint[0] + BASE_PADDING, - startGlobalPoint[1], - ) - : startHeading === HEADING_DOWN - ? pointFrom( - startGlobalPoint[0], - startGlobalPoint[1] - BASE_PADDING, - ) - : pointFrom( - startGlobalPoint[0] - BASE_PADDING, - startGlobalPoint[1], - ); - const fixedEndDongle = - endHeading === HEADING_UP - ? pointFrom( - endGlobalPoint[0], - endGlobalPoint[1] + BASE_PADDING, - ) - : endHeading === HEADING_RIGHT - ? pointFrom( - endGlobalPoint[0] + BASE_PADDING, - endGlobalPoint[1], - ) - : endHeading === HEADING_DOWN - ? pointFrom( - endGlobalPoint[0], - endGlobalPoint[1] - BASE_PADDING, - ) - : pointFrom( - endGlobalPoint[0] - BASE_PADDING, - endGlobalPoint[1], - ); const startPointBounds = [ startGlobalPoint[0] - 2, startGlobalPoint[1] - 2, @@ -627,8 +612,10 @@ const getElbowArrowData = ( ), options?.startIsMidPoint, options?.endIsMidPoint, - fixedStartDongle, - fixedEndDongle, + startHeading, + endHeading, + startGlobalPoint, + endGlobalPoint, options?.heading, ) : generateDynamicAABBs( @@ -982,12 +969,22 @@ const generateSegmentedDynamicAABBs = ( b: Bounds, startIsMidPoint: boolean | undefined, endIsMidPoint: boolean | undefined, - startDongle: GlobalPoint | undefined, - endDongle: GlobalPoint | undefined, + startHeading: Heading, + endHeading: Heading, + startGlobalPoint: GlobalPoint, + endGlobalPoint: GlobalPoint, heading: Heading | undefined, ): Bounds[] => { let first = a; let second = b; + // const startDongle = startIsMidPoint + // ? startGlobalPoint + // : getDonglePosition(a, startHeading, startGlobalPoint); + // const endDongle = endIsMidPoint + // ? endGlobalPoint + // : getDonglePosition(b, endHeading, endGlobalPoint); + const startDongle = getDonglePosition(a, startHeading, startGlobalPoint); + const endDongle = getDonglePosition(b, endHeading, endGlobalPoint); if (startIsMidPoint && heading && startDongle && endDongle) { if (headingIsHorizontal(heading)) { @@ -1017,6 +1014,13 @@ const generateSegmentedDynamicAABBs = ( } } + const boundsOverlap = + pointInsideBounds(startGlobalPoint, second) || + pointInsideBounds(endGlobalPoint, first); + if (boundsOverlap) { + return [a, b]; + } + return [first, second] as Bounds[]; }; From c247bae9171a84588ef16bbf05425569090e6d9b Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 27 Nov 2024 22:28:30 +0100 Subject: [PATCH 126/283] Broken but logic-wise laid out static fixed segment --- packages/excalidraw/components/App.tsx | 107 ++---- packages/excalidraw/element/dragElements.ts | 15 - packages/excalidraw/element/elbowarrow.ts | 357 +++++------------- .../excalidraw/element/linearElementEditor.ts | 325 ++-------------- packages/excalidraw/element/mutateElement.ts | 5 +- packages/excalidraw/element/types.ts | 18 +- .../excalidraw/renderer/interactiveScene.ts | 41 +- 7 files changed, 178 insertions(+), 690 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 8ce402f42cc5..4e15d921c7bf 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -186,8 +186,6 @@ import type { MagicGenerationData, ExcalidrawNonSelectionElement, ExcalidrawArrowElement, - Sequential, - FixedSegment, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -3223,22 +3221,11 @@ class App extends React.Component { const newElements = duplicateElements( elements.map((element) => { - let newElement = newElementWith(element, { + const newElement = newElementWith(element, { x: element.x + gridX - minX, y: element.y + gridY - minY, }); - if (isElbowArrow(newElement)) { - newElement = { - ...newElement, - fixedSegments: LinearElementEditor.restoreFixedSegments( - newElement, - newElement.x, - newElement.y, - ), - }; - } - return newElement; }), { @@ -5312,34 +5299,13 @@ class App extends React.Component { (this.state.selectedLinearElement?.pointerDownState?.segmentMidpoint ?.index || -1) > -1 ) { - // Delete fixed segment point this.store.shouldCaptureIncrement(); - const elementsMap = this.scene.getNonDeletedElementsMap(); - const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords( + LinearElementEditor.deleteFixedSegment( this.state.selectedLinearElement, - { x: sceneX, y: sceneY }, - this.state, - elementsMap, - ); - const index = - segmentMidPoint && - LinearElementEditor.getSegmentMidPointIndex( - this.state.selectedLinearElement, - this.state, - segmentMidPoint, - elementsMap, - ); - const fixedSegments = selectedElements[0].fixedSegments?.map( - (segment) => - segment.index !== index ? segment : { ...segment, anchor: null }, - ) as Sequential | null; - - mutateElement(selectedElements[0], { fixedSegments }); - - LinearElementEditor.updateEditorMidPointsCache( - selectedElements[0], - elementsMap, this.state, + sceneX, + sceneY, + this.scene.getNonDeletedElementsMap(), ); return; @@ -7923,23 +7889,44 @@ class App extends React.Component { pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), ) >= DRAGGING_THRESHOLD ) { + // Move fixed segment const [gridX, gridY] = getGridPoint( pointerCoords.x, pointerCoords.y, event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), ); - const ret = LinearElementEditor.moveElbowArrowSegment( - this.state.selectedLinearElement, - { x: gridX, y: gridY }, - elementsMap, - this.state, - linearElementEditor.pointerDownState.segmentMidpoint.added, - ); + const elementsMap = this.scene.getNonDeletedElementsMap(); + const segmentMidPoint = + LinearElementEditor.getSegmentMidpointHitCoords( + this.state.selectedLinearElement, + { x: pointerDownState.origin.x, y: pointerDownState.origin.y }, + this.state, + elementsMap, + ); + const index = + segmentMidPoint && + LinearElementEditor.getSegmentMidPointIndex( + this.state.selectedLinearElement, + this.state, + segmentMidPoint, + elementsMap, + ); const element = LinearElementEditor.getElement( this.state.selectedLinearElement.elementId, elementsMap, ); - if (element) { + + if (element && index && index > 0) { + LinearElementEditor.movePoints(element, [ + { + index: index - 1, + point: pointFrom( + gridX - element.x, + gridY - element.y, + ), + isDragging: true, + }, + ]); LinearElementEditor.updateEditorMidPointsCache( element, elementsMap, @@ -7947,32 +7934,6 @@ class App extends React.Component { ); } - if ( - this.state.selectedLinearElement.pointerDownState.segmentMidpoint - .index !== ret.pointerDownState.segmentMidpoint.index || - this.state.selectedLinearElement.pointerDownState.segmentMidpoint - .added !== ret.pointerDownState.segmentMidpoint.added - ) { - flushSync(() => { - if (this.state.selectedLinearElement) { - this.setState({ - selectedLinearElement: { - ...this.state.selectedLinearElement, - pointerDownState: ret.pointerDownState, - }, - }); - } - if (this.state.editingLinearElement) { - this.setState({ - editingLinearElement: { - ...this.state.editingLinearElement, - pointerDownState: ret.pointerDownState, - }, - }); - } - }); - } - return; } else if ( linearElementEditor.pointerDownState.segmentMidpoint.value !== null && diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index d86cb6942853..b5f210812b11 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -22,7 +22,6 @@ import { import { getFontString } from "../utils"; import { TEXT_AUTOWRAP_THRESHOLD } from "../constants"; import { getGridPoint } from "../snapping"; -import { LinearElementEditor } from "./linearElementEditor"; export const dragSelectedElements = ( pointerDownState: PointerDownState, @@ -77,20 +76,6 @@ export const dragSelectedElements = ( elementsToUpdate.forEach((element) => { updateElementCoords(pointerDownState, element, adjustedOffset); - if (isElbowArrow(element)) { - mutateElement( - element, - { - fixedSegments: LinearElementEditor.restoreFixedSegments( - element, - element.x, - element.y, - ), - }, - true, - true, - ); - } if (!isArrowElement(element)) { // skip arrow labels since we calculate its position during render const textElement = getBoundTextElement( diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index a55c922134c1..78b3c02fc72b 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -1,9 +1,8 @@ import { - distanceToLineSegment, - lineSegment, pointDistance, pointFrom, pointScaleFromOrigin, + pointsEqual, pointTranslate, vector, vectorCross, @@ -16,6 +15,7 @@ import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; import { aabbForElement, pointInsideBounds } from "../shapes"; import { + invariant, isAnyTrue, memo, multiDimensionalArrayDeepFilter, @@ -45,7 +45,6 @@ import { vectorToHeading, } from "./heading"; import type { ElementUpdate } from "./mutateElement"; -import { newElement } from "./newElement"; import { isBindableElement, isRectanguloidElement } from "./typeChecks"; import { type ExcalidrawElbowArrowElement, @@ -58,9 +57,6 @@ import type { ExcalidrawBindableElement, FixedPointBinding, FixedSegment, - FractionalIndex, - Ordered, - Sequential, } from "./types"; type GridAddress = [number, number] & { _brand: "gridaddress" }; @@ -112,35 +108,6 @@ const generatePoints = memo( }) ?? [], ); -const anchorPointToSegmentIndex = ( - points: GlobalPoint[], - anchor: GlobalPoint, - anchorIsHorizontal: boolean, -) => { - let closestDistance = Infinity; - return points - .slice(1) - .map( - (point, idx) => - [ - distanceToLineSegment(anchor, lineSegment(points[idx], point)), - Math.abs(points[idx][1] - point[1]) < 1, - idx, - ] as [number, boolean, number], - ) - .filter( - ([_, segmentIsHorizontal]) => segmentIsHorizontal === anchorIsHorizontal, - ) - .reduce((acc, [distance, _, idx]) => { - if (distance < closestDistance) { - closestDistance = distance; - return idx + 1; - } - - return acc; - }, 0); -}; - /** * */ @@ -148,238 +115,103 @@ export const updateElbowArrowPoints = ( arrow: ExcalidrawElbowArrowElement, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, updates: { - points?: readonly LocalPoint[]; - fixedSegments?: FixedSegment[]; + points: readonly LocalPoint[]; }, options?: { isDragging?: boolean; }, ): ElementUpdate => { - const updatedPoints = Array.from(updates.points ?? arrow.points); - - const fakeElementsMap = toBrandedType(new Map(elementsMap)); - - const nextFixedSegments = (updates.fixedSegments ?? arrow.fixedSegments ?? []) - // Delete segment if anchor is null - .filter((segment) => segment.anchor != null) - // Due to the fixedSegment index sorting is essential! - .sort((a, b) => a.index - b.index); + invariant( + arrow.points.length < 2 || arrow.points.length === updates.points.length, + "Updated point array length must match the arrow point length (i.e. you can't add new points manually to elbow arrows)", + ); - const { startDonglePosition, endDonglePosition, startHeading, endHeading } = - getElbowArrowData(arrow, elementsMap, updatedPoints, { - ...options, - startIsMidPoint: nextFixedSegments.length !== 0, - endIsMidPoint: nextFixedSegments.length !== 0, - }); + const updatedPoints = Array.from(updates.points ?? arrow.points); - // End segment is getting fixed - must happen before start segment move check! - if ( - nextFixedSegments[nextFixedSegments.length - 1]?.index === - updatedPoints.length - 1 - ) { - nextFixedSegments[nextFixedSegments.length - 1].index = - updatedPoints.length - 2; - updatedPoints.push(arrow.points[arrow.points.length - 1]); - } + const nextFixedSegments = arrow.points + .map((p, idx) => { + const existingSegment = arrow.fixedSegments?.find( + (segment) => + segment.start[0] === arrow.points[idx - 1][0] && + segment.start[1] === arrow.points[idx - 1][1] && + segment.end[0] === arrow.points[idx][0] && + segment.end[1] === arrow.points[idx][1], + ); + if (existingSegment) { + return [existingSegment, idx] as const; + } - // Start segment is getting fixed - if (nextFixedSegments[0]?.index === 1) { - nextFixedSegments[0].index = 2; - updatedPoints.unshift(pointFrom(0, 0)); - } + if ( + idx > 0 && + !pointsEqual(p, updatedPoints[idx]) && + !pointsEqual(arrow.points[idx - 1], updatedPoints[idx - 1]) + ) { + // If the previous point is not the same as the updated previous point + // and the current point is not the same as the updated point, then a + // new segment is being moved / fixed + return [ + { + start: updatedPoints[idx - 1], + end: updatedPoints[idx], + }, + idx, + ] as const; + } - let segmentStart: { - startPoint: GlobalPoint; - startFixedPoint: FixedPointBinding["fixedPoint"] | null; - startElementId: string | null; - } = { - startPoint: pointFrom( - arrow.x + updatedPoints[0][0], - arrow.y + updatedPoints[0][1], - ), - startFixedPoint: arrow.startBinding?.fixedPoint ?? null, - startElementId: arrow.startBinding?.elementId ?? null, + return null; + }) + .filter((segment) => segment !== null) + .sort((a, b) => a![1] - b![1]) + // @ts-ignore + .map(([segment, _]): FixedSegment => segment); + + let state = { + x: arrow.x, + y: arrow.y, + startBinding: arrow.startBinding, + endBinding: null, + startArrowhead: arrow.startArrowhead, + endArrowhead: null, }; - - const anchorStartDongleOffsets = [ - startDonglePosition[0] - BASE_PADDING, - startDonglePosition[1] - BASE_PADDING, - startDonglePosition[0] + BASE_PADDING, - startDonglePosition[0] + BASE_PADDING, - ] as Bounds; - const anchorEndDongleOffsets = [ - endDonglePosition[0] - BASE_PADDING, - endDonglePosition[1] - BASE_PADDING, - endDonglePosition[0] + BASE_PADDING, - endDonglePosition[0] + BASE_PADDING, - ] as Bounds; + let startPoint = updatedPoints[0]; const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = - nextFixedSegments.map((segmentEnd, segmentIdx) => { - // Determine if we need to flip the heading for visual appeal - const startHandle: GlobalPoint = headingIsHorizontal(segmentEnd.heading) - ? pointFrom( - nextFixedSegments[segmentIdx - 1]?.anchor[0] ?? - getDonglePosition( - anchorStartDongleOffsets, - startHeading, - startDonglePosition, - )[0], - segmentEnd.anchor[1], - ) - : pointFrom( - segmentEnd.anchor[0], - nextFixedSegments[segmentIdx - 1]?.anchor[1] ?? - getDonglePosition( - anchorStartDongleOffsets, - startHeading, - startDonglePosition, - )[1], - ); - const endHandle: GlobalPoint = headingIsHorizontal(segmentEnd.heading) - ? pointFrom( - nextFixedSegments[segmentIdx + 1]?.anchor[0] ?? - getDonglePosition( - anchorEndDongleOffsets, - endHeading, - endDonglePosition, - )[0], - segmentEnd.anchor[1], - ) - : pointFrom( - segmentEnd.anchor[0], - nextFixedSegments[segmentIdx + 1]?.anchor[1] ?? - getDonglePosition( - anchorEndDongleOffsets, - endHeading, - endDonglePosition, - )[1], - ); - - const heading = headingIsHorizontal(segmentEnd.heading) - ? startHandle[0] < endHandle[0] - ? HEADING_LEFT - : HEADING_RIGHT - : startHandle[1] < endHandle[1] - ? HEADING_UP - : HEADING_DOWN; - nextFixedSegments[segmentIdx].heading = heading; - - // Calculate new anchor point (sliding anchor) - const anchor = headingIsHorizontal(heading) - ? pointFrom( - (startHandle[0] + endHandle[0]) / 2, - segmentEnd.anchor[1], - ) - : pointFrom( - segmentEnd.anchor[0], - (startHandle[1] + endHandle[1]) / 2, - ); - nextFixedSegments[segmentIdx].anchor = anchor; - - const el = { - ...newElement({ - type: "rectangle", - x: anchor[0] - 0.1, - y: anchor[1] - 0.1, - width: 0.2, - height: 0.2, - }), - index: "DONOTSYNC" as FractionalIndex, - } as Ordered; - fakeElementsMap.set(el.id, el); - - const endFixedPoint: [number, number] = compareHeading( - heading, - HEADING_DOWN, - ) - ? [0.5, 1] - : compareHeading(heading, HEADING_LEFT) - ? [0, 0.5] - : compareHeading(heading, HEADING_UP) - ? [0.5, 0] - : [1, 0.5]; - - const state = { - x: segmentStart.startPoint[0], - y: segmentStart.startPoint[1], - startArrowhead: segmentIdx === 0 ? arrow.startArrowhead : null, - endArrowhead: null as Arrowhead | null, - startBinding: - segmentIdx === 0 - ? arrow.startBinding - : { - elementId: segmentStart.startElementId!, - focus: 0, - gap: 0, - fixedPoint: segmentStart.startFixedPoint!, - }, - endBinding: { - elementId: el.id, - focus: 0, - gap: 0, - fixedPoint: endFixedPoint, - }, - }; - const nextStartFixedPoint: [number, number] = compareHeading( - heading, - HEADING_DOWN, - ) - ? [0.5, 0] - : compareHeading(heading, HEADING_LEFT) - ? [1, 0.5] - : compareHeading(heading, HEADING_UP) - ? [0.5, 1] - : [0, 0.5]; - const segmentEndGlobalPoint = getGlobalFixedPointForBindableElement( - endFixedPoint, - el, + nextFixedSegments.map((segment, segmentIdx) => { + const start = pointFrom( + segment.start[0] - startPoint[0], + segment.start[1] - startPoint[1], ); - const endLocalPoint = pointFrom( - segmentEndGlobalPoint[0] - segmentStart.startPoint[0], - segmentEndGlobalPoint[1] - segmentStart.startPoint[1], + const end = pointFrom( + segment.end[0] - startPoint[0], + segment.end[1] - startPoint[1], ); + const ret: [ElbowArrowState, readonly LocalPoint[]] = [ + state, + [startPoint, segmentIdx === 0 ? end : start], + ]; - segmentStart = { - startPoint: getGlobalFixedPointForBindableElement( - nextStartFixedPoint, - el, - ), - startFixedPoint: nextStartFixedPoint, - startElementId: el.id, + startPoint = + segmentIdx === nextFixedSegments.length - 1 + ? segment.start + : segment.end; + + state = { + ...state, + x: arrow.x + segment.end[0], + y: arrow.y + segment.end[1], + startBinding: null, + startArrowhead: null, }; - return [state, [pointFrom(0, 0), endLocalPoint]]; + return ret; }); pointPairs.push([ { - x: segmentStart.startPoint[0], - y: segmentStart.startPoint[1], - startArrowhead: null, - endArrowhead: arrow.endArrowhead, - startBinding: - nextFixedSegments.length === 0 - ? arrow.startBinding - : { - elementId: segmentStart.startElementId!, - focus: 0, - gap: 0, - fixedPoint: segmentStart.startFixedPoint!, - }, + ...state, endBinding: arrow.endBinding, + endArrowhead: arrow.endArrowhead, }, - [ - pointFrom(0, 0), - // Translate from unsegmented local array point -> global point -> last segment local point - pointFrom( - arrow.x + - updatedPoints[updatedPoints.length - 1][0] - - segmentStart.startPoint[0], - arrow.y + - updatedPoints[updatedPoints.length - 1][1] - - segmentStart.startPoint[1], - ), - ], + [startPoint, updatedPoints[updatedPoints.length - 1]], ]); const simplifiedPoints = getElbowArrowCornerPoints( @@ -390,36 +222,14 @@ export const updateElbowArrowPoints = ( points, idx, pointPairs.length - 1, - fakeElementsMap, - { - ...options, - heading: nextFixedSegments[idx]?.heading, - }, + elementsMap, + options, ), ), ), ).flat(); - return normalizeArrowElementUpdate( - simplifiedPoints, - nextFixedSegments.map((segment) => { - segment.index = anchorPointToSegmentIndex( - simplifiedPoints, - segment.anchor, - headingIsHorizontal(segment.heading), - ); - - if (segment.index === 1) { - segment.index = 2; - } - - if (segment.index === simplifiedPoints.length - 1) { - segment.index = simplifiedPoints.length - 2; - } - - return segment; - }) as Sequential, - ); + return normalizeArrowElementUpdate(simplifiedPoints, nextFixedSegments); }; /** @@ -1469,14 +1279,14 @@ const getBindableElementForId = ( const normalizeArrowElementUpdate = ( global: GlobalPoint[], - nextFixedSegments: Sequential | null, + nextFixedSegments: FixedSegment[] | null, ): { points: LocalPoint[]; x: number; y: number; width: number; height: number; - fixedSegments: Sequential | null; + fixedSegments: FixedSegment[] | null; } => { const offsetX = global[0][0]; const offsetY = global[0][1]; @@ -1492,7 +1302,8 @@ const normalizeArrowElementUpdate = ( points, x: offsetX, y: offsetY, - fixedSegments: nextFixedSegments?.length ? nextFixedSegments : null, + fixedSegments: + (nextFixedSegments?.length ?? 0) > 0 ? nextFixedSegments : null, ...getSizeFromPoints(points), }; }; diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 9623c3610a39..ca94a6023cc4 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -10,9 +10,6 @@ import type { OrderedExcalidrawElement, FixedPointBinding, SceneElementsMap, - FixedSegment, - ExcalidrawElbowArrowElement, - Sequential, } from "./types"; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; import type { Bounds } from "./bounds"; @@ -36,7 +33,7 @@ import { getHoveredElementForBinding, isBindingEnabled, } from "./binding"; -import { invariant, toBrandedType, tupleToCoors } from "../utils"; +import { invariant, tupleToCoors } from "../utils"; import { isBindingElement, isElbowArrow, @@ -69,14 +66,6 @@ import { mapIntervalToBezierT, } from "../shapes"; import { getGridPoint } from "../snapping"; -import { - HEADING_DOWN, - HEADING_LEFT, - HEADING_RIGHT, - HEADING_UP, - headingIsHorizontal, - headingIsVertical, -} from "./heading"; const editorMidPointsCache: { version: number | null; @@ -1278,7 +1267,7 @@ export class LinearElementEditor { otherUpdates?: { startBinding?: PointBinding | null; endBinding?: PointBinding | null; - fixedSegments?: Sequential | null; + fixedSegments?: number[] | null; }, options?: { changedElements?: Map; @@ -1287,7 +1276,6 @@ export class LinearElementEditor { ) { const { points } = element; const targets = Array.from(targetPoints); - const indices = targets.map((p) => p.index).sort(); // 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 @@ -1323,129 +1311,6 @@ export class LinearElementEditor { return offsetX || offsetY ? pointFrom(p[0] - offsetX, p[1] - offsetY) : p; }); - if (isElbowArrow(element)) { - const newFixedSegments = indices - // The segment id being fixed is always the last point index of the - // arrow segment, so it's always > 0. Also segments should always - // be 2 points. - .filter((i, _, a) => a.findIndex((t) => t === i - 1) > -1) - // Map segments to mid points of the given segment represented by - // the two points, and the direction. The segment idx we will need - // to know if a given segment is manually moved. - // NOTE: Segment indices are not permanent, the arrow update - // might simplify the arrow and remove/merge segments. - .map((idx) => { - // When moved segment lines up with previous or next segment - // The heading direction flips, so we use the old direction - // to override and ensure a consistent heading during the - // full drag operation. - const origHeading = element.fixedSegments?.find( - (s) => s.index === idx, - )?.heading; - const override = options?.elbowArrowSegmentOverride ?? false; - if (override && origHeading) { - if (headingIsVertical(origHeading)) { - return { - anchor: pointFrom( - element.x + nextPoints[idx][0], - (element.y + - nextPoints[idx][1] + - element.y + - nextPoints[idx - 1][1]) / - 2, - ), - heading: origHeading, - index: idx, - }; - } - - return { - anchor: pointFrom( - (element.x + - nextPoints[idx][0] + - element.x + - nextPoints[idx - 1][0]) / - 2, - element.y + nextPoints[idx][1], - ), - heading: origHeading, - index: idx, - }; - } - // If this is the first render of the mid segment dragging - // then calculate the direction freely. - if ( - Math.abs(nextPoints[idx][0] - nextPoints[idx - 1][0]) < - Math.abs(nextPoints[idx][1] - nextPoints[idx - 1][1]) - ) { - return { - anchor: pointFrom( - element.x + nextPoints[idx][0], - (element.y + - nextPoints[idx][1] + - element.y + - nextPoints[idx - 1][1]) / - 2, - ), - heading: - element.y + nextPoints[idx][1] > - element.y + nextPoints[idx - 1][1] - ? HEADING_UP - : HEADING_DOWN, - index: idx, - }; - } - - return { - anchor: pointFrom( - (element.x + - nextPoints[idx][0] + - element.x + - nextPoints[idx - 1][0]) / - 2, - element.y + nextPoints[idx][1], - ), - heading: - element.x + nextPoints[idx][0] > - element.x + nextPoints[idx - 1][0] - ? HEADING_LEFT - : HEADING_RIGHT, - index: idx, - }; - }) as Sequential; - - let fixedSegments = - element.fixedSegments ?? toBrandedType>([]); - newFixedSegments.forEach((segment) => { - if (segment.anchor == null) { - // Delete segment - fixedSegments = fixedSegments.filter( - (s) => s.index !== segment.index, - ) as Sequential; - } else { - const idx = - fixedSegments?.findIndex((s) => s.index === segment.index) ?? -1; - if (idx > -1) { - // Update segment - fixedSegments[idx] = { - anchor: segment.anchor, - heading: options?.elbowArrowSegmentOverride - ? fixedSegments[idx].heading - : segment.heading, - index: segment.index, - }; - } else { - // Add segment request - fixedSegments.push(segment); - } - } - }); - otherUpdates = { - ...otherUpdates, - fixedSegments: fixedSegments.length > 0 ? fixedSegments : null, - }; - } - LinearElementEditor._updatePoints( element, nextPoints, @@ -1463,59 +1328,6 @@ export class LinearElementEditor { ); } - static restoreFixedSegments( - element: ExcalidrawElbowArrowElement, - x: number, - y: number, - ): Sequential | null { - return (element.fixedSegments?.map((segment) => { - if ( - Math.abs( - element.points[segment.index][0] - - element.points[segment.index - 1][0], - ) < - Math.abs( - element.points[segment.index][1] - - element.points[segment.index - 1][1], - ) - ) { - return { - anchor: pointFrom( - x + element.points[segment.index][0], - (y + - element.points[segment.index][1] + - y + - element.points[segment.index - 1][1]) / - 2, - ), - heading: - y + element.points[segment.index][1] > - y + element.points[segment.index - 1][1] - ? HEADING_UP - : HEADING_DOWN, - index: segment.index, - }; - } - - return { - anchor: pointFrom( - (x + - element.points[segment.index][0] + - x + - element.points[segment.index - 1][0]) / - 2, - y + element.points[segment.index][1], - ), - heading: - x + element.points[segment.index][0] > - x + element.points[segment.index - 1][0] - ? HEADING_LEFT - : HEADING_RIGHT, - index: segment.index, - }; - }) ?? null) as Sequential; - } - static shouldAddMidpoint( linearElementEditor: LinearElementEditor, pointerCoords: PointerCoords, @@ -1621,7 +1433,6 @@ export class LinearElementEditor { otherUpdates?: { startBinding?: PointBinding | null; endBinding?: PointBinding | null; - fixedSegments?: Sequential | null; }, options?: { changedElements?: Map; @@ -1632,7 +1443,6 @@ export class LinearElementEditor { const updates: { startBinding?: FixedPointBinding | null; endBinding?: FixedPointBinding | null; - fixedSegments?: Sequential | null; points?: LocalPoint[]; } = {}; if (otherUpdates?.startBinding !== undefined) { @@ -1649,9 +1459,6 @@ export class LinearElementEditor { ? otherUpdates.endBinding : null; } - if (otherUpdates?.fixedSegments) { - updates.fixedSegments = otherUpdates.fixedSegments; - } updates.points = Array.from(nextPoints); updates.points[0] = pointTranslate( @@ -1945,104 +1752,50 @@ export class LinearElementEditor { return coords; }; - /** - * - */ - static moveElbowArrowSegment( + static deleteFixedSegment( linearElementEditor: LinearElementEditor, - pointerCoords: { x: number; y: number }, - elementsMap: ElementsMap, - appState: AppState, - added: boolean, - ): LinearElementEditor { - if (!linearElementEditor.elbowed) { - return linearElementEditor; - } - - const { index: segmentIdx } = - linearElementEditor.pointerDownState.segmentMidpoint; - const element = LinearElementEditor.getElement( - linearElementEditor.elementId, + state: AppState, + x: number, + y: number, + elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, + ) { + const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords( + linearElementEditor, + { x, y }, + state, elementsMap, ); - - if (!element || !segmentIdx) { - return linearElementEditor; - } - - // Calculate the expected / existing position of the - // segment in the `fixedSegments` array on the arrow - let currFixedSegmentsArrayIdx = - element.fixedSegments?.findIndex( - (segment) => segment.index === segmentIdx, - ) ?? -1; - if (currFixedSegmentsArrayIdx < 0) { - // Segment not yet fixed - we expect it to fall into this place in the array: - currFixedSegmentsArrayIdx = - element.fixedSegments?.filter((segment) => segment.index < segmentIdx) - ?.length ?? 0; - } - - const startPoint = pointFrom( - element.x + element.points[segmentIdx - 1][0], - element.y + element.points[segmentIdx - 1][1], - ); - const endPoint = pointFrom( - element.x + element.points[segmentIdx][0], - element.y + element.points[segmentIdx][1], - ); - const origHeading = element.fixedSegments?.find( - (s) => s.index === segmentIdx, - )?.heading; - const isHorizontal = - added && origHeading - ? headingIsHorizontal(origHeading) - : Math.abs(startPoint[1] - endPoint[1]) < - Math.abs(startPoint[0] - endPoint[0]); - - LinearElementEditor.movePoints( - element, - [ - { - index: segmentIdx - 1, - point: pointFrom( - (!isHorizontal ? pointerCoords.x : startPoint[0]) - element.x, - (isHorizontal ? pointerCoords.y : startPoint[1]) - element.y, - ), - isDragging: true, - }, - { - index: segmentIdx, - point: pointFrom( - (!isHorizontal ? pointerCoords.x : endPoint[0]) - element.x, - (isHorizontal ? pointerCoords.y : endPoint[1]) - element.y, - ), - isDragging: true, - }, - ], - undefined, - { elbowArrowSegmentOverride: added }, - ); - - LinearElementEditor.updateEditorMidPointsCache( - element, + const index = + segmentMidPoint && + LinearElementEditor.getSegmentMidPointIndex( + linearElementEditor, + state, + segmentMidPoint, + elementsMap, + ); + const element = LinearElementEditor.getElement( + linearElementEditor.elementId, elementsMap, - appState, ); - const newIndex = element.fixedSegments![currFixedSegmentsArrayIdx].index; + if (index && index > 0 && element && isElbowArrow(element)) { + const start = element.points[index - 1]; + const end = element.points[index]; - return { - ...linearElementEditor, - pointerDownState: { - ...linearElementEditor.pointerDownState, - segmentMidpoint: { - ...linearElementEditor.pointerDownState.segmentMidpoint, - index: newIndex, // Update index for the next frame - added: true, - }, - }, - }; + mutateElement(element, { + fixedSegments: element.fixedSegments?.filter( + (segment) => + !pointsEqual(segment.start, start) && + !pointsEqual(segment.end, end), + ), + }); + + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + state, + ); + } } } diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index 928ad38eb4b9..3d203dd99a72 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -35,10 +35,7 @@ export const mutateElement = >( // (see https://github.com/microsoft/TypeScript/issues/21732) const { points, fileId } = updates as any; - if ( - typeof points !== "undefined" || - (!isDragging && Object.hasOwn(updates, "fixedSegments")) - ) { + if (typeof points !== "undefined") { if (isElbowArrow(element)) { const mergedElementsMap = toBrandedType( new Map([ diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index be6eb19f7dad..7688e506e97e 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -1,4 +1,4 @@ -import type { GlobalPoint, LocalPoint, Radians } from "../../math"; +import type { LocalPoint, Radians } from "../../math"; import type { FONT_FAMILY, ROUNDNESS, @@ -12,7 +12,6 @@ import type { Merge, ValueOf, } from "../utility-types"; -import type { Heading } from "./heading"; export type ChartType = "bar" | "line"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; @@ -295,12 +294,6 @@ export type FixedPointBinding = Merge< } >; -export type FixedSegment = { - anchor: GlobalPoint; - heading: Heading; - index: number; -}; - export type Arrowhead = | "arrow" | "bar" @@ -323,6 +316,11 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase & endArrowhead: Arrowhead | null; }>; +export type FixedSegment = { + start: LocalPoint; + end: LocalPoint; +}; + export type ExcalidrawArrowElement = ExcalidrawLinearElement & Readonly<{ type: "arrow"; @@ -336,12 +334,10 @@ export type ExcalidrawElbowArrowElement = Merge< elbowed: true; startBinding: FixedPointBinding | null; endBinding: FixedPointBinding | null; - fixedSegments: Sequential | null; + fixedSegments: FixedSegment[] | null; } >; -export type Sequential = T[] & { __brand: "__sequential" }; - export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & Readonly<{ type: "freedraw"; diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 7f380c3eafc1..5a73d7834a24 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -81,7 +81,6 @@ import { type Radians, } from "../../math"; import { getCornerRadius } from "../shapes"; -import { headingIsHorizontal } from "../element/heading"; const renderLinearElementPointHighlight = ( context: CanvasRenderingContext2D, @@ -489,7 +488,7 @@ const renderLinearPointHandles = ( context.save(); context.translate(appState.scrollX, appState.scrollY); context.lineWidth = 1 / appState.zoom.value; - const points = LinearElementEditor.getPointsGlobalCoordinates( + const points: GlobalPoint[] = LinearElementEditor.getPointsGlobalCoordinates( element, elementsMap, ); @@ -511,46 +510,32 @@ const renderLinearPointHandles = ( // Rendering segment mid points if (isElbowArrow(element)) { - const fixedPoints = element.points.slice(0, -1).map((p, i) => { - const fixedSegmentIdx = - element.fixedSegments?.findIndex( - (fixedSegment) => fixedSegment.index === i + 1, - ) ?? -1; - if (fixedSegmentIdx > -1) { - return headingIsHorizontal( - element.fixedSegments![fixedSegmentIdx].heading, - ); - } - - return null; - }); - const globalPoints = element.points.map((p) => - pointFrom(element.x + p[0], element.y + p[1]), - ); - globalPoints.slice(0, -1).forEach((p, idx) => { + const fixedSegments = element.fixedSegments || []; + points.slice(0, -1).forEach((p, idx) => { if ( !LinearElementEditor.isSegmentTooShort( element, p, - globalPoints[idx + 1], + points[idx + 1], appState.zoom, ) ) { - const isHorizontal = - Math.abs(p[0] - globalPoints[idx + 1][0]) > - Math.abs(p[1] - globalPoints[idx + 1][1]); renderSingleLinearPoint( context, appState, pointFrom( - (p[0] + globalPoints[idx + 1][0]) / 2, - (p[1] + globalPoints[idx + 1][1]) / 2, + (p[0] + points[idx + 1][0]) / 2, + (p[1] + points[idx + 1][1]) / 2, ), POINT_HANDLE_SIZE / 2, false, - !(fixedPoints[idx] !== null - ? fixedPoints[idx] === isHorizontal - : false), + !fixedSegments.find( + (segment) => + segment.start[0] === points[idx - 1][0] && + segment.start[1] === points[idx - 1][1] && + segment.end[0] === points[idx][0] && + segment.end[1] === points[idx][1], + ), ); } }); From 948786f727caa11471739d84fb646aaa5d7d3936 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 28 Nov 2024 13:46:38 +0100 Subject: [PATCH 127/283] Start point is detected as segment move --- packages/excalidraw/components/App.tsx | 48 +++---------- packages/excalidraw/element/elbowarrow.ts | 68 +++++++++++++++--- .../excalidraw/element/linearElementEditor.ts | 70 +++++++++++++++++-- packages/excalidraw/element/mutateElement.ts | 1 + .../excalidraw/renderer/interactiveScene.ts | 8 +-- 5 files changed, 138 insertions(+), 57 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 4e15d921c7bf..d81ad836742a 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7889,52 +7889,20 @@ class App extends React.Component { pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), ) >= DRAGGING_THRESHOLD ) { - // Move fixed segment const [gridX, gridY] = getGridPoint( pointerCoords.x, pointerCoords.y, event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), ); - const elementsMap = this.scene.getNonDeletedElementsMap(); - const segmentMidPoint = - LinearElementEditor.getSegmentMidpointHitCoords( - this.state.selectedLinearElement, - { x: pointerDownState.origin.x, y: pointerDownState.origin.y }, - this.state, - elementsMap, - ); - const index = - segmentMidPoint && - LinearElementEditor.getSegmentMidPointIndex( - this.state.selectedLinearElement, - this.state, - segmentMidPoint, - elementsMap, - ); - const element = LinearElementEditor.getElement( - this.state.selectedLinearElement.elementId, - elementsMap, - ); - if (element && index && index > 0) { - LinearElementEditor.movePoints(element, [ - { - index: index - 1, - point: pointFrom( - gridX - element.x, - gridY - element.y, - ), - isDragging: true, - }, - ]); - LinearElementEditor.updateEditorMidPointsCache( - element, - elementsMap, - this.state, - ); - } - - return; + return LinearElementEditor.moveFixedSegment( + this.state.selectedLinearElement, + gridX, + gridY, + this.scene.getNonDeletedElementsMap(), + pointerDownState, + this.state, + ); } else if ( linearElementEditor.pointerDownState.segmentMidpoint.value !== null && !linearElementEditor.pointerDownState.segmentMidpoint.added diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 78b3c02fc72b..0e0161203b9a 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -22,6 +22,7 @@ import { toBrandedType, tupleToCoors, } from "../utils"; +import { debugCloseFrame, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -128,21 +129,52 @@ export const updateElbowArrowPoints = ( const updatedPoints = Array.from(updates.points ?? arrow.points); + const isSegmentMove = + arrow.points.length > 2 && + updatedPoints + .map((point, idx) => !pointsEqual(arrow.points[idx], point)) + .filter((diff) => diff).length; + console.log(isSegmentMove); + + if ( + arrow.points.length > 2 && + !pointsEqual(arrow.points[0], updatedPoints[0]) && + !pointsEqual(arrow.points[1], updatedPoints[1]) + ) { + updatedPoints.unshift(arrow.points[0]); + } + if ( + arrow.points.length > 2 && + !pointsEqual( + arrow.points[arrow.points.length - 1], + updatedPoints[updatedPoints.length - 1], + ) && + !pointsEqual( + arrow.points[arrow.points.length - 2], + updatedPoints[updatedPoints.length - 2], + ) + ) { + updatedPoints.push(arrow.points[arrow.points.length - 1]); + } + //console.log(arrow.points.length, updatedPoints.length); const nextFixedSegments = arrow.points .map((p, idx) => { - const existingSegment = arrow.fixedSegments?.find( - (segment) => - segment.start[0] === arrow.points[idx - 1][0] && - segment.start[1] === arrow.points[idx - 1][1] && - segment.end[0] === arrow.points[idx][0] && - segment.end[1] === arrow.points[idx][1], - ); + const existingSegment = + idx > 1 + ? arrow.fixedSegments?.find( + (segment) => + segment.start[0] === arrow.points[idx - 1][0] && + segment.start[1] === arrow.points[idx - 1][1] && + segment.end[0] === arrow.points[idx][0] && + segment.end[1] === arrow.points[idx][1], + ) + : undefined; if (existingSegment) { return [existingSegment, idx] as const; } if ( - idx > 0 && + idx > 1 && !pointsEqual(p, updatedPoints[idx]) && !pointsEqual(arrow.points[idx - 1], updatedPoints[idx - 1]) ) { @@ -160,11 +192,29 @@ export const updateElbowArrowPoints = ( return null; }) - .filter((segment) => segment !== null) + .filter((segment) => segment != null) .sort((a, b) => a![1] - b![1]) // @ts-ignore .map(([segment, _]): FixedSegment => segment); + nextFixedSegments.forEach((segment) => { + debugDrawPoint( + pointFrom( + arrow.x + segment.start[0], + arrow.y + segment.start[1], + ), + { color: "green", permanent: true }, + ); + debugDrawPoint( + pointFrom( + arrow.x + segment.end[0], + arrow.y + segment.end[1], + ), + { color: "red", permanent: true }, + ); + }); + debugCloseFrame(); + let state = { x: arrow.x, y: arrow.y, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index ca94a6023cc4..db3ab93fc44b 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -25,6 +25,7 @@ import type { AppClassProperties, NullableGridSize, Zoom, + PointerDownState, } from "../types"; import { mutateElement } from "./mutateElement"; @@ -57,6 +58,7 @@ import { type LocalPoint, pointDistance, pointTranslate, + vectorFromPoint, } from "../../math"; import { getBezierCurveLength, @@ -66,6 +68,7 @@ import { mapIntervalToBezierT, } from "../shapes"; import { getGridPoint } from "../snapping"; +import { headingIsHorizontal, vectorToHeading } from "./heading"; const editorMidPointsCache: { version: number | null; @@ -1275,7 +1278,6 @@ export class LinearElementEditor { }, ) { const { points } = element; - const targets = Array.from(targetPoints); // 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 @@ -1285,7 +1287,7 @@ export class LinearElementEditor { let offsetX = 0; let offsetY = 0; - const selectedOriginPoint = targets.find(({ index }) => index === 0); + const selectedOriginPoint = targetPoints.find(({ index }) => index === 0); if (selectedOriginPoint) { offsetX = @@ -1295,7 +1297,7 @@ export class LinearElementEditor { } const nextPoints: LocalPoint[] = points.map((p, idx) => { - const selectedPointData = targets.find((t) => t.index === idx); + const selectedPointData = targetPoints.find((t) => t.index === idx); if (selectedPointData) { if (selectedPointData.index === 0) { return p; @@ -1318,7 +1320,7 @@ export class LinearElementEditor { offsetY, otherUpdates, { - isDragging: targets.reduce( + isDragging: targetPoints.reduce( (dragging, targetPoint): boolean => dragging || targetPoint.isDragging === true, false, @@ -1797,6 +1799,66 @@ export class LinearElementEditor { ); } } + + static moveFixedSegment( + linearElement: LinearElementEditor, + x: number, + y: number, + elementsMap: ElementsMap, + pointerDownState: PointerDownState, + state: AppState, + ) { + const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords( + linearElement, + { x: pointerDownState.origin.x, y: pointerDownState.origin.y }, + state, + elementsMap, + ); + const index = + segmentMidPoint && + LinearElementEditor.getSegmentMidPointIndex( + linearElement, + state, + segmentMidPoint, + elementsMap, + ); + const element = LinearElementEditor.getElement( + linearElement.elementId, + elementsMap, + ); + + if (element && index && index > 0) { + const isHorizontal = headingIsHorizontal( + vectorToHeading( + vectorFromPoint(element.points[index], element.points[index - 1]), + ), + ); + + LinearElementEditor.movePoints(element, [ + { + index: index - 1, + point: pointFrom( + !isHorizontal ? x - element.x : element.points[index - 1][0], + isHorizontal ? y - element.y : element.points[index - 1][1], + ), + isDragging: true, + }, + { + index, + point: pointFrom( + !isHorizontal ? x - element.x : element.points[index][0], + isHorizontal ? y - element.y : element.points[index][1], + ), + isDragging: true, + }, + ]); + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + state, + ); + } + } } const normalizeSelectedPoints = ( diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index 3d203dd99a72..77e554d2f36a 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -61,6 +61,7 @@ export const mutateElement = >( }, ), }; + //console.log(updates); } else { updates = { ...getSizeFromPoints(points), ...updates }; } diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 5a73d7834a24..9647f2e54b95 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -531,10 +531,10 @@ const renderLinearPointHandles = ( false, !fixedSegments.find( (segment) => - segment.start[0] === points[idx - 1][0] && - segment.start[1] === points[idx - 1][1] && - segment.end[0] === points[idx][0] && - segment.end[1] === points[idx][1], + segment.start[0] === points[idx][0] && + segment.start[1] === points[idx][1] && + segment.end[0] === points[idx + 1][0] && + segment.end[1] === points[idx + 1][1], ), ); } From 4c98a3bf979b4a8fa7883819d093e85d66ed9d9b Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 28 Nov 2024 16:54:45 +0100 Subject: [PATCH 128/283] Move only update one or two points --- packages/excalidraw/element/elbowarrow.ts | 1 - .../excalidraw/element/linearElementEditor.ts | 37 ++++++++++++------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 0e0161203b9a..f7806959b6a1 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -134,7 +134,6 @@ export const updateElbowArrowPoints = ( updatedPoints .map((point, idx) => !pointsEqual(arrow.points[idx], point)) .filter((diff) => diff).length; - console.log(isSegmentMove); if ( arrow.points.length > 2 && diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index db3ab93fc44b..7fc95ed99b63 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1296,22 +1296,33 @@ export class LinearElementEditor { selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1]; } - const nextPoints: LocalPoint[] = points.map((p, idx) => { - const selectedPointData = targetPoints.find((t) => t.index === idx); - if (selectedPointData) { - if (selectedPointData.index === 0) { + const nextPoints: LocalPoint[] = isElbowArrow(element) + ? points.map((p, idx) => { + const selectedPointData = targetPoints.find((t) => t.index === idx); + if (selectedPointData) { + return selectedPointData.point; + } + return p; - } + }) + : points.map((p, idx) => { + const selectedPointData = targetPoints.find((t) => t.index === idx); + if (selectedPointData) { + if (selectedPointData.index === 0) { + return p; + } - const deltaX = - selectedPointData.point[0] - points[selectedPointData.index][0]; - const deltaY = - selectedPointData.point[1] - points[selectedPointData.index][1]; + const deltaX = + selectedPointData.point[0] - points[selectedPointData.index][0]; + const deltaY = + selectedPointData.point[1] - points[selectedPointData.index][1]; - return pointFrom(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY); - } - return offsetX || offsetY ? pointFrom(p[0] - offsetX, p[1] - offsetY) : p; - }); + return pointFrom(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY); + } + return offsetX || offsetY + ? pointFrom(p[0] - offsetX, p[1] - offsetY) + : p; + }); LinearElementEditor._updatePoints( element, From 177d2150f9aad73e41aa25f0062eeba06118f02d Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 28 Nov 2024 19:31:54 +0100 Subject: [PATCH 129/283] Fixed start/end point move Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 189 ++++++++++-------- .../excalidraw/element/linearElementEditor.ts | 40 ++-- packages/excalidraw/element/mutateElement.ts | 1 - 3 files changed, 122 insertions(+), 108 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index f7806959b6a1..92e9a403b109 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -22,7 +22,6 @@ import { toBrandedType, tupleToCoors, } from "../utils"; -import { debugCloseFrame, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -122,97 +121,123 @@ export const updateElbowArrowPoints = ( isDragging?: boolean; }, ): ElementUpdate => { + if (arrow.points.length < 2) { + return { points: updates.points }; + } invariant( - arrow.points.length < 2 || arrow.points.length === updates.points.length, + arrow.points.length === updates.points.length, "Updated point array length must match the arrow point length (i.e. you can't add new points manually to elbow arrows)", ); - const updatedPoints = Array.from(updates.points ?? arrow.points); + const updatedPoints = Array.from(updates.points); // TODO: Do we need the cloning here? + const renormalizedUpdatedPoints = updatedPoints.map((point, idx) => { + if (idx === 0) { + return point; + } - const isSegmentMove = - arrow.points.length > 2 && - updatedPoints - .map((point, idx) => !pointsEqual(arrow.points[idx], point)) - .filter((diff) => diff).length; + return pointFrom( + point[0] - updatedPoints[0][0], + point[1] - updatedPoints[0][1], + ); + }); - if ( - arrow.points.length > 2 && - !pointsEqual(arrow.points[0], updatedPoints[0]) && - !pointsEqual(arrow.points[1], updatedPoints[1]) - ) { - updatedPoints.unshift(arrow.points[0]); - } - if ( - arrow.points.length > 2 && + // Check is needed because fixed point binding might re-adjust + // the end or start point + const firstAndLastPointMoved = + renormalizedUpdatedPoints.length > 2 && // Edge case where we have a linear arrow + !pointsEqual(arrow.points[0], renormalizedUpdatedPoints[0]) && !pointsEqual( arrow.points[arrow.points.length - 1], - updatedPoints[updatedPoints.length - 1], - ) && - !pointsEqual( - arrow.points[arrow.points.length - 2], - updatedPoints[updatedPoints.length - 2], - ) - ) { - updatedPoints.push(arrow.points[arrow.points.length - 1]); - } - //console.log(arrow.points.length, updatedPoints.length); - const nextFixedSegments = arrow.points - .map((p, idx) => { - const existingSegment = - idx > 1 - ? arrow.fixedSegments?.find( - (segment) => - segment.start[0] === arrow.points[idx - 1][0] && - segment.start[1] === arrow.points[idx - 1][1] && - segment.end[0] === arrow.points[idx][0] && - segment.end[1] === arrow.points[idx][1], - ) - : undefined; - if (existingSegment) { - return [existingSegment, idx] as const; - } + renormalizedUpdatedPoints[renormalizedUpdatedPoints.length - 1], + ); + const isSegmentMove = + arrow.points.length >= 2 && + renormalizedUpdatedPoints + .map((point, idx) => !pointsEqual(arrow.points[idx], point)) + .filter((diff) => diff).length === 2 && + !firstAndLastPointMoved; + + let nextFixedSegments: FixedSegment[] = []; + if (isSegmentMove) { + if ( + !pointsEqual(arrow.points[0], updatedPoints[0]) && + !pointsEqual(arrow.points[1], updatedPoints[1]) + ) { + updatedPoints.unshift(arrow.points[0]); + } + if ( + !pointsEqual( + arrow.points[arrow.points.length - 1], + updatedPoints[updatedPoints.length - 1], + ) && + !pointsEqual( + arrow.points[arrow.points.length - 2], + updatedPoints[updatedPoints.length - 2], + ) + ) { + updatedPoints.push(arrow.points[arrow.points.length - 1]); + } + //console.log(arrow.points.length, updatedPoints.length); + nextFixedSegments = arrow.points + .map((p, idx) => { + const existingSegment = + idx > 1 + ? arrow.fixedSegments?.find( + (segment) => + segment.start[0] === arrow.points[idx - 1][0] && + segment.start[1] === arrow.points[idx - 1][1] && + segment.end[0] === arrow.points[idx][0] && + segment.end[1] === arrow.points[idx][1], + ) + : undefined; + if (existingSegment) { + return [existingSegment, idx] as const; + } - if ( - idx > 1 && - !pointsEqual(p, updatedPoints[idx]) && - !pointsEqual(arrow.points[idx - 1], updatedPoints[idx - 1]) - ) { - // If the previous point is not the same as the updated previous point - // and the current point is not the same as the updated point, then a - // new segment is being moved / fixed - return [ - { - start: updatedPoints[idx - 1], - end: updatedPoints[idx], - }, - idx, - ] as const; - } + if ( + idx > 1 && + !pointsEqual(p, updatedPoints[idx]) && + !pointsEqual(arrow.points[idx - 1], updatedPoints[idx - 1]) + ) { + // If the previous point is not the same as the updated previous point + // and the current point is not the same as the updated point, then a + // new segment is being moved / fixed + return [ + { + start: updatedPoints[idx - 1], + end: updatedPoints[idx], + }, + idx, + ] as const; + } - return null; - }) - .filter((segment) => segment != null) - .sort((a, b) => a![1] - b![1]) - // @ts-ignore - .map(([segment, _]): FixedSegment => segment); - - nextFixedSegments.forEach((segment) => { - debugDrawPoint( - pointFrom( - arrow.x + segment.start[0], - arrow.y + segment.start[1], - ), - { color: "green", permanent: true }, - ); - debugDrawPoint( - pointFrom( - arrow.x + segment.end[0], - arrow.y + segment.end[1], - ), - { color: "red", permanent: true }, - ); - }); - debugCloseFrame(); + return null; + }) + .filter((segment) => segment != null) + .sort((a, b) => a![1] - b![1]) + // @ts-ignore + .map(([segment, _]): FixedSegment => segment); + + // nextFixedSegments.forEach((segment) => { + // debugDrawPoint( + // pointFrom( + // arrow.x + segment.start[0], + // arrow.y + segment.start[1], + // ), + // { color: "green", permanent: true }, + // ); + // debugDrawPoint( + // pointFrom( + // arrow.x + segment.end[0], + // arrow.y + segment.end[1], + // ), + // { color: "red", permanent: true }, + // ); + // }); + // debugCloseFrame(); + } + + //debugDrawPoint(pointFrom(arrow.x, arrow.y), { permanent: true }); let state = { x: arrow.x, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 7fc95ed99b63..9dbb0a5aaeab 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1291,38 +1291,28 @@ export class LinearElementEditor { if (selectedOriginPoint) { offsetX = - selectedOriginPoint.point[0] + points[selectedOriginPoint.index][0]; + selectedOriginPoint.point[0] + points[selectedOriginPoint.index][0]; // points[selectedOriginPoint.index] = points[0] offsetY = selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1]; } - const nextPoints: LocalPoint[] = isElbowArrow(element) - ? points.map((p, idx) => { - const selectedPointData = targetPoints.find((t) => t.index === idx); - if (selectedPointData) { - return selectedPointData.point; - } - + const nextPoints: LocalPoint[] = points.map((p, idx) => { + const selectedPointData = targetPoints.find((t) => t.index === idx); + if (selectedPointData) { + if (selectedPointData.index === 0) { return p; - }) - : points.map((p, idx) => { - const selectedPointData = targetPoints.find((t) => t.index === idx); - if (selectedPointData) { - if (selectedPointData.index === 0) { - return p; - } + } - const deltaX = - selectedPointData.point[0] - points[selectedPointData.index][0]; - const deltaY = - selectedPointData.point[1] - points[selectedPointData.index][1]; + const deltaX = + selectedPointData.point[0] - points[selectedPointData.index][0]; + const deltaY = + selectedPointData.point[1] - points[selectedPointData.index][1]; - return pointFrom(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY); - } - return offsetX || offsetY - ? pointFrom(p[0] - offsetX, p[1] - offsetY) - : p; - }); + return pointFrom(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY); + } + + return offsetX || offsetY ? pointFrom(p[0] - offsetX, p[1] - offsetY) : p; + }); LinearElementEditor._updatePoints( element, diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index 77e554d2f36a..3d203dd99a72 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -61,7 +61,6 @@ export const mutateElement = >( }, ), }; - //console.log(updates); } else { updates = { ...getSizeFromPoints(points), ...updates }; } From ce7736df99d9d305dd00f51b6d4a07ad63854f46 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 28 Nov 2024 20:20:10 +0100 Subject: [PATCH 130/283] Relative point calc fix Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 68 +++++++++++------------ 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 92e9a403b109..8423fa07aa49 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -22,6 +22,7 @@ import { toBrandedType, tupleToCoors, } from "../utils"; +import { debugCloseFrame, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -177,7 +178,7 @@ export const updateElbowArrowPoints = ( ) { updatedPoints.push(arrow.points[arrow.points.length - 1]); } - //console.log(arrow.points.length, updatedPoints.length); + nextFixedSegments = arrow.points .map((p, idx) => { const existingSegment = @@ -218,27 +219,25 @@ export const updateElbowArrowPoints = ( // @ts-ignore .map(([segment, _]): FixedSegment => segment); - // nextFixedSegments.forEach((segment) => { - // debugDrawPoint( - // pointFrom( - // arrow.x + segment.start[0], - // arrow.y + segment.start[1], - // ), - // { color: "green", permanent: true }, - // ); - // debugDrawPoint( - // pointFrom( - // arrow.x + segment.end[0], - // arrow.y + segment.end[1], - // ), - // { color: "red", permanent: true }, - // ); - // }); - // debugCloseFrame(); + nextFixedSegments.forEach((segment) => { + debugDrawPoint( + pointFrom( + arrow.x + segment.start[0], + arrow.y + segment.start[1], + ), + { color: "green", permanent: true }, + ); + debugDrawPoint( + pointFrom( + arrow.x + segment.end[0], + arrow.y + segment.end[1], + ), + { color: "red", permanent: true }, + ); + }); + debugCloseFrame(); } - //debugDrawPoint(pointFrom(arrow.x, arrow.y), { permanent: true }); - let state = { x: arrow.x, y: arrow.y, @@ -251,23 +250,18 @@ export const updateElbowArrowPoints = ( const pointPairs: [ElbowArrowState, readonly LocalPoint[]][] = nextFixedSegments.map((segment, segmentIdx) => { - const start = pointFrom( - segment.start[0] - startPoint[0], - segment.start[1] - startPoint[1], - ); - const end = pointFrom( - segment.end[0] - startPoint[0], - segment.end[1] - startPoint[1], - ); const ret: [ElbowArrowState, readonly LocalPoint[]] = [ state, - [startPoint, segmentIdx === 0 ? end : start], + [ + startPoint, + pointFrom( + arrow.x + segment.start[0] - state.x, + arrow.y + segment.start[1] - state.y, + ), + ], ]; - startPoint = - segmentIdx === nextFixedSegments.length - 1 - ? segment.start - : segment.end; + startPoint = pointFrom(0, 0); state = { ...state, @@ -285,7 +279,13 @@ export const updateElbowArrowPoints = ( endBinding: arrow.endBinding, endArrowhead: arrow.endArrowhead, }, - [startPoint, updatedPoints[updatedPoints.length - 1]], + [ + startPoint, + pointFrom( + arrow.x + updatedPoints[updatedPoints.length - 1][0] - state.x, + arrow.y + updatedPoints[updatedPoints.length - 1][1] - state.y, + ), + ], ]); const simplifiedPoints = getElbowArrowCornerPoints( From 10d1b42910c7176b7660c1430b1f28217054b7a2 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 29 Nov 2024 19:50:46 +0100 Subject: [PATCH 131/283] Midpoint fixed in inverse move --- packages/excalidraw/element/elbowarrow.ts | 492 +++++++++--------- .../excalidraw/element/linearElementEditor.ts | 66 ++- .../excalidraw/renderer/interactiveScene.ts | 8 +- 3 files changed, 288 insertions(+), 278 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 8423fa07aa49..32b8b754aac5 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -22,7 +22,6 @@ import { toBrandedType, tupleToCoors, } from "../utils"; -import { debugCloseFrame, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -94,18 +93,15 @@ const generatePoints = memo( ( state: ElbowArrowState, points: readonly LocalPoint[], - segmentIdx: number, totalSegments: number, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, options?: { isDragging?: boolean; - heading?: Heading; }, ) => routeElbowArrow(state, elementsMap, points, { ...options, - ...(segmentIdx !== 0 ? { startIsMidPoint: true } : {}), - ...(segmentIdx !== totalSegments ? { endIsMidPoint: true } : {}), + ...(totalSegments > 0 ? { turnOffAvoidance: true } : {}), }) ?? [], ); @@ -125,6 +121,7 @@ export const updateElbowArrowPoints = ( if (arrow.points.length < 2) { return { points: updates.points }; } + invariant( arrow.points.length === updates.points.length, "Updated point array length must match the arrow point length (i.e. you can't add new points manually to elbow arrows)", @@ -154,30 +151,34 @@ export const updateElbowArrowPoints = ( const isSegmentMove = arrow.points.length >= 2 && renormalizedUpdatedPoints - .map((point, idx) => !pointsEqual(arrow.points[idx], point)) + .map( + (point, idx) => + arrow.points[idx][0] !== point[0] || + arrow.points[idx][1] !== point[1], + ) .filter((diff) => diff).length === 2 && !firstAndLastPointMoved; - let nextFixedSegments: FixedSegment[] = []; + let nextFixedSegments: FixedSegment[] = arrow.fixedSegments ?? []; if (isSegmentMove) { - if ( - !pointsEqual(arrow.points[0], updatedPoints[0]) && - !pointsEqual(arrow.points[1], updatedPoints[1]) - ) { - updatedPoints.unshift(arrow.points[0]); - } - if ( - !pointsEqual( - arrow.points[arrow.points.length - 1], - updatedPoints[updatedPoints.length - 1], - ) && - !pointsEqual( - arrow.points[arrow.points.length - 2], - updatedPoints[updatedPoints.length - 2], - ) - ) { - updatedPoints.push(arrow.points[arrow.points.length - 1]); - } + // if ( + // !pointsEqual(arrow.points[0], updatedPoints[0]) && + // !pointsEqual(arrow.points[1], updatedPoints[1]) + // ) { + // updatedPoints.unshift(arrow.points[0]); + // } + // if ( + // !pointsEqual( + // arrow.points[arrow.points.length - 1], + // updatedPoints[updatedPoints.length - 1], + // ) && + // !pointsEqual( + // arrow.points[arrow.points.length - 2], + // updatedPoints[updatedPoints.length - 2], + // ) + // ) { + // updatedPoints.push(arrow.points[arrow.points.length - 1]); + // } nextFixedSegments = arrow.points .map((p, idx) => { @@ -185,10 +186,10 @@ export const updateElbowArrowPoints = ( idx > 1 ? arrow.fixedSegments?.find( (segment) => - segment.start[0] === arrow.points[idx - 1][0] && - segment.start[1] === arrow.points[idx - 1][1] && - segment.end[0] === arrow.points[idx][0] && - segment.end[1] === arrow.points[idx][1], + pointsEqual( + segment.start, + renormalizedUpdatedPoints[idx - 1], + ) && pointsEqual(segment.end, renormalizedUpdatedPoints[idx]), ) : undefined; if (existingSegment) { @@ -197,16 +198,20 @@ export const updateElbowArrowPoints = ( if ( idx > 1 && - !pointsEqual(p, updatedPoints[idx]) && - !pointsEqual(arrow.points[idx - 1], updatedPoints[idx - 1]) + !pointsEqual(p, renormalizedUpdatedPoints[idx]) && + !pointsEqual( + arrow.points[idx - 1], + renormalizedUpdatedPoints[idx - 1], + ) ) { // If the previous point is not the same as the updated previous point // and the current point is not the same as the updated point, then a // new segment is being moved / fixed + return [ { - start: updatedPoints[idx - 1], - end: updatedPoints[idx], + start: renormalizedUpdatedPoints[idx - 1], + end: renormalizedUpdatedPoints[idx], }, idx, ] as const; @@ -218,24 +223,6 @@ export const updateElbowArrowPoints = ( .sort((a, b) => a![1] - b![1]) // @ts-ignore .map(([segment, _]): FixedSegment => segment); - - nextFixedSegments.forEach((segment) => { - debugDrawPoint( - pointFrom( - arrow.x + segment.start[0], - arrow.y + segment.start[1], - ), - { color: "green", permanent: true }, - ); - debugDrawPoint( - pointFrom( - arrow.x + segment.end[0], - arrow.y + segment.end[1], - ), - { color: "red", permanent: true }, - ); - }); - debugCloseFrame(); } let state = { @@ -290,19 +277,83 @@ export const updateElbowArrowPoints = ( const simplifiedPoints = getElbowArrowCornerPoints( removeElbowArrowShortSegments( - pointPairs.map(([state, points], idx) => - generatePoints( + pointPairs.map(([state, points]) => { + const nextPoints = generatePoints( state, points, - idx, pointPairs.length - 1, elementsMap, options, - ), - ), + ); + + return nextPoints; + }), ), ).flat(); + nextFixedSegments.forEach((_, idx) => { + nextFixedSegments[idx].start = pointFrom( + arrow.x + nextFixedSegments[idx].start[0] - simplifiedPoints[0][0], + arrow.y + nextFixedSegments[idx].start[1] - simplifiedPoints[0][1], + ); + nextFixedSegments[idx].end = pointFrom( + arrow.x + nextFixedSegments[idx].end[0] - simplifiedPoints[0][0], + arrow.y + nextFixedSegments[idx].end[1] - simplifiedPoints[0][1], + ); + const isHorizontal = headingIsHorizontal( + vectorToHeading( + vectorFromPoint( + nextFixedSegments[idx].end, + nextFixedSegments[idx].start, + ), + ), + ); + + const similarIndices = simplifiedPoints + .map((p, i) => { + if (i > 0) { + const q = simplifiedPoints[i - 1]; + + if ( + (isHorizontal && + p[1] === simplifiedPoints[0][1] + nextFixedSegments[idx].end[1] && + q[1] === + simplifiedPoints[0][1] + nextFixedSegments[idx].start[1]) || + (!isHorizontal && + p[0] === simplifiedPoints[0][0] + nextFixedSegments[idx].end[0] && + q[0] === simplifiedPoints[0][0] + nextFixedSegments[idx].start[0]) + ) { + return i; + } + } + + return null; + }) + .filter((i) => i != null) as number[]; + + if (similarIndices.length > 0) { + const i = similarIndices[0]; + nextFixedSegments[idx] = { + start: pointFrom( + isHorizontal + ? simplifiedPoints[i - 1][0] - simplifiedPoints[0][0] + : nextFixedSegments[idx].start[0], + !isHorizontal + ? simplifiedPoints[i - 1][1] - simplifiedPoints[0][1] + : nextFixedSegments[idx].start[1], + ), + end: pointFrom( + isHorizontal + ? simplifiedPoints[i][0] - simplifiedPoints[0][0] + : nextFixedSegments[idx].end[0], + !isHorizontal + ? simplifiedPoints[i][1] - simplifiedPoints[0][1] + : nextFixedSegments[idx].end[1], + ), + }; + } + }); + return normalizeArrowElementUpdate(simplifiedPoints, nextFixedSegments); }; @@ -342,9 +393,7 @@ const getElbowArrowData = ( nextPoints: readonly LocalPoint[], options?: { isDragging?: boolean; - startIsMidPoint?: boolean; - endIsMidPoint?: boolean; - heading?: Heading; + turnOffAvoidance?: boolean; }, ) => { const origStartGlobalPoint: GlobalPoint = pointTranslate< @@ -364,29 +413,25 @@ const getElbowArrowData = ( const [hoveredStartElement, hoveredEndElement] = options?.isDragging ? getHoveredElements(origStartGlobalPoint, origEndGlobalPoint, elementsMap) : [startElement, endElement]; - const startGlobalPoint = options?.startIsMidPoint - ? origStartGlobalPoint - : getGlobalPoint( - arrow.startBinding?.fixedPoint, - origStartGlobalPoint, - origEndGlobalPoint, - elementsMap, - startElement, - hoveredStartElement, + const startGlobalPoint = getGlobalPoint( + arrow.startBinding?.fixedPoint, + origStartGlobalPoint, + origEndGlobalPoint, + elementsMap, + startElement, + hoveredStartElement, - options?.isDragging, - ); - const endGlobalPoint = options?.endIsMidPoint - ? origEndGlobalPoint - : getGlobalPoint( - arrow.endBinding?.fixedPoint, - origEndGlobalPoint, - origStartGlobalPoint, - elementsMap, - endElement, - hoveredEndElement, - options?.isDragging, - ); + options?.isDragging, + ); + const endGlobalPoint = getGlobalPoint( + arrow.endBinding?.fixedPoint, + origEndGlobalPoint, + origStartGlobalPoint, + elementsMap, + endElement, + hoveredEndElement, + options?.isDragging, + ); const startHeading = getBindPointHeading( startGlobalPoint, endGlobalPoint, @@ -420,8 +465,6 @@ const getElbowArrowData = ( startHeading, arrow.startArrowhead ? FIXED_BINDING_DISTANCE * 6 - : options?.startIsMidPoint - ? 0.01 : FIXED_BINDING_DISTANCE * 2, 1, ), @@ -434,14 +477,13 @@ const getElbowArrowData = ( endHeading, arrow.endArrowhead ? FIXED_BINDING_DISTANCE * 6 - : options?.endIsMidPoint - ? 0.01 : FIXED_BINDING_DISTANCE * 2, 1, ), ) : endPointBounds; const boundsOverlap = + options?.turnOffAvoidance || pointInsideBounds( startGlobalPoint, hoveredEndElement @@ -461,116 +503,50 @@ const getElbowArrowData = ( : startPointBounds, ); const commonBounds = commonAABB( - boundsOverlap && !(options?.startIsMidPoint || options?.endIsMidPoint) + boundsOverlap ? [startPointBounds, endPointBounds] : [startElementBounds, endElementBounds], ); - const dynamicAABBs = - options?.startIsMidPoint || options?.endIsMidPoint - ? generateSegmentedDynamicAABBs( - padAABB( - startElementBounds, - options?.startIsMidPoint - ? [0, 0, 0, 0] - : offsetFromHeading( - startHeading, - BASE_PADDING - - (arrow.startArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2), - BASE_PADDING, - ), - ), - padAABB( - endElementBounds, - options?.endIsMidPoint - ? [0, 0, 0, 0] - : offsetFromHeading( - endHeading, - BASE_PADDING - - (arrow.endArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2), - BASE_PADDING, - ), - ), - options?.startIsMidPoint, - options?.endIsMidPoint, + 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, - startGlobalPoint, - endGlobalPoint, - options?.heading, + !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, + 0, ) - : generateDynamicAABBs( - options?.startIsMidPoint - ? ([ - hoveredStartElement!.x + hoveredStartElement!.width / 2 - 1, - hoveredStartElement!.y + hoveredStartElement!.height / 2 - 1, - hoveredStartElement!.x + hoveredStartElement!.width / 2 + 1, - hoveredStartElement!.y + hoveredStartElement!.height / 2 + 1, - ] as Bounds) - : boundsOverlap && - !(options?.startIsMidPoint || options?.endIsMidPoint) - ? startPointBounds - : startElementBounds, - options?.endIsMidPoint - ? ([ - hoveredEndElement!.x + hoveredEndElement!.width / 2 - 1, - hoveredEndElement!.y + hoveredEndElement!.height / 2 - 1, - hoveredEndElement!.x + hoveredEndElement!.width / 2 + 1, - hoveredEndElement!.y + hoveredEndElement!.height / 2 + 1, - ] as Bounds) - : boundsOverlap && - !(options?.startIsMidPoint || options?.endIsMidPoint) - ? endPointBounds - : endElementBounds, - commonBounds, - options?.startIsMidPoint - ? [0, 0, 0, 0] - : boundsOverlap && - !(options?.startIsMidPoint || options?.endIsMidPoint) - ? 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, - ), - options?.endIsMidPoint - ? [0, 0, 0, 0] - : boundsOverlap && - !(options?.startIsMidPoint || options?.endIsMidPoint) - ? 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 && - !(options?.startIsMidPoint || options?.endIsMidPoint), - hoveredStartElement && aabbForElement(hoveredStartElement), - hoveredEndElement && aabbForElement(hoveredEndElement), - options?.endIsMidPoint, - options?.startIsMidPoint, - ); + : 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], @@ -615,9 +591,7 @@ const routeElbowArrow = ( nextPoints: readonly LocalPoint[], options?: { isDragging?: boolean; - startIsMidPoint?: boolean; - endIsMidPoint?: boolean; - heading?: Heading; + turnOffAvoidance?: boolean; }, ): GlobalPoint[] | null => { const { @@ -840,73 +814,73 @@ const pathTo = (start: Node, node: Node) => { const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]); -const padAABB = (bounds: Bounds, offset: [number, number, number, number]) => - [ - bounds[0] - offset[3], - bounds[1] - offset[0], - bounds[2] + offset[1], - bounds[3] + offset[2], - ] as Bounds; - -const generateSegmentedDynamicAABBs = ( - a: Bounds, - b: Bounds, - startIsMidPoint: boolean | undefined, - endIsMidPoint: boolean | undefined, - startHeading: Heading, - endHeading: Heading, - startGlobalPoint: GlobalPoint, - endGlobalPoint: GlobalPoint, - heading: Heading | undefined, -): Bounds[] => { - let first = a; - let second = b; - // const startDongle = startIsMidPoint - // ? startGlobalPoint - // : getDonglePosition(a, startHeading, startGlobalPoint); - // const endDongle = endIsMidPoint - // ? endGlobalPoint - // : getDonglePosition(b, endHeading, endGlobalPoint); - const startDongle = getDonglePosition(a, startHeading, startGlobalPoint); - const endDongle = getDonglePosition(b, endHeading, endGlobalPoint); - - if (startIsMidPoint && heading && startDongle && endDongle) { - if (headingIsHorizontal(heading)) { - if (startDongle[0] < endDongle[0]) { - first = [startDongle[0], a[1], endDongle[0], a[3]]; - } else { - first = [endDongle[0], a[1], startDongle[0], a[3]]; - } - } else if (startDongle[1] < endDongle[1]) { - first = [a[0], startDongle[1], a[2], endDongle[1]]; - } else { - first = [a[0], endDongle[1], a[2], startDongle[1]]; - } - } - - if (endIsMidPoint && heading && startDongle && endDongle) { - if (headingIsHorizontal(heading)) { - if (startDongle[0] < endDongle[0]) { - second = [startDongle[0], b[1], endDongle[0], b[3]]; - } else { - second = [endDongle[0], b[1], startDongle[0], b[3]]; - } - } else if (startDongle[1] < endDongle[1]) { - second = [b[0], startDongle[1], b[2], endDongle[1]]; - } else { - second = [b[0], endDongle[1], b[2], startDongle[1]]; - } - } - - const boundsOverlap = - pointInsideBounds(startGlobalPoint, second) || - pointInsideBounds(endGlobalPoint, first); - if (boundsOverlap) { - return [a, b]; - } - - return [first, second] as Bounds[]; -}; +// const padAABB = (bounds: Bounds, offset: [number, number, number, number]) => +// [ +// bounds[0] - offset[3], +// bounds[1] - offset[0], +// bounds[2] + offset[1], +// bounds[3] + offset[2], +// ] as Bounds; + +// const generateSegmentedDynamicAABBs = ( +// a: Bounds, +// b: Bounds, +// startIsMidPoint: boolean | undefined, +// endIsMidPoint: boolean | undefined, +// startHeading: Heading, +// endHeading: Heading, +// startGlobalPoint: GlobalPoint, +// endGlobalPoint: GlobalPoint, +// heading: Heading | undefined, +// ): Bounds[] => { +// let first = a; +// let second = b; +// // const startDongle = startIsMidPoint +// // ? startGlobalPoint +// // : getDonglePosition(a, startHeading, startGlobalPoint); +// // const endDongle = endIsMidPoint +// // ? endGlobalPoint +// // : getDonglePosition(b, endHeading, endGlobalPoint); +// const startDongle = getDonglePosition(a, startHeading, startGlobalPoint); +// const endDongle = getDonglePosition(b, endHeading, endGlobalPoint); + +// if (startIsMidPoint && heading && startDongle && endDongle) { +// if (headingIsHorizontal(heading)) { +// if (startDongle[0] < endDongle[0]) { +// first = [startDongle[0], a[1], endDongle[0], a[3]]; +// } else { +// first = [endDongle[0], a[1], startDongle[0], a[3]]; +// } +// } else if (startDongle[1] < endDongle[1]) { +// first = [a[0], startDongle[1], a[2], endDongle[1]]; +// } else { +// first = [a[0], endDongle[1], a[2], startDongle[1]]; +// } +// } + +// if (endIsMidPoint && heading && startDongle && endDongle) { +// if (headingIsHorizontal(heading)) { +// if (startDongle[0] < endDongle[0]) { +// second = [startDongle[0], b[1], endDongle[0], b[3]]; +// } else { +// second = [endDongle[0], b[1], startDongle[0], b[3]]; +// } +// } else if (startDongle[1] < endDongle[1]) { +// second = [b[0], startDongle[1], b[2], endDongle[1]]; +// } else { +// second = [b[0], endDongle[1], b[2], startDongle[1]]; +// } +// } + +// const boundsOverlap = +// pointInsideBounds(startGlobalPoint, second) || +// pointInsideBounds(endGlobalPoint, first); +// if (boundsOverlap) { +// return [a, b]; +// } + +// return [first, second] as Bounds[]; +// }; /** * Create dynamically resizing, always touching diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 9dbb0a5aaeab..17224709e900 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1809,26 +1809,62 @@ export class LinearElementEditor { pointerDownState: PointerDownState, state: AppState, ) { - const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords( - linearElement, - { x: pointerDownState.origin.x, y: pointerDownState.origin.y }, - state, - elementsMap, - ); - const index = - segmentMidPoint && - LinearElementEditor.getSegmentMidPointIndex( - linearElement, - state, - segmentMidPoint, - elementsMap, - ); + // const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords( + // linearElement, + // { x, y }, + // state, + // elementsMap, + // ); + // const index = + // segmentMidPoint && + // LinearElementEditor.getSegmentMidPointIndex( + // linearElement, + // state, + // segmentMidPoint, + // elementsMap, + // ); + const element = LinearElementEditor.getElement( linearElement.elementId, elementsMap, ); - if (element && index && index > 0) { + if (!element) { + return; + } + + const index = + element.points.length - + element.points + // @ts-ignore + .toReversed() + .findIndex((p: LocalPoint, idx: number, points: LocalPoint[]) => { + if (idx === 0) { + return false; + } + + const other = points[idx - 1]; + const midPoint = pointFrom( + (element.x + other[0] + element.x + p[0]) / 2, + (element.y + other[1] + element.y + p[1]) / 2, + ); + + if ( + pointDistance( + //pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), + pointFrom(x, y), + midPoint, + ) * + state.zoom.value < + LinearElementEditor.POINT_HANDLE_SIZE + 1 + ) { + return true; + } + + return false; + }); + + if (index > 0 && index < element.points.length) { const isHorizontal = headingIsHorizontal( vectorToHeading( vectorFromPoint(element.points[index], element.points[index - 1]), diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 9647f2e54b95..8a653480f57d 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -531,10 +531,10 @@ const renderLinearPointHandles = ( false, !fixedSegments.find( (segment) => - segment.start[0] === points[idx][0] && - segment.start[1] === points[idx][1] && - segment.end[0] === points[idx + 1][0] && - segment.end[1] === points[idx + 1][1], + element.x + segment.start[0] === points[idx][0] && + element.y + segment.start[1] === points[idx][1] && + element.x + segment.end[0] === points[idx + 1][0] && + element.y + segment.end[1] === points[idx + 1][1], ), ); } From 7cc2b8816d220ca850333568bbca5dd200777154 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 29 Nov 2024 20:02:05 +0100 Subject: [PATCH 132/283] Disable midpoint cache update for elbow arrows Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/linearElementEditor.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 17224709e900..f9b41f9fa7ec 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -521,11 +521,12 @@ export class LinearElementEditor { ) { return editorMidPointsCache.points; } - LinearElementEditor.updateEditorMidPointsCache( - element, - elementsMap, - appState, - ); + !isElbowArrow(element) && + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + appState, + ); return editorMidPointsCache.points!; }; From cb7a28b5a09cb1dc61470bd76b5da964f428bf22 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 2 Dec 2024 22:27:23 +0100 Subject: [PATCH 133/283] Use midpoint cache Signed-off-by: Mark Tolmacs --- packages/excalidraw/components/App.tsx | 7 +-- .../excalidraw/element/linearElementEditor.ts | 63 +++++-------------- 2 files changed, 16 insertions(+), 54 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d81ad836742a..c408079cbea9 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7883,11 +7883,7 @@ class App extends React.Component { return; } else if ( linearElementEditor.elbowed && - linearElementEditor.pointerDownState.segmentMidpoint.index && - pointDistance( - pointFrom(pointerCoords.x, pointerCoords.y), - pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), - ) >= DRAGGING_THRESHOLD + linearElementEditor.pointerDownState.segmentMidpoint.index ) { const [gridX, gridY] = getGridPoint( pointerCoords.x, @@ -7900,7 +7896,6 @@ class App extends React.Component { gridX, gridY, this.scene.getNonDeletedElementsMap(), - pointerDownState, this.state, ); } else if ( diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index f9b41f9fa7ec..7b42ec9f9c2a 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -25,7 +25,6 @@ import type { AppClassProperties, NullableGridSize, Zoom, - PointerDownState, } from "../types"; import { mutateElement } from "./mutateElement"; @@ -1807,23 +1806,22 @@ export class LinearElementEditor { x: number, y: number, elementsMap: ElementsMap, - pointerDownState: PointerDownState, state: AppState, ) { - // const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords( - // linearElement, - // { x, y }, - // state, - // elementsMap, - // ); - // const index = - // segmentMidPoint && - // LinearElementEditor.getSegmentMidPointIndex( - // linearElement, - // state, - // segmentMidPoint, - // elementsMap, - // ); + const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords( + linearElement, + { x, y }, + state, + elementsMap, + ); + const index = + segmentMidPoint && + LinearElementEditor.getSegmentMidPointIndex( + linearElement, + state, + segmentMidPoint, + elementsMap, + ); const element = LinearElementEditor.getElement( linearElement.elementId, @@ -1834,38 +1832,7 @@ export class LinearElementEditor { return; } - const index = - element.points.length - - element.points - // @ts-ignore - .toReversed() - .findIndex((p: LocalPoint, idx: number, points: LocalPoint[]) => { - if (idx === 0) { - return false; - } - - const other = points[idx - 1]; - const midPoint = pointFrom( - (element.x + other[0] + element.x + p[0]) / 2, - (element.y + other[1] + element.y + p[1]) / 2, - ); - - if ( - pointDistance( - //pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), - pointFrom(x, y), - midPoint, - ) * - state.zoom.value < - LinearElementEditor.POINT_HANDLE_SIZE + 1 - ) { - return true; - } - - return false; - }); - - if (index > 0 && index < element.points.length) { + if (index && index > 0 && index < element.points.length) { const isHorizontal = headingIsHorizontal( vectorToHeading( vectorFromPoint(element.points[index], element.points[index - 1]), From 4efb41c87a69b7f333c8d73967c03425a848127a Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 2 Dec 2024 22:45:21 +0100 Subject: [PATCH 134/283] Fixed segment delete Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 4 +- packages/excalidraw/element/mutateElement.ts | 59 ++++++++++---------- 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 32b8b754aac5..c18a9d21b2f3 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -113,6 +113,7 @@ export const updateElbowArrowPoints = ( elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, updates: { points: readonly LocalPoint[]; + fixedSegments?: FixedSegment[]; }, options?: { isDragging?: boolean; @@ -159,7 +160,8 @@ export const updateElbowArrowPoints = ( .filter((diff) => diff).length === 2 && !firstAndLastPointMoved; - let nextFixedSegments: FixedSegment[] = arrow.fixedSegments ?? []; + let nextFixedSegments: FixedSegment[] = + updates.fixedSegments ?? arrow.fixedSegments ?? []; if (isSegmentMove) { // if ( // !pointsEqual(arrow.points[0], updatedPoints[0]) && diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index 3d203dd99a72..7fcdbb81fc45 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -35,35 +35,38 @@ export const mutateElement = >( // (see https://github.com/microsoft/TypeScript/issues/21732) const { points, fileId } = updates as any; + if (isElbowArrow(element)) { + const mergedElementsMap = toBrandedType( + new Map([ + ...(Scene.getScene(element)?.getNonDeletedElementsMap() ?? []), + ...(changedElements ?? []), + ]), + ); + + updates = { + ...updates, + angle: 0 as Radians, + ...updateElbowArrowPoints( + { + ...element, + x: updates.x || element.x, + y: updates.y || element.y, + }, + mergedElementsMap, + // @ts-ignore + { + ...updates, + points: points || element.points, + }, + { + isDragging, + }, + ), + }; + } + if (typeof points !== "undefined") { - if (isElbowArrow(element)) { - const mergedElementsMap = toBrandedType( - new Map([ - ...(Scene.getScene(element)?.getNonDeletedElementsMap() ?? []), - ...(changedElements ?? []), - ]), - ); - - updates = { - ...updates, - angle: 0 as Radians, - ...updateElbowArrowPoints( - { - ...element, - x: updates.x || element.x, - y: updates.y || element.y, - }, - mergedElementsMap, - // @ts-ignore - updates, - { - isDragging, - }, - ), - }; - } else { - updates = { ...getSizeFromPoints(points), ...updates }; - } + updates = { ...getSizeFromPoints(points), ...updates }; } for (const key in updates) { From 6cbeaa24ac1f9f2af7958f1ce5c6d75238810a22 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 3 Dec 2024 11:57:22 +0100 Subject: [PATCH 135/283] Fix dragged element not updating elbow arrow mid points --- packages/excalidraw/components/App.tsx | 28 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index c408079cbea9..cdcb9a784679 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -8131,7 +8131,7 @@ class App extends React.Component { // when we're editing the name of a frame, we want the user to be // able to select and interact with the text input - !this.state.editingFrame && + if (!this.state.editingFrame) { dragSelectedElements( pointerDownState, selectedElements, @@ -8140,15 +8140,23 @@ class App extends React.Component { snapOffset, event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), ); - selectedElements - .filter(isElbowArrow) - .forEach((element) => - LinearElementEditor.updateEditorMidPointsCache( - element, - elementsMap, - this.state, - ), - ); + selectedElements + .flatMap( + (draggedElement) => + (isLinearElement(draggedElement) + ? [draggedElement] + : draggedElement.boundElements?.map((binding) => + elementsMap.get(binding.id), + ) ?? []) as ExcalidrawLinearElement[], + ) + .forEach((element) => + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + this.state, + ), + ); + } this.setState({ selectedElementsAreBeingDragged: true, From 2dd27101066e3b362127cc1bb649aa9927d3cfd2 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 3 Dec 2024 16:21:50 +0100 Subject: [PATCH 136/283] Testing dynamic bbox change for smooth fixed segment sizing Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 280 +++++++++++++--------- 1 file changed, 169 insertions(+), 111 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index c18a9d21b2f3..eab8aeb0153a 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -22,6 +22,7 @@ import { toBrandedType, tupleToCoors, } from "../utils"; +import { debugDrawBounds, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -94,6 +95,7 @@ const generatePoints = memo( state: ElbowArrowState, points: readonly LocalPoint[], totalSegments: number, + currentSegment: number, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, options?: { isDragging?: boolean; @@ -101,7 +103,23 @@ const generatePoints = memo( ) => routeElbowArrow(state, elementsMap, points, { ...options, - ...(totalSegments > 0 ? { turnOffAvoidance: true } : {}), + ...(totalSegments > 0 + ? { + startMidPointHeading: + currentSegment > 0 + ? vectorToHeading(vectorFromPoint(points[1], points[0])) + : undefined, + endMidPointHeading: + currentSegment < totalSegments + ? vectorToHeading( + vectorFromPoint( + points[points.length - 2], + points[points.length - 1], + ), + ) + : undefined, + } + : {}), }) ?? [], ); @@ -279,11 +297,12 @@ export const updateElbowArrowPoints = ( const simplifiedPoints = getElbowArrowCornerPoints( removeElbowArrowShortSegments( - pointPairs.map(([state, points]) => { + pointPairs.map(([state, points], idx) => { const nextPoints = generatePoints( state, points, pointPairs.length - 1, + idx, elementsMap, options, ); @@ -395,7 +414,8 @@ const getElbowArrowData = ( nextPoints: readonly LocalPoint[], options?: { isDragging?: boolean; - turnOffAvoidance?: boolean; + startMidPointHeading?: Heading; + endMidPointHeading?: Heading; }, ) => { const origStartGlobalPoint: GlobalPoint = pointTranslate< @@ -422,7 +442,6 @@ const getElbowArrowData = ( elementsMap, startElement, hoveredStartElement, - options?.isDragging, ); const endGlobalPoint = getGlobalPoint( @@ -485,7 +504,6 @@ const getElbowArrowData = ( ) : endPointBounds; const boundsOverlap = - options?.turnOffAvoidance || pointInsideBounds( startGlobalPoint, hoveredEndElement @@ -509,47 +527,84 @@ const getElbowArrowData = ( ? [startPointBounds, endPointBounds] : [startElementBounds, endElementBounds], ); - const dynamicAABBs = generateDynamicAABBs( - boundsOverlap ? startPointBounds : startElementBounds, - boundsOverlap ? endPointBounds : endElementBounds, - commonBounds, - boundsOverlap - ? offsetFromHeading( - startHeading, - !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING, - 0, - ) - : offsetFromHeading( + const dynamicAABBs = + options?.startMidPointHeading || options?.endMidPointHeading + ? generateSegmentedDynamicAABBs( + padAABB( + startElementBounds, + pointsEqual(origStartGlobalPoint, startGlobalPoint) + ? [0, 0, 0, 0] + : offsetFromHeading( + startHeading, + BASE_PADDING - + (arrow.startArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2), + BASE_PADDING, + ), + ), + padAABB( + endElementBounds, + options?.startMidPointHeading && options?.endMidPointHeading + ? [0, 0, 0, 0] + : offsetFromHeading( + endHeading, + BASE_PADDING - + (arrow.endArrowhead + ? FIXED_BINDING_DISTANCE * 6 + : FIXED_BINDING_DISTANCE * 2), + BASE_PADDING, + ), + ), + options?.startMidPointHeading, + options?.endMidPointHeading, 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, + startGlobalPoint, + endGlobalPoint, ) - : 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), - ); - + : 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), + ); + //dynamicAABBs.forEach((aabb) => debugDrawBounds(aabb)); + false && console.log("DDDDD"); const startDonglePosition = getDonglePosition( dynamicAABBs[0], startHeading, @@ -593,7 +648,8 @@ const routeElbowArrow = ( nextPoints: readonly LocalPoint[], options?: { isDragging?: boolean; - turnOffAvoidance?: boolean; + startMidPointHeading?: Heading; + endMidPointHeading?: Heading; }, ): GlobalPoint[] | null => { const { @@ -816,73 +872,75 @@ const pathTo = (start: Node, node: Node) => { const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]); -// const padAABB = (bounds: Bounds, offset: [number, number, number, number]) => -// [ -// bounds[0] - offset[3], -// bounds[1] - offset[0], -// bounds[2] + offset[1], -// bounds[3] + offset[2], -// ] as Bounds; - -// const generateSegmentedDynamicAABBs = ( -// a: Bounds, -// b: Bounds, -// startIsMidPoint: boolean | undefined, -// endIsMidPoint: boolean | undefined, -// startHeading: Heading, -// endHeading: Heading, -// startGlobalPoint: GlobalPoint, -// endGlobalPoint: GlobalPoint, -// heading: Heading | undefined, -// ): Bounds[] => { -// let first = a; -// let second = b; -// // const startDongle = startIsMidPoint -// // ? startGlobalPoint -// // : getDonglePosition(a, startHeading, startGlobalPoint); -// // const endDongle = endIsMidPoint -// // ? endGlobalPoint -// // : getDonglePosition(b, endHeading, endGlobalPoint); -// const startDongle = getDonglePosition(a, startHeading, startGlobalPoint); -// const endDongle = getDonglePosition(b, endHeading, endGlobalPoint); - -// if (startIsMidPoint && heading && startDongle && endDongle) { -// if (headingIsHorizontal(heading)) { -// if (startDongle[0] < endDongle[0]) { -// first = [startDongle[0], a[1], endDongle[0], a[3]]; -// } else { -// first = [endDongle[0], a[1], startDongle[0], a[3]]; -// } -// } else if (startDongle[1] < endDongle[1]) { -// first = [a[0], startDongle[1], a[2], endDongle[1]]; -// } else { -// first = [a[0], endDongle[1], a[2], startDongle[1]]; -// } -// } - -// if (endIsMidPoint && heading && startDongle && endDongle) { -// if (headingIsHorizontal(heading)) { -// if (startDongle[0] < endDongle[0]) { -// second = [startDongle[0], b[1], endDongle[0], b[3]]; -// } else { -// second = [endDongle[0], b[1], startDongle[0], b[3]]; -// } -// } else if (startDongle[1] < endDongle[1]) { -// second = [b[0], startDongle[1], b[2], endDongle[1]]; -// } else { -// second = [b[0], endDongle[1], b[2], startDongle[1]]; -// } -// } - -// const boundsOverlap = -// pointInsideBounds(startGlobalPoint, second) || -// pointInsideBounds(endGlobalPoint, first); -// if (boundsOverlap) { -// return [a, b]; -// } - -// return [first, second] as Bounds[]; -// }; +const padAABB = (bounds: Bounds, offset: [number, number, number, number]) => + [ + bounds[0] - offset[3], + bounds[1] - offset[0], + bounds[2] + offset[1], + bounds[3] + offset[2], + ] as Bounds; + +const generateSegmentedDynamicAABBs = ( + a: Bounds, + b: Bounds, + startMidPointHeading: Heading | undefined, + endMidPointHeading: Heading | undefined, + startHeading: Heading, + endHeading: Heading, + startGlobalPoint: GlobalPoint, + endGlobalPoint: GlobalPoint, +): Bounds[] => { + const startDongle = getDonglePosition(a, startHeading, startGlobalPoint); + const endDongle = getDonglePosition(b, endHeading, endGlobalPoint); + + let first = a; + let second = b; + false && + console.log( + "generateSegmentedDynamicAABBs", + !!startMidPointHeading, + !!endMidPointHeading, + ); + if (startMidPointHeading && startDongle && endDongle) { + if (headingIsHorizontal(startMidPointHeading)) { + if (startDongle[0] < endDongle[0]) { + first = [startDongle[0], a[1], endDongle[0], a[3]]; + } else { + first = [endDongle[0], a[1], startDongle[0], a[3]]; + } + } else if (startDongle[1] < endDongle[1]) { + first = [a[0], startDongle[1], a[2], endDongle[1]]; + } else { + first = [a[0], endDongle[1], a[2], startDongle[1]]; + } + } + + if (endMidPointHeading && startDongle && endDongle) { + if (headingIsHorizontal(endMidPointHeading)) { + if (startDongle[0] < endDongle[0]) { + second = [startDongle[0], b[1], endDongle[0], b[3]]; + } else { + second = [endDongle[0], b[1], startDongle[0], b[3]]; + } + } else if (startDongle[1] < endDongle[1]) { + second = [b[0], startDongle[1], b[2], endDongle[1]]; + } else { + second = [b[0], endDongle[1], b[2], startDongle[1]]; + } + } + endMidPointHeading && debugDrawBounds(second, { color: "red" }); + endMidPointHeading && debugDrawBounds(first, { color: "green" }); + endMidPointHeading && debugDrawPoint(startGlobalPoint, { color: "green" }); + endMidPointHeading && debugDrawPoint(endGlobalPoint, { color: "red" }); + const boundsOverlap = + pointInsideBounds(startGlobalPoint, second) || + pointInsideBounds(endGlobalPoint, first); + if (boundsOverlap) { + return [a, b]; + } + + return [first, second] as Bounds[]; +}; /** * Create dynamically resizing, always touching From 8073c42429aa7ddfdb59234b7b98ab3992cb472f Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 4 Dec 2024 14:11:05 +0100 Subject: [PATCH 137/283] More expansive bounding box while segment is fixed Signed-off-by: Mark Tolmacs --- packages/excalidraw/element/elbowarrow.ts | 84 +++++++++++++++-------- 1 file changed, 54 insertions(+), 30 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index eab8aeb0153a..d5badb6e0d24 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -890,8 +890,8 @@ const generateSegmentedDynamicAABBs = ( startGlobalPoint: GlobalPoint, endGlobalPoint: GlobalPoint, ): Bounds[] => { - const startDongle = getDonglePosition(a, startHeading, startGlobalPoint); - const endDongle = getDonglePosition(b, endHeading, endGlobalPoint); + // const startDongle = getDonglePosition(a, startHeading, startGlobalPoint); + // const endDongle = getDonglePosition(b, endHeading, endGlobalPoint); let first = a; let second = b; @@ -901,37 +901,61 @@ const generateSegmentedDynamicAABBs = ( !!startMidPointHeading, !!endMidPointHeading, ); - if (startMidPointHeading && startDongle && endDongle) { - if (headingIsHorizontal(startMidPointHeading)) { - if (startDongle[0] < endDongle[0]) { - first = [startDongle[0], a[1], endDongle[0], a[3]]; - } else { - first = [endDongle[0], a[1], startDongle[0], a[3]]; - } - } else if (startDongle[1] < endDongle[1]) { - first = [a[0], startDongle[1], a[2], endDongle[1]]; - } else { - first = [a[0], endDongle[1], a[2], startDongle[1]]; - } + if (startMidPointHeading) { + first = [ + startGlobalPoint[0] - 0.0001, + startGlobalPoint[1] - 0.0001, + startGlobalPoint[0] + 0.0001, + startGlobalPoint[1] + 0.0001, + ]; + second = [ + Math.min(b[0], startGlobalPoint[0] + 0.0001), + Math.min(b[1], startGlobalPoint[1] + 0.0001), + Math.max(b[2], startGlobalPoint[0] - 0.0001), + Math.max(b[3], startGlobalPoint[1] - 0.0001), + ]; + // if (headingIsHorizontal(startMidPointHeading)) { + // if (startDongle[0] < endDongle[0]) { + // first = [startDongle[0], a[1], endDongle[0], a[3]]; + // } else { + // first = [endDongle[0], a[1], startDongle[0], a[3]]; + // } + // } else if (startDongle[1] < endDongle[1]) { + // first = [a[0], startDongle[1], a[2], endDongle[1]]; + // } else { + // first = [a[0], endDongle[1], a[2], startDongle[1]]; + // } } - if (endMidPointHeading && startDongle && endDongle) { - if (headingIsHorizontal(endMidPointHeading)) { - if (startDongle[0] < endDongle[0]) { - second = [startDongle[0], b[1], endDongle[0], b[3]]; - } else { - second = [endDongle[0], b[1], startDongle[0], b[3]]; - } - } else if (startDongle[1] < endDongle[1]) { - second = [b[0], startDongle[1], b[2], endDongle[1]]; - } else { - second = [b[0], endDongle[1], b[2], startDongle[1]]; - } + if (endMidPointHeading) { + second = [ + endGlobalPoint[0] - 0.0001, + endGlobalPoint[1] - 0.0001, + endGlobalPoint[0] + 0.0001, + endGlobalPoint[1] + 0.0001, + ]; + first = [ + Math.min(a[0], endGlobalPoint[0] + 0.0001), + Math.min(a[1], endGlobalPoint[1] + 0.0001), + Math.max(a[2], endGlobalPoint[0] - 0.0001), + Math.max(a[3], endGlobalPoint[1] - 0.0001), + ]; + // if (headingIsHorizontal(endMidPointHeading)) { + // if (startDongle[0] < endDongle[0]) { + // second = [startDongle[0], b[1], endDongle[0], b[3]]; + // } else { + // second = [endDongle[0], b[1], startDongle[0], b[3]]; + // } + // } else if (startDongle[1] < endDongle[1]) { + // second = [b[0], startDongle[1], b[2], endDongle[1]]; + // } else { + // second = [b[0], endDongle[1], b[2], startDongle[1]]; + // } } - endMidPointHeading && debugDrawBounds(second, { color: "red" }); - endMidPointHeading && debugDrawBounds(first, { color: "green" }); - endMidPointHeading && debugDrawPoint(startGlobalPoint, { color: "green" }); - endMidPointHeading && debugDrawPoint(endGlobalPoint, { color: "red" }); + // endMidPointHeading && debugDrawBounds(second, { color: "red" }); + // endMidPointHeading && debugDrawBounds(first, { color: "green" }); + // endMidPointHeading && debugDrawPoint(startGlobalPoint, { color: "green" }); + // endMidPointHeading && debugDrawPoint(endGlobalPoint, { color: "red" }); const boundsOverlap = pointInsideBounds(startGlobalPoint, second) || pointInsideBounds(endGlobalPoint, first); From 72346033685efbe948c42a709d21a56258dcf09d Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 4 Dec 2024 17:38:05 +0100 Subject: [PATCH 138/283] Fixed segment now shows marker midpoint --- packages/excalidraw/element/elbowarrow.ts | 83 +++++++++++++---------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index d5badb6e0d24..d15af8916254 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -4,6 +4,7 @@ import { pointScaleFromOrigin, pointsEqual, pointTranslate, + PRECISION, vector, vectorCross, vectorFromPoint, @@ -312,6 +313,7 @@ export const updateElbowArrowPoints = ( ), ).flat(); + let segmentArrayPointer = 1; nextFixedSegments.forEach((_, idx) => { nextFixedSegments[idx].start = pointFrom( arrow.x + nextFixedSegments[idx].start[0] - simplifiedPoints[0][0], @@ -321,6 +323,7 @@ export const updateElbowArrowPoints = ( arrow.x + nextFixedSegments[idx].end[0] - simplifiedPoints[0][0], arrow.y + nextFixedSegments[idx].end[1] - simplifiedPoints[0][1], ); + const isHorizontal = headingIsHorizontal( vectorToHeading( vectorFromPoint( @@ -329,50 +332,58 @@ export const updateElbowArrowPoints = ( ), ), ); + const x = simplifiedPoints[0][0]; + const y = simplifiedPoints[0][1]; - const similarIndices = simplifiedPoints - .map((p, i) => { - if (i > 0) { - const q = simplifiedPoints[i - 1]; + const similarIdx = + 1 + + (simplifiedPoints + .slice(segmentArrayPointer) + .map((p, i) => { + const q = simplifiedPoints[i + 1]; if ( - (isHorizontal && - p[1] === simplifiedPoints[0][1] + nextFixedSegments[idx].end[1] && - q[1] === - simplifiedPoints[0][1] + nextFixedSegments[idx].start[1]) || - (!isHorizontal && - p[0] === simplifiedPoints[0][0] + nextFixedSegments[idx].end[0] && - q[0] === simplifiedPoints[0][0] + nextFixedSegments[idx].start[0]) + isHorizontal + ? Math.abs(p[1] - y - nextFixedSegments[idx].start[1]) < 0.1 && + Math.abs(q[1] - y - nextFixedSegments[idx].end[1]) < 0.1 + : Math.abs(p[0] - x - nextFixedSegments[idx].start[0]) < 0.1 && + Math.abs(q[0] - x - nextFixedSegments[idx].end[0]) < 0.1 ) { + segmentArrayPointer = i + 1; return i; } - } - return null; - }) - .filter((i) => i != null) as number[]; - - if (similarIndices.length > 0) { - const i = similarIndices[0]; - nextFixedSegments[idx] = { - start: pointFrom( - isHorizontal - ? simplifiedPoints[i - 1][0] - simplifiedPoints[0][0] - : nextFixedSegments[idx].start[0], - !isHorizontal - ? simplifiedPoints[i - 1][1] - simplifiedPoints[0][1] - : nextFixedSegments[idx].start[1], - ), - end: pointFrom( - isHorizontal - ? simplifiedPoints[i][0] - simplifiedPoints[0][0] - : nextFixedSegments[idx].end[0], - !isHorizontal - ? simplifiedPoints[i][1] - simplifiedPoints[0][1] - : nextFixedSegments[idx].end[1], - ), - }; + return null; + }) + .filter((i) => i != null)[0] ?? -1); + + if (similarIdx != null) { + nextFixedSegments[idx].start = pointFrom( + simplifiedPoints[similarIdx][0] - x, + simplifiedPoints[similarIdx][1] - y, + ); + nextFixedSegments[idx].end = pointFrom( + simplifiedPoints[similarIdx + 1][0] - x, + simplifiedPoints[similarIdx + 1][1] - y, + ); + } else { + console.warn("Could not find similar point which shouldn't happen"); } + + debugDrawPoint( + pointFrom( + x + nextFixedSegments[idx].start[0], + y + nextFixedSegments[idx].start[1], + ), + { color: "green", permanent: false }, + ); + debugDrawPoint( + pointFrom( + x + nextFixedSegments[idx].end[0], + y + nextFixedSegments[idx].end[1], + ), + { color: "red", permanent: false }, + ); }); return normalizeArrowElementUpdate(simplifiedPoints, nextFixedSegments); From 2ee045ba2a0950e78c53dad93107fdbc00842969 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 4 Dec 2024 17:39:45 +0100 Subject: [PATCH 139/283] Remove debug --- packages/excalidraw/element/elbowarrow.ts | 61 ++--------------------- 1 file changed, 3 insertions(+), 58 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index d15af8916254..aec95324df7c 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -4,7 +4,6 @@ import { pointScaleFromOrigin, pointsEqual, pointTranslate, - PRECISION, vector, vectorCross, vectorFromPoint, @@ -23,7 +22,6 @@ import { toBrandedType, tupleToCoors, } from "../utils"; -import { debugDrawBounds, debugDrawPoint } from "../visualdebug"; import { bindPointToSnapToElementOutline, distanceToBindableElement, @@ -369,21 +367,6 @@ export const updateElbowArrowPoints = ( } else { console.warn("Could not find similar point which shouldn't happen"); } - - debugDrawPoint( - pointFrom( - x + nextFixedSegments[idx].start[0], - y + nextFixedSegments[idx].start[1], - ), - { color: "green", permanent: false }, - ); - debugDrawPoint( - pointFrom( - x + nextFixedSegments[idx].end[0], - y + nextFixedSegments[idx].end[1], - ), - { color: "red", permanent: false }, - ); }); return normalizeArrowElementUpdate(simplifiedPoints, nextFixedSegments); @@ -569,8 +552,6 @@ const getElbowArrowData = ( ), options?.startMidPointHeading, options?.endMidPointHeading, - startHeading, - endHeading, startGlobalPoint, endGlobalPoint, ) @@ -614,8 +595,7 @@ const getElbowArrowData = ( hoveredStartElement && aabbForElement(hoveredStartElement), hoveredEndElement && aabbForElement(hoveredEndElement), ); - //dynamicAABBs.forEach((aabb) => debugDrawBounds(aabb)); - false && console.log("DDDDD"); + const startDonglePosition = getDonglePosition( dynamicAABBs[0], startHeading, @@ -896,22 +876,12 @@ const generateSegmentedDynamicAABBs = ( b: Bounds, startMidPointHeading: Heading | undefined, endMidPointHeading: Heading | undefined, - startHeading: Heading, - endHeading: Heading, startGlobalPoint: GlobalPoint, endGlobalPoint: GlobalPoint, ): Bounds[] => { - // const startDongle = getDonglePosition(a, startHeading, startGlobalPoint); - // const endDongle = getDonglePosition(b, endHeading, endGlobalPoint); - let first = a; let second = b; - false && - console.log( - "generateSegmentedDynamicAABBs", - !!startMidPointHeading, - !!endMidPointHeading, - ); + if (startMidPointHeading) { first = [ startGlobalPoint[0] - 0.0001, @@ -925,17 +895,6 @@ const generateSegmentedDynamicAABBs = ( Math.max(b[2], startGlobalPoint[0] - 0.0001), Math.max(b[3], startGlobalPoint[1] - 0.0001), ]; - // if (headingIsHorizontal(startMidPointHeading)) { - // if (startDongle[0] < endDongle[0]) { - // first = [startDongle[0], a[1], endDongle[0], a[3]]; - // } else { - // first = [endDongle[0], a[1], startDongle[0], a[3]]; - // } - // } else if (startDongle[1] < endDongle[1]) { - // first = [a[0], startDongle[1], a[2], endDongle[1]]; - // } else { - // first = [a[0], endDongle[1], a[2], startDongle[1]]; - // } } if (endMidPointHeading) { @@ -951,22 +910,8 @@ const generateSegmentedDynamicAABBs = ( Math.max(a[2], endGlobalPoint[0] - 0.0001), Math.max(a[3], endGlobalPoint[1] - 0.0001), ]; - // if (headingIsHorizontal(endMidPointHeading)) { - // if (startDongle[0] < endDongle[0]) { - // second = [startDongle[0], b[1], endDongle[0], b[3]]; - // } else { - // second = [endDongle[0], b[1], startDongle[0], b[3]]; - // } - // } else if (startDongle[1] < endDongle[1]) { - // second = [b[0], startDongle[1], b[2], endDongle[1]]; - // } else { - // second = [b[0], endDongle[1], b[2], startDongle[1]]; - // } } - // endMidPointHeading && debugDrawBounds(second, { color: "red" }); - // endMidPointHeading && debugDrawBounds(first, { color: "green" }); - // endMidPointHeading && debugDrawPoint(startGlobalPoint, { color: "green" }); - // endMidPointHeading && debugDrawPoint(endGlobalPoint, { color: "red" }); + const boundsOverlap = pointInsideBounds(startGlobalPoint, second) || pointInsideBounds(endGlobalPoint, first); From ec4ed69d8b277bea1f3d1c4da197c094d761d814 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 4 Dec 2024 20:59:43 +0100 Subject: [PATCH 140/283] Small reformats Signed-off-by: Mark Tolmacs --- packages/excalidraw/components/App.tsx | 6 +----- packages/excalidraw/element/binding.ts | 1 + packages/excalidraw/element/linearElementEditor.ts | 3 +-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index cdcb9a784679..0b1b395f6167 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -3221,12 +3221,10 @@ class App extends React.Component { const newElements = duplicateElements( elements.map((element) => { - const newElement = newElementWith(element, { + return newElementWith(element, { x: element.x + gridX - minX, y: element.y + gridY - minY, }); - - return newElement; }), { randomizeSeed: !opts.retainSeed, @@ -7919,7 +7917,6 @@ class App extends React.Component { linearElementEditor, this.scene, ); - if (didDrag) { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; @@ -8749,7 +8746,6 @@ class App extends React.Component { this.scene.getNonDeletedElements(), ); } - this.setState({ suggestedBindings: [], startBoundElement: null }); if (!activeTool.locked) { resetCursor(this.interactiveCanvas); diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 4f3065c6d0ad..43af29ab2748 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -2203,6 +2203,7 @@ export const getGlobalFixedPointForBindableElement = ( element: ExcalidrawBindableElement, ): GlobalPoint => { const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); + return pointRotateRads( pointFrom( element.x + element.width * fixedX, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 7b42ec9f9c2a..d4f212768cbe 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1291,7 +1291,7 @@ export class LinearElementEditor { if (selectedOriginPoint) { offsetX = - selectedOriginPoint.point[0] + points[selectedOriginPoint.index][0]; // points[selectedOriginPoint.index] = points[0] + selectedOriginPoint.point[0] + points[selectedOriginPoint.index][0]; offsetY = selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1]; } @@ -1310,7 +1310,6 @@ export class LinearElementEditor { return pointFrom(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY); } - return offsetX || offsetY ? pointFrom(p[0] - offsetX, p[1] - offsetY) : p; }); From 72b34347636f5ea7c161bbcc267e6821e51e2ff0 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 5 Dec 2024 14:51:17 +0100 Subject: [PATCH 141/283] Small cleanups and fixes Signed-off-by: Mark Tolmacs --- packages/excalidraw/components/App.tsx | 4 ---- packages/excalidraw/element/binding.ts | 4 +--- packages/excalidraw/element/elbowarrow.ts | 22 ++++++++++++++++---- packages/excalidraw/element/mutateElement.ts | 4 ++-- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 0b1b395f6167..7184c246b8ab 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -9230,10 +9230,6 @@ class App extends React.Component { } } - // const midPointSelected = - // (this.state.selectedLinearElement?.pointerDownState.segmentMidpoint - // .index || -1) < 0; - if ( // not elbow midpoint dragged !(hitElement && isElbowArrow(hitElement)) && diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 43af29ab2748..c8b0449a48ba 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -727,9 +727,7 @@ export const getHeadingForElbowArrowSnap = ( ); } - const pointHeading = headingForPointFromElement(bindableElement, aabb, p); - - return pointHeading; + return headingForPointFromElement(bindableElement, aabb, p); }; const getDistanceForBinding = ( diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index aec95324df7c..7321c61dacbf 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -126,7 +126,7 @@ const generatePoints = memo( * */ export const updateElbowArrowPoints = ( - arrow: ExcalidrawElbowArrowElement, + arrow: Readonly, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, updates: { points: readonly LocalPoint[]; @@ -141,11 +141,25 @@ export const updateElbowArrowPoints = ( } invariant( - arrow.points.length === updates.points.length, - "Updated point array length must match the arrow point length (i.e. you can't add new points manually to elbow arrows)", + !updates.points || + arrow.points.length === updates.points.length || + 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)", ); - const updatedPoints = Array.from(updates.points); // TODO: Do we need the cloning here? + const updatedPoints = 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, + ) + : Array.from(updates.points) + : Array.from(arrow.points); const renormalizedUpdatedPoints = updatedPoints.map((point, idx) => { if (idx === 0) { return point; diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index 7fcdbb81fc45..5f5dfa1748b9 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -36,6 +36,7 @@ export const mutateElement = >( const { points, fileId } = updates as any; if (isElbowArrow(element)) { + const { fixedSegments } = element; const mergedElementsMap = toBrandedType( new Map([ ...(Scene.getScene(element)?.getNonDeletedElementsMap() ?? []), @@ -53,9 +54,8 @@ export const mutateElement = >( y: updates.y || element.y, }, mergedElementsMap, - // @ts-ignore { - ...updates, + fixedSegments: fixedSegments || element.fixedSegments, points: points || element.points, }, { From 3327f93d6c5db3c61091b83f8bd3be89c9d6458a Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 5 Dec 2024 19:27:28 +0100 Subject: [PATCH 142/283] Fix jumping arrow on binding --- packages/excalidraw/element/elbowarrow.ts | 2 +- packages/excalidraw/element/mutateElement.ts | 31 ++++++++++---------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 7321c61dacbf..16ef4feb9ab5 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -130,7 +130,7 @@ export const updateElbowArrowPoints = ( elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, updates: { points: readonly LocalPoint[]; - fixedSegments?: FixedSegment[]; + fixedSegments?: FixedSegment[] | null; }, options?: { isDragging?: boolean; diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index 5f5dfa1748b9..9b87c51af8e9 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -47,21 +47,22 @@ export const mutateElement = >( updates = { ...updates, angle: 0 as Radians, - ...updateElbowArrowPoints( - { - ...element, - x: updates.x || element.x, - y: updates.y || element.y, - }, - mergedElementsMap, - { - fixedSegments: fixedSegments || element.fixedSegments, - points: points || element.points, - }, - { - isDragging, - }, - ), + ...(points && + updateElbowArrowPoints( + { + ...element, + x: updates.x || element.x, + y: updates.y || element.y, + }, + mergedElementsMap, + { + fixedSegments, + points, + }, + { + isDragging, + }, + )), }; } From c3e7ede69d5db8ae8307d378e3a5ae4e88d514e6 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 5 Dec 2024 19:27:39 +0100 Subject: [PATCH 143/283] Add new arrow point debug draw fn --- packages/excalidraw/visualdebug.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/excalidraw/visualdebug.ts b/packages/excalidraw/visualdebug.ts index baddeeadcf5d..b385321b12f5 100644 --- a/packages/excalidraw/visualdebug.ts +++ b/packages/excalidraw/visualdebug.ts @@ -3,6 +3,7 @@ import { lineSegment, pointFrom, type GlobalPoint, + type LocalPoint, } from "../math"; import type { LineSegment } from "../utils"; import type { BoundingBox, Bounds } from "./element/bounds"; @@ -147,6 +148,23 @@ export const debugDrawBounds = ( ); }; +export const debugDrawPoints = ( + { + x, + y, + points, + }: { + x: number; + y: number; + points: LocalPoint[]; + }, + options: any, +) => { + points.forEach((p) => + debugDrawPoint(pointFrom(x + p[0], y + p[1]), options), + ); +}; + export const debugCloseFrame = () => { window.visualDebug?.data.push([]); }; From af229f049dfb95aa8a594ceb4c347e536b296a18 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 5 Dec 2024 20:16:50 +0100 Subject: [PATCH 144/283] Fix very close to start or end fixed segment positioning --- packages/excalidraw/element/elbowarrow.ts | 53 +++++++------------ .../excalidraw/element/linearElementEditor.ts | 5 +- 2 files changed, 20 insertions(+), 38 deletions(-) diff --git a/packages/excalidraw/element/elbowarrow.ts b/packages/excalidraw/element/elbowarrow.ts index 16ef4feb9ab5..a613ced22b77 100644 --- a/packages/excalidraw/element/elbowarrow.ts +++ b/packages/excalidraw/element/elbowarrow.ts @@ -325,6 +325,15 @@ export const updateElbowArrowPoints = ( ), ).flat(); + // The goal is to update next fixed segments to match the new arrow points. + // The solution here is to search for the exact x or y coordinates within the + // new points resspective to horizontal/vertical fixed segment, then mark the + // last position in the new points array where we left off. This is useful + // for optimization, but more importantly if two segments happen to line up + // then the second segment will be corrupted otherise, getting the same start + // and endpoints as the first segment. Ex.: In a 1. horizontal, 2. vertical, + // 3. horizontal, 4. vertical, 5. horizontal setup 1. and 5. can potentially + // line up perfectly. let segmentArrayPointer = 1; nextFixedSegments.forEach((_, idx) => { nextFixedSegments[idx].start = pointFrom( @@ -538,32 +547,8 @@ const getElbowArrowData = ( const dynamicAABBs = options?.startMidPointHeading || options?.endMidPointHeading ? generateSegmentedDynamicAABBs( - padAABB( - startElementBounds, - pointsEqual(origStartGlobalPoint, startGlobalPoint) - ? [0, 0, 0, 0] - : offsetFromHeading( - startHeading, - BASE_PADDING - - (arrow.startArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2), - BASE_PADDING, - ), - ), - padAABB( - endElementBounds, - options?.startMidPointHeading && options?.endMidPointHeading - ? [0, 0, 0, 0] - : offsetFromHeading( - endHeading, - BASE_PADDING - - (arrow.endArrowhead - ? FIXED_BINDING_DISTANCE * 6 - : FIXED_BINDING_DISTANCE * 2), - BASE_PADDING, - ), - ), + boundsOverlap ? startPointBounds : startElementBounds, + boundsOverlap ? endPointBounds : endElementBounds, options?.startMidPointHeading, options?.endMidPointHeading, startGlobalPoint, @@ -609,7 +594,13 @@ const getElbowArrowData = ( hoveredStartElement && aabbForElement(hoveredStartElement), hoveredEndElement && aabbForElement(hoveredEndElement), ); - + // options?.startMidPointHeading && + // dynamicAABBs.forEach((aabb, idx) => { + // if (idx === 0) { + // debugDrawPoint(pointFrom(aabb[2], aabb[3])); + // } + // debugDrawBounds(aabb, { color: idx > 0 ? "red" : "green" }); + // }); const startDonglePosition = getDonglePosition( dynamicAABBs[0], startHeading, @@ -877,14 +868,6 @@ const pathTo = (start: Node, node: Node) => { const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]); -const padAABB = (bounds: Bounds, offset: [number, number, number, number]) => - [ - bounds[0] - offset[3], - bounds[1] - offset[0], - bounds[2] + offset[1], - bounds[3] + offset[2], - ] as Bounds; - const generateSegmentedDynamicAABBs = ( a: Bounds, b: Bounds, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index d4f212768cbe..3791cef63614 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -1274,7 +1274,6 @@ export class LinearElementEditor { }, options?: { changedElements?: Map; - elbowArrowSegmentOverride?: boolean; }, ) { const { points } = element; @@ -1845,7 +1844,7 @@ export class LinearElementEditor { !isHorizontal ? x - element.x : element.points[index - 1][0], isHorizontal ? y - element.y : element.points[index - 1][1], ), - isDragging: true, + isDragging: false, }, { index, @@ -1853,7 +1852,7 @@ export class LinearElementEditor { !isHorizontal ? x - element.x : element.points[index][0], isHorizontal ? y - element.y : element.points[index][1], ), - isDragging: true, + isDragging: false, }, ]); LinearElementEditor.updateEditorMidPointsCache( From 6410f9e8782b7a77fb1ddf4308f861cdd14c2235 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 26 Nov 2024 17:42:25 +0100 Subject: [PATCH 145/283] fix: update old blog links & add canonical url (#8846) --- README.md | 2 +- dev-docs/docusaurus.config.js | 4 ++-- excalidraw-app/components/EncryptedIcon.tsx | 2 +- excalidraw-app/index.html | 2 ++ .../tests/packages/__snapshots__/excalidraw.test.tsx.snap | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e8cd3b06fa86..3c7265a80c90 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Excalidraw Editor | - Blog | + Blog | Documentation | Excalidraw+

diff --git a/dev-docs/docusaurus.config.js b/dev-docs/docusaurus.config.js index a246522c1dd7..7899df164add 100644 --- a/dev-docs/docusaurus.config.js +++ b/dev-docs/docusaurus.config.js @@ -66,7 +66,7 @@ const config = { label: "Docs", }, { - to: "https://blog.excalidraw.com", + to: "https://plus.excalidraw.com/blog", label: "Blog", position: "left", }, @@ -111,7 +111,7 @@ const config = { items: [ { label: "Blog", - to: "https://blog.excalidraw.com", + to: "https://plus.excalidraw.com/blog", }, { label: "GitHub", diff --git a/excalidraw-app/components/EncryptedIcon.tsx b/excalidraw-app/components/EncryptedIcon.tsx index 3b8655eff815..8d2dd88f41cd 100644 --- a/excalidraw-app/components/EncryptedIcon.tsx +++ b/excalidraw-app/components/EncryptedIcon.tsx @@ -8,7 +8,7 @@ export const EncryptedIcon = () => { return ( + +