From e19fd1332a7c81179adbbd59649d81663dd1f43d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Mon, 5 May 2025 09:51:20 +0200 Subject: [PATCH 1/8] feat: Precise highlights for bindings (#9472) --- packages/element/src/bounds.ts | 30 +- packages/excalidraw/renderer/helpers.ts | 423 +++++++++++++++++- .../excalidraw/renderer/interactiveScene.ts | 128 +----- packages/math/src/curve.ts | 23 +- packages/math/src/vector.ts | 5 + 5 files changed, 484 insertions(+), 125 deletions(-) diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index d0c071f2c..a5b91922b 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -1,17 +1,17 @@ import rough from "roughjs/bin/rough"; import { - rescalePoints, arrayToMap, invariant, + rescalePoints, sizeOf, } from "@excalidraw/common"; import { degreesToRadians, lineSegment, - pointFrom, pointDistance, + pointFrom, pointFromArray, pointRotateRads, } from "@excalidraw/math"; @@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types"; import type { Mutable } from "@excalidraw/common/utility-types"; -import { ShapeCache } from "./ShapeCache"; import { generateRoughOptions } from "./Shape"; +import { ShapeCache } from "./ShapeCache"; import { LinearElementEditor } from "./linearElementEditor"; import { getBoundTextElement, getContainerElement } from "./textElement"; import { @@ -52,20 +52,20 @@ import { deconstructRectanguloidElement, } from "./utils"; -import type { - ExcalidrawElement, - ExcalidrawLinearElement, - Arrowhead, - ExcalidrawFreeDrawElement, - NonDeleted, - ExcalidrawTextElementWithContainer, - ElementsMap, - ExcalidrawRectanguloidElement, - ExcalidrawEllipseElement, - ElementsMapOrArray, -} from "./types"; import type { Drawable, Op } from "roughjs/bin/core"; import type { Point as RoughPoint } from "roughjs/bin/geometry"; +import type { + Arrowhead, + ElementsMap, + ElementsMapOrArray, + ExcalidrawElement, + ExcalidrawEllipseElement, + ExcalidrawFreeDrawElement, + ExcalidrawLinearElement, + ExcalidrawRectanguloidElement, + ExcalidrawTextElementWithContainer, + NonDeleted, +} from "./types"; export type RectangleBox = { x: number; diff --git a/packages/excalidraw/renderer/helpers.ts b/packages/excalidraw/renderer/helpers.ts index 765ef4294..726044989 100644 --- a/packages/excalidraw/renderer/helpers.ts +++ b/packages/excalidraw/renderer/helpers.ts @@ -1,7 +1,30 @@ -import { THEME, THEME_FILTER } from "@excalidraw/common"; +import { elementCenterPoint, THEME, THEME_FILTER } from "@excalidraw/common"; + +import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding"; +import { getDiamondPoints } from "@excalidraw/element/bounds"; +import { getCornerRadius } from "@excalidraw/element/shapes"; + +import { + bezierEquation, + curve, + curveTangent, + type GlobalPoint, + pointFrom, + pointFromVector, + pointRotateRads, + vector, + vectorNormal, + vectorNormalize, + vectorScale, +} from "@excalidraw/math"; + +import type { + ExcalidrawDiamondElement, + ExcalidrawRectanguloidElement, +} from "@excalidraw/element/types"; import type { StaticCanvasRenderConfig } from "../scene/types"; -import type { StaticCanvasAppState, AppState } from "../types"; +import type { AppState, StaticCanvasAppState } from "../types"; export const fillCircle = ( context: CanvasRenderingContext2D, @@ -72,3 +95,399 @@ export const bootstrapCanvas = ({ return context; }; + +function drawCatmullRomQuadraticApprox( + ctx: CanvasRenderingContext2D, + points: GlobalPoint[], + segments = 20, +) { + ctx.lineTo(points[0][0], points[0][1]); + + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i - 1 < 0 ? 0 : i - 1]; + const p1 = points[i]; + const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1]; + + for (let t = 0; t <= 1; t += 1 / segments) { + const t2 = t * t; + + const x = + (1 - t) * (1 - t) * p0[0] + 2 * (1 - t) * t * p1[0] + t2 * p2[0]; + + const y = + (1 - t) * (1 - t) * p0[1] + 2 * (1 - t) * t * p1[1] + t2 * p2[1]; + + ctx.lineTo(x, y); + } + } +} + +function drawCatmullRomCubicApprox( + ctx: CanvasRenderingContext2D, + points: GlobalPoint[], + segments = 20, +) { + ctx.lineTo(points[0][0], points[0][1]); + + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i - 1 < 0 ? 0 : i - 1]; + const p1 = points[i]; + const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1]; + const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2]; + + for (let t = 0; t <= 1; t += 1 / segments) { + const t2 = t * t; + const t3 = t2 * t; + + const x = + 0.5 * + (2 * p1[0] + + (-p0[0] + p2[0]) * t + + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3); + + const y = + 0.5 * + (2 * p1[1] + + (-p0[1] + p2[1]) * t + + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3); + + ctx.lineTo(x, y); + } + } +} + +export const drawHighlightForRectWithRotation = ( + context: CanvasRenderingContext2D, + element: ExcalidrawRectanguloidElement, + padding: number, +) => { + const [x, y] = pointRotateRads( + pointFrom(element.x, element.y), + elementCenterPoint(element), + element.angle, + ); + + context.save(); + context.translate(x, y); + context.rotate(element.angle); + + let radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + if (radius === 0) { + radius = 0.01; + } + + context.beginPath(); + + { + const topLeftApprox = offsetQuadraticBezier( + pointFrom(0, 0 + radius), + pointFrom(0, 0), + pointFrom(0 + radius, 0), + padding, + ); + const topRightApprox = offsetQuadraticBezier( + pointFrom(element.width - radius, 0), + pointFrom(element.width, 0), + pointFrom(element.width, radius), + padding, + ); + const bottomRightApprox = offsetQuadraticBezier( + pointFrom(element.width, element.height - radius), + pointFrom(element.width, element.height), + pointFrom(element.width - radius, element.height), + padding, + ); + const bottomLeftApprox = offsetQuadraticBezier( + pointFrom(radius, element.height), + pointFrom(0, element.height), + pointFrom(0, element.height - radius), + padding, + ); + + context.moveTo( + topLeftApprox[topLeftApprox.length - 1][0], + topLeftApprox[topLeftApprox.length - 1][1], + ); + context.lineTo(topRightApprox[0][0], topRightApprox[0][1]); + drawCatmullRomQuadraticApprox(context, topRightApprox); + context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]); + drawCatmullRomQuadraticApprox(context, bottomRightApprox); + context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]); + drawCatmullRomQuadraticApprox(context, bottomLeftApprox); + context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]); + drawCatmullRomQuadraticApprox(context, topLeftApprox); + } + + // Counter-clockwise for the cutout in the middle. We need to have an "inverse + // mask" on a filled shape for the diamond highlight, because stroking creates + // sharp inset edges on line joins < 90 degrees. + { + const topLeftApprox = offsetQuadraticBezier( + pointFrom(0 + radius, 0), + pointFrom(0, 0), + pointFrom(0, 0 + radius), + -FIXED_BINDING_DISTANCE, + ); + const topRightApprox = offsetQuadraticBezier( + pointFrom(element.width, radius), + pointFrom(element.width, 0), + pointFrom(element.width - radius, 0), + -FIXED_BINDING_DISTANCE, + ); + const bottomRightApprox = offsetQuadraticBezier( + pointFrom(element.width - radius, element.height), + pointFrom(element.width, element.height), + pointFrom(element.width, element.height - radius), + -FIXED_BINDING_DISTANCE, + ); + const bottomLeftApprox = offsetQuadraticBezier( + pointFrom(0, element.height - radius), + pointFrom(0, element.height), + pointFrom(radius, element.height), + -FIXED_BINDING_DISTANCE, + ); + + context.moveTo( + topLeftApprox[topLeftApprox.length - 1][0], + topLeftApprox[topLeftApprox.length - 1][1], + ); + context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]); + drawCatmullRomQuadraticApprox(context, bottomLeftApprox); + context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]); + drawCatmullRomQuadraticApprox(context, bottomRightApprox); + context.lineTo(topRightApprox[0][0], topRightApprox[0][1]); + drawCatmullRomQuadraticApprox(context, topRightApprox); + context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]); + drawCatmullRomQuadraticApprox(context, topLeftApprox); + } + + context.closePath(); + context.fill(); + + context.restore(); +}; + +export const strokeEllipseWithRotation = ( + context: CanvasRenderingContext2D, + width: number, + height: number, + cx: number, + cy: number, + angle: number, +) => { + context.beginPath(); + context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2); + context.stroke(); +}; + +export const strokeRectWithRotation = ( + context: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + cx: number, + cy: number, + angle: number, + fill: boolean = false, + /** should account for zoom */ + radius: number = 0, +) => { + context.save(); + context.translate(cx, cy); + context.rotate(angle); + if (fill) { + context.fillRect(x - cx, y - cy, width, height); + } + if (radius && context.roundRect) { + context.beginPath(); + context.roundRect(x - cx, y - cy, width, height, radius); + context.stroke(); + context.closePath(); + } else { + context.strokeRect(x - cx, y - cy, width, height); + } + context.restore(); +}; + +export const drawHighlightForDiamondWithRotation = ( + context: CanvasRenderingContext2D, + padding: number, + element: ExcalidrawDiamondElement, +) => { + const [x, y] = pointRotateRads( + pointFrom(element.x, element.y), + elementCenterPoint(element), + element.angle, + ); + context.save(); + context.translate(x, y); + context.rotate(element.angle); + + { + context.beginPath(); + + const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = + getDiamondPoints(element); + const verticalRadius = element.roundness + ? getCornerRadius(Math.abs(topX - leftX), element) + : (topX - leftX) * 0.01; + const horizontalRadius = element.roundness + ? getCornerRadius(Math.abs(rightY - topY), element) + : (rightY - topY) * 0.01; + const topApprox = offsetCubicBezier( + pointFrom(topX - verticalRadius, topY + horizontalRadius), + pointFrom(topX, topY), + pointFrom(topX, topY), + pointFrom(topX + verticalRadius, topY + horizontalRadius), + padding, + ); + const rightApprox = offsetCubicBezier( + pointFrom(rightX - verticalRadius, rightY - horizontalRadius), + pointFrom(rightX, rightY), + pointFrom(rightX, rightY), + pointFrom(rightX - verticalRadius, rightY + horizontalRadius), + padding, + ); + const bottomApprox = offsetCubicBezier( + pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), + pointFrom(bottomX, bottomY), + pointFrom(bottomX, bottomY), + pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), + padding, + ); + const leftApprox = offsetCubicBezier( + pointFrom(leftX + verticalRadius, leftY + horizontalRadius), + pointFrom(leftX, leftY), + pointFrom(leftX, leftY), + pointFrom(leftX + verticalRadius, leftY - horizontalRadius), + padding, + ); + + context.moveTo( + topApprox[topApprox.length - 1][0], + topApprox[topApprox.length - 1][1], + ); + context.lineTo(rightApprox[0][0], rightApprox[0][1]); + drawCatmullRomCubicApprox(context, rightApprox); + context.lineTo(bottomApprox[0][0], bottomApprox[0][1]); + drawCatmullRomCubicApprox(context, bottomApprox); + context.lineTo(leftApprox[0][0], leftApprox[0][1]); + drawCatmullRomCubicApprox(context, leftApprox); + context.lineTo(topApprox[0][0], topApprox[0][1]); + drawCatmullRomCubicApprox(context, topApprox); + } + + // Counter-clockwise for the cutout in the middle. We need to have an "inverse + // mask" on a filled shape for the diamond highlight, because stroking creates + // sharp inset edges on line joins < 90 degrees. + { + const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = + getDiamondPoints(element); + const verticalRadius = element.roundness + ? getCornerRadius(Math.abs(topX - leftX), element) + : (topX - leftX) * 0.01; + const horizontalRadius = element.roundness + ? getCornerRadius(Math.abs(rightY - topY), element) + : (rightY - topY) * 0.01; + const topApprox = offsetCubicBezier( + pointFrom(topX + verticalRadius, topY + horizontalRadius), + pointFrom(topX, topY), + pointFrom(topX, topY), + pointFrom(topX - verticalRadius, topY + horizontalRadius), + -FIXED_BINDING_DISTANCE, + ); + const rightApprox = offsetCubicBezier( + pointFrom(rightX - verticalRadius, rightY + horizontalRadius), + pointFrom(rightX, rightY), + pointFrom(rightX, rightY), + pointFrom(rightX - verticalRadius, rightY - horizontalRadius), + -FIXED_BINDING_DISTANCE, + ); + const bottomApprox = offsetCubicBezier( + pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), + pointFrom(bottomX, bottomY), + pointFrom(bottomX, bottomY), + pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), + -FIXED_BINDING_DISTANCE, + ); + const leftApprox = offsetCubicBezier( + pointFrom(leftX + verticalRadius, leftY - horizontalRadius), + pointFrom(leftX, leftY), + pointFrom(leftX, leftY), + pointFrom(leftX + verticalRadius, leftY + horizontalRadius), + -FIXED_BINDING_DISTANCE, + ); + + context.moveTo( + topApprox[topApprox.length - 1][0], + topApprox[topApprox.length - 1][1], + ); + context.lineTo(leftApprox[0][0], leftApprox[0][1]); + drawCatmullRomCubicApprox(context, leftApprox); + context.lineTo(bottomApprox[0][0], bottomApprox[0][1]); + drawCatmullRomCubicApprox(context, bottomApprox); + context.lineTo(rightApprox[0][0], rightApprox[0][1]); + drawCatmullRomCubicApprox(context, rightApprox); + context.lineTo(topApprox[0][0], topApprox[0][1]); + drawCatmullRomCubicApprox(context, topApprox); + } + context.closePath(); + context.fill(); + context.restore(); +}; + +function offsetCubicBezier( + p0: GlobalPoint, + p1: GlobalPoint, + p2: GlobalPoint, + p3: GlobalPoint, + offsetDist: number, + steps = 20, +) { + const offsetPoints = []; + + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const c = curve(p0, p1, p2, p3); + const point = bezierEquation(c, t); + const tangent = vectorNormalize(curveTangent(c, t)); + const normal = vectorNormal(tangent); + + offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point)); + } + + return offsetPoints; +} + +function offsetQuadraticBezier( + p0: GlobalPoint, + p1: GlobalPoint, + p2: GlobalPoint, + offsetDist: number, + steps = 20, +) { + const offsetPoints = []; + + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const t1 = 1 - t; + const point = pointFrom( + t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0], + t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1], + ); + const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]); + const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]); + const tangent = vectorNormalize(vector(tangentX, tangentY)); + const normal = vectorNormal(tangent); + + offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point)); + } + + return offsetPoints; +} diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 69c6a8196..dcef13209 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -1,23 +1,22 @@ -import oc from "open-color"; import { pointFrom, type GlobalPoint, type LocalPoint, type Radians, } from "@excalidraw/math"; +import oc from "open-color"; import { + arrayToMap, DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE, - THEME, - arrayToMap, invariant, + THEME, throttleRAF, } from "@excalidraw/common"; import { - BINDING_HIGHLIGHT_OFFSET, - BINDING_HIGHLIGHT_THICKNESS, + FIXED_BINDING_DISTANCE, maxBindingGap, } from "@excalidraw/element/binding"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; @@ -35,14 +34,12 @@ import { isTextElement, } from "@excalidraw/element/typeChecks"; -import { getCornerRadius } from "@excalidraw/element/shapes"; - import { renderSelectionElement } from "@excalidraw/element/renderElement"; import { - isSelectedViaGroup, - getSelectedGroupIds, getElementsInGroup, + getSelectedGroupIds, + isSelectedViaGroup, selectGroupsFromGivenElements, } from "@excalidraw/element/groups"; @@ -86,8 +83,12 @@ import { getClientColor, renderRemoteCursors } from "../clients"; import { bootstrapCanvas, + drawHighlightForDiamondWithRotation, + drawHighlightForRectWithRotation, fillCircle, getNormalizedCanvasDimensions, + strokeEllipseWithRotation, + strokeRectWithRotation, } from "./helpers"; import type { @@ -160,57 +161,6 @@ const highlightPoint = ( ); }; -const strokeRectWithRotation = ( - context: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - cx: number, - cy: number, - angle: number, - fill: boolean = false, - /** should account for zoom */ - radius: number = 0, -) => { - context.save(); - context.translate(cx, cy); - context.rotate(angle); - if (fill) { - context.fillRect(x - cx, y - cy, width, height); - } - if (radius && context.roundRect) { - context.beginPath(); - context.roundRect(x - cx, y - cy, width, height, radius); - context.stroke(); - context.closePath(); - } else { - context.strokeRect(x - cx, y - cy, width, height); - } - context.restore(); -}; - -const strokeDiamondWithRotation = ( - context: CanvasRenderingContext2D, - width: number, - height: number, - cx: number, - cy: number, - angle: number, -) => { - context.save(); - context.translate(cx, cy); - context.rotate(angle); - context.beginPath(); - context.moveTo(0, height / 2); - context.lineTo(width / 2, 0); - context.lineTo(0, -height / 2); - context.lineTo(-width / 2, 0); - context.closePath(); - context.stroke(); - context.restore(); -}; - const renderSingleLinearPoint = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -237,19 +187,6 @@ const renderSingleLinearPoint = ( ); }; -const strokeEllipseWithRotation = ( - context: CanvasRenderingContext2D, - width: number, - height: number, - cx: number, - cy: number, - angle: number, -) => { - context.beginPath(); - context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2); - context.stroke(); -}; - const renderBindingHighlightForBindableElement = ( context: CanvasRenderingContext2D, element: ExcalidrawBindableElement, @@ -261,16 +198,10 @@ const renderBindingHighlightForBindableElement = ( const height = y2 - y1; context.strokeStyle = "rgba(0,0,0,.05)"; - // When zooming out, make line width greater for visibility - const zoomValue = zoom.value < 1 ? zoom.value : 1; - context.lineWidth = BINDING_HIGHLIGHT_THICKNESS / zoomValue; - // To ensure the binding highlight doesn't overlap the element itself - const padding = context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET; + context.fillStyle = "rgba(0,0,0,.05)"; - const radius = getCornerRadius( - Math.min(element.width, element.height), - element, - ); + // To ensure the binding highlight doesn't overlap the element itself + const padding = maxBindingGap(element, element.width, element.height, zoom); switch (element.type) { case "rectangle": @@ -280,37 +211,20 @@ const renderBindingHighlightForBindableElement = ( case "embeddable": case "frame": case "magicframe": - strokeRectWithRotation( - context, - x1 - padding, - y1 - padding, - width + padding * 2, - height + padding * 2, - x1 + width / 2, - y1 + height / 2, - element.angle, - undefined, - radius, - ); + drawHighlightForRectWithRotation(context, element, padding); break; case "diamond": - const side = Math.hypot(width, height); - const wPadding = (padding * side) / height; - const hPadding = (padding * side) / width; - strokeDiamondWithRotation( - context, - width + wPadding * 2, - height + hPadding * 2, - x1 + width / 2, - y1 + height / 2, - element.angle, - ); + drawHighlightForDiamondWithRotation(context, padding, element); break; case "ellipse": + context.lineWidth = + maxBindingGap(element, element.width, element.height, zoom) - + FIXED_BINDING_DISTANCE; + strokeEllipseWithRotation( context, - width + padding * 2, - height + padding * 2, + width + padding + FIXED_BINDING_DISTANCE, + height + padding + FIXED_BINDING_DISTANCE, x1 + width / 2, y1 + height / 2, element.angle, diff --git a/packages/math/src/curve.ts b/packages/math/src/curve.ts index a79fb43a1..ec2d1afcd 100644 --- a/packages/math/src/curve.ts +++ b/packages/math/src/curve.ts @@ -2,6 +2,7 @@ import type { Bounds } from "@excalidraw/element/bounds"; import { isPoint, pointDistance, pointFrom } from "./point"; import { rectangle, rectangleIntersectLineSegment } from "./rectangle"; +import { vector } from "./vector"; import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types"; @@ -82,7 +83,7 @@ function solve( return [t0, s0]; } -const bezierEquation = ( +export const bezierEquation = ( c: Curve, t: number, ) => @@ -274,6 +275,26 @@ export function isCurve

( ); } +export function curveTangent( + [p0, p1, p2, p3]: Curve, + t: number, +) { + return vector( + -3 * (1 - t) * (1 - t) * p0[0] + + 3 * (1 - t) * (1 - t) * p1[0] - + 6 * t * (1 - t) * p1[0] - + 3 * t * t * p2[0] + + 6 * t * (1 - t) * p2[0] + + 3 * t * t * p3[0], + -3 * (1 - t) * (1 - t) * p0[1] + + 3 * (1 - t) * (1 - t) * p1[1] - + 6 * t * (1 - t) * p1[1] - + 3 * t * t * p2[1] + + 6 * t * (1 - t) * p2[1] + + 3 * t * t * p3[1], + ); +} + function curveBounds( c: Curve, ): Bounds { diff --git a/packages/math/src/vector.ts b/packages/math/src/vector.ts index 246722067..12682fcd9 100644 --- a/packages/math/src/vector.ts +++ b/packages/math/src/vector.ts @@ -143,3 +143,8 @@ export const vectorNormalize = (v: Vector): Vector => { return vector(v[0] / m, v[1] / m); }; + +/** + * Calculate the right-hand normal of the vector. + */ +export const vectorNormal = (v: Vector): Vector => vector(v[1], -v[0]); From d4f70e9f31addc5f1f5afc39112aef6a18364c91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Mon, 5 May 2025 11:34:40 +0200 Subject: [PATCH 2/8] feat: Quarter snap points for diamonds (#9387) --- packages/element/src/binding.ts | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index ee5d037a8..d44123b71 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1171,6 +1171,48 @@ export const snapToMid = ( center, angle, ); + } else if (element.type === "diamond") { + const distance = FIXED_BINDING_DISTANCE - 1; + const topLeft = pointFrom( + x + width / 4 - distance, + y + height / 4 - distance, + ); + const topRight = pointFrom( + x + (3 * width) / 4 + distance, + y + height / 4 - distance, + ); + const bottomLeft = pointFrom( + x + width / 4 - distance, + y + (3 * height) / 4 + distance, + ); + const bottomRight = pointFrom( + x + (3 * width) / 4 + distance, + y + (3 * height) / 4 + distance, + ); + if ( + pointDistance(topLeft, nonRotated) < + Math.max(horizontalThrehsold, verticalThrehsold) + ) { + return pointRotateRads(topLeft, center, angle); + } + if ( + pointDistance(topRight, nonRotated) < + Math.max(horizontalThrehsold, verticalThrehsold) + ) { + return pointRotateRads(topRight, center, angle); + } + if ( + pointDistance(bottomLeft, nonRotated) < + Math.max(horizontalThrehsold, verticalThrehsold) + ) { + return pointRotateRads(bottomLeft, center, angle); + } + if ( + pointDistance(bottomRight, nonRotated) < + Math.max(horizontalThrehsold, verticalThrehsold) + ) { + return pointRotateRads(bottomRight, center, angle); + } } return p; From cec5232a7a35e33a1439a75dfdc264c5fcd0d895 Mon Sep 17 00:00:00 2001 From: Narek Malkhasyan Date: Mon, 5 May 2025 14:15:42 +0400 Subject: [PATCH 3/8] fix: when resizing element, update bound elements after final size of element is determined (#9475) --- packages/element/src/resizeElements.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/element/src/resizeElements.ts b/packages/element/src/resizeElements.ts index 8a1702afa..43a5e7211 100644 --- a/packages/element/src/resizeElements.ts +++ b/packages/element/src/resizeElements.ts @@ -962,11 +962,6 @@ export const resizeSingleElement = ( isDragging: false, }); - updateBoundElements(latestElement, scene, { - // TODO: confirm with MARK if this actually makes sense - newSize: { width: nextWidth, height: nextHeight }, - }); - if (boundTextElement && boundTextFont != null) { scene.mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize, @@ -978,6 +973,11 @@ export const resizeSingleElement = ( handleDirection, shouldMaintainAspectRatio, ); + + updateBoundElements(latestElement, scene, { + // TODO: confirm with MARK if this actually makes sense + newSize: { width: nextWidth, height: nextHeight }, + }); } }; From a7c61319dd54e5f8fe48a842e036fae7ff95faab Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 6 May 2025 13:09:00 +0200 Subject: [PATCH 4/8] fix: do not translate bound elements twice (#9486) --- packages/excalidraw/components/Stats/utils.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index 769601a46..c30777e42 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -215,23 +215,6 @@ export const moveElement = ( updateBindings(latestChildElement, scene, { simultaneouslyUpdated: originalChildren, }); - - const boundTextElement = getBoundTextElement( - latestChildElement, - originalElementsMap, - ); - if (boundTextElement) { - const latestBoundTextElement = elementsMap.get(boundTextElement.id); - latestBoundTextElement && - scene.mutateElement( - latestBoundTextElement, - { - x: boundTextElement.x + changeInX, - y: boundTextElement.y + changeInY, - }, - { informMutation: shouldInformMutation, isDragging: false }, - ); - } }); } }; From 3dc54a724a4bb1e9dbd69c9fb61959f02be1a1aa Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Tue, 6 May 2025 19:23:02 +0200 Subject: [PATCH 5/8] feat: add `onIncrement` API (#9450) --- excalidraw-app/tests/collab.test.tsx | 84 +- .../{excalidraw => common/src}/emitter.ts | 2 +- packages/common/src/index.ts | 1 + packages/common/src/utility-types.ts | 5 + packages/common/src/utils.ts | 19 + packages/element/src/Scene.ts | 17 +- .../change.ts => element/src/delta.ts} | 472 +- packages/element/src/index.ts | 11 +- packages/element/src/linearElementEditor.ts | 4 +- packages/element/src/store.ts | 968 + packages/element/tests/delta.test.tsx | 143 + .../excalidraw/actions/actionAddToLibrary.ts | 3 +- packages/excalidraw/actions/actionAlign.tsx | 3 +- .../excalidraw/actions/actionBoundText.tsx | 4 +- packages/excalidraw/actions/actionCanvas.tsx | 3 +- .../excalidraw/actions/actionClipboard.tsx | 4 +- .../excalidraw/actions/actionCropEditor.tsx | 3 +- .../actions/actionDeleteSelected.tsx | 3 +- .../excalidraw/actions/actionDistribute.tsx | 3 +- .../actions/actionDuplicateSelection.tsx | 3 +- .../excalidraw/actions/actionElementLink.ts | 3 +- .../excalidraw/actions/actionElementLock.ts | 3 +- .../excalidraw/actions/actionEmbeddable.ts | 3 +- packages/excalidraw/actions/actionExport.tsx | 3 +- .../excalidraw/actions/actionFinalize.tsx | 3 +- packages/excalidraw/actions/actionFlip.ts | 3 +- packages/excalidraw/actions/actionFrame.ts | 3 +- packages/excalidraw/actions/actionGroup.tsx | 3 +- packages/excalidraw/actions/actionHistory.tsx | 32 +- .../excalidraw/actions/actionLinearEditor.tsx | 3 +- packages/excalidraw/actions/actionLink.tsx | 3 +- packages/excalidraw/actions/actionMenu.tsx | 4 +- .../excalidraw/actions/actionNavigate.tsx | 3 +- .../excalidraw/actions/actionProperties.tsx | 6 +- .../excalidraw/actions/actionSelectAll.ts | 4 +- packages/excalidraw/actions/actionStyles.ts | 3 +- .../actions/actionTextAutoResize.ts | 3 +- .../actions/actionToggleGridMode.tsx | 3 +- .../actions/actionToggleObjectsSnapMode.tsx | 3 +- .../actions/actionToggleSearchMenu.ts | 3 +- .../actions/actionToggleShapeSwitch.tsx | 3 +- .../excalidraw/actions/actionToggleStats.tsx | 3 +- .../actions/actionToggleViewMode.tsx | 3 +- .../actions/actionToggleZenMode.tsx | 3 +- packages/excalidraw/actions/actionZindex.tsx | 3 +- packages/excalidraw/actions/types.ts | 3 +- packages/excalidraw/components/App.tsx | 145 +- .../excalidraw/components/Stats/DragInput.tsx | 3 +- packages/excalidraw/data/library.ts | 2 +- packages/excalidraw/history.ts | 170 +- packages/excalidraw/hooks/useEmitter.ts | 2 +- packages/excalidraw/index.tsx | 4 +- packages/excalidraw/store.ts | 449 - .../__snapshots__/contextmenu.test.tsx.snap | 4093 ++-- .../tests/__snapshots__/export.test.tsx.snap | 2 +- .../tests/__snapshots__/history.test.tsx.snap | 16328 ++++++++------- .../tests/__snapshots__/move.test.tsx.snap | 12 +- .../regressionTests.test.tsx.snap | 16662 ++++++++-------- .../excalidraw/tests/contextmenu.test.tsx | 4 +- packages/excalidraw/tests/history.test.tsx | 36 +- .../excalidraw/tests/regressionTests.test.tsx | 4 +- packages/excalidraw/tests/test-utils.ts | 44 + packages/excalidraw/types.ts | 11 +- 63 files changed, 20173 insertions(+), 19665 deletions(-) rename packages/{excalidraw => common/src}/emitter.ts (94%) rename packages/{excalidraw/change.ts => element/src/delta.ts} (81%) create mode 100644 packages/element/src/store.ts create mode 100644 packages/element/tests/delta.test.tsx delete mode 100644 packages/excalidraw/store.ts diff --git a/excalidraw-app/tests/collab.test.tsx b/excalidraw-app/tests/collab.test.tsx index 3572303f4..4549c2128 100644 --- a/excalidraw-app/tests/collab.test.tsx +++ b/excalidraw-app/tests/collab.test.tsx @@ -8,6 +8,13 @@ import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils"; import { vi } from "vitest"; +import { StoreIncrement } from "@excalidraw/element/store"; + +import type { + DurableIncrement, + EphemeralIncrement, +} from "@excalidraw/element/store"; + import ExcalidrawApp from "../App"; const { h } = window; @@ -65,6 +72,79 @@ vi.mock("socket.io-client", () => { * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. */ describe("collaboration", () => { + it("should emit two ephemeral increments even though updates get batched", async () => { + const durableIncrements: DurableIncrement[] = []; + const ephemeralIncrements: EphemeralIncrement[] = []; + + await render(); + + h.store.onStoreIncrementEmitter.on((increment) => { + if (StoreIncrement.isDurable(increment)) { + durableIncrements.push(increment); + } else { + ephemeralIncrements.push(increment); + } + }); + + // eslint-disable-next-line dot-notation + expect(h.store["scheduledMicroActions"].length).toBe(0); + expect(durableIncrements.length).toBe(0); + expect(ephemeralIncrements.length).toBe(0); + + const rectProps = { + type: "rectangle", + id: "A", + height: 200, + width: 100, + x: 0, + y: 0, + } as const; + + const rect = API.createElement({ ...rectProps }); + + API.updateScene({ + elements: [rect], + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }); + + await waitFor(() => { + // expect(commitSpy).toHaveBeenCalledTimes(1); + expect(durableIncrements.length).toBe(1); + }); + + // simulate two batched remote updates + act(() => { + h.app.updateScene({ + elements: [newElementWith(h.elements[0], { x: 100 })], + captureUpdate: CaptureUpdateAction.NEVER, + }); + h.app.updateScene({ + elements: [newElementWith(h.elements[0], { x: 200 })], + captureUpdate: CaptureUpdateAction.NEVER, + }); + + // we scheduled two micro actions, + // which confirms they are going to be executed as part of one batched component update + // eslint-disable-next-line dot-notation + expect(h.store["scheduledMicroActions"].length).toBe(2); + }); + + await waitFor(() => { + // altough the updates get batched, + // we expect two ephemeral increments for each update, + // and each such update should have the expected change + expect(ephemeralIncrements.length).toBe(2); + expect(ephemeralIncrements[0].change.elements.A).toEqual( + expect.objectContaining({ x: 100 }), + ); + expect(ephemeralIncrements[1].change.elements.A).toEqual( + expect.objectContaining({ x: 200 }), + ); + // eslint-disable-next-line dot-notation + expect(h.store["scheduledMicroActions"].length).toBe(0); + }); + }); + it("should allow to undo / redo even on force-deleted elements", async () => { await render(); const rect1Props = { @@ -122,7 +202,7 @@ describe("collaboration", () => { expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); }); - const undoAction = createUndoAction(h.history, h.store); + const undoAction = createUndoAction(h.history); act(() => h.app.actionManager.executeAction(undoAction)); // with explicit undo (as addition) we expect our item to be restored from the snapshot! @@ -154,7 +234,7 @@ describe("collaboration", () => { expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); }); - const redoAction = createRedoAction(h.history, h.store); + const redoAction = createRedoAction(h.history); act(() => h.app.actionManager.executeAction(redoAction)); // with explicit redo (as removal) we again restore the element from the snapshot! diff --git a/packages/excalidraw/emitter.ts b/packages/common/src/emitter.ts similarity index 94% rename from packages/excalidraw/emitter.ts rename to packages/common/src/emitter.ts index 938269728..7c069a5a0 100644 --- a/packages/excalidraw/emitter.ts +++ b/packages/common/src/emitter.ts @@ -1,4 +1,4 @@ -import type { UnsubscribeCallback } from "./types"; +import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types"; type Subscriber = (...payload: T) => void; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index d896ba98e..79f243f4f 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -9,3 +9,4 @@ export * from "./promise-pool"; export * from "./random"; export * from "./url"; export * from "./utils"; +export * from "./emitter"; diff --git a/packages/common/src/utility-types.ts b/packages/common/src/utility-types.ts index d4804d195..dd26fc397 100644 --- a/packages/common/src/utility-types.ts +++ b/packages/common/src/utility-types.ts @@ -68,3 +68,8 @@ export type MaybePromise = T | Promise; // get union of all keys from the union of types export type AllPossibleKeys = T extends any ? keyof T : never; + +/** Strip all the methods or functions from a type */ +export type DTO = { + [K in keyof T as T[K] extends Function ? never : K]: T[K]; +}; diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index e7053b181..6bf309c62 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -735,6 +735,25 @@ export const arrayToList = (array: readonly T[]): Node[] => return acc; }, [] as Node[]); +/** + * Converts a readonly array or map into an iterable. + * Useful for avoiding entry allocations when iterating object / map on each iteration. + */ +export const toIterable = ( + values: readonly T[] | ReadonlyMap, +): Iterable => { + return Array.isArray(values) ? values : values.values(); +}; + +/** + * Converts a readonly array or map into an array. + */ +export const toArray = ( + values: readonly T[] | ReadonlyMap, +): T[] => { + return Array.isArray(values) ? values : Array.from(toIterable(values)); +}; + export const isTestEnv = () => import.meta.env.MODE === ENV.TEST; export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT; diff --git a/packages/element/src/Scene.ts b/packages/element/src/Scene.ts index b35c54cae..a31c96264 100644 --- a/packages/element/src/Scene.ts +++ b/packages/element/src/Scene.ts @@ -6,14 +6,13 @@ import { toBrandedType, isDevEnv, isTestEnv, - isReadonlyArray, + toArray, } from "@excalidraw/common"; import { isNonDeletedElement } from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element/typeChecks"; import { getElementsInGroup } from "@excalidraw/element/groups"; import { - orderByFractionalIndex, syncInvalidIndices, syncMovedIndices, validateFractionalIndices, @@ -268,19 +267,13 @@ class Scene { } replaceAllElements(nextElements: ElementsMapOrArray) { - // ts doesn't like `Array.isArray` of `instanceof Map` - if (!isReadonlyArray(nextElements)) { - // need to order by fractional indices to get the correct order - nextElements = orderByFractionalIndex( - Array.from(nextElements.values()) as OrderedExcalidrawElement[], - ); - } - + // we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices + const _nextElements = toArray(nextElements); const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; - validateIndicesThrottled(nextElements); + validateIndicesThrottled(_nextElements); - this.elements = syncInvalidIndices(nextElements); + this.elements = syncInvalidIndices(_nextElements); this.elementsMap.clear(); this.elements.forEach((element) => { if (isFrameLikeElement(element)) { diff --git a/packages/excalidraw/change.ts b/packages/element/src/delta.ts similarity index 81% rename from packages/excalidraw/change.ts rename to packages/element/src/delta.ts index e7ba76f60..2499f7d66 100644 --- a/packages/excalidraw/change.ts +++ b/packages/element/src/delta.ts @@ -5,43 +5,7 @@ import { isDevEnv, isShallowEqual, isTestEnv, - toBrandedType, } from "@excalidraw/common"; -import { - BoundElement, - BindableElement, - bindingProperties, - updateBoundElements, -} from "@excalidraw/element/binding"; -import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; -import { - mutateElement, - newElementWith, -} from "@excalidraw/element/mutateElement"; -import { - getBoundTextElementId, - redrawTextBoundingBox, -} from "@excalidraw/element/textElement"; -import { - hasBoundTextElement, - isBindableElement, - isBoundToContainer, - isImageElement, - isTextElement, -} from "@excalidraw/element/typeChecks"; - -import { getNonDeletedGroupIds } from "@excalidraw/element/groups"; - -import { - orderByFractionalIndex, - syncMovedIndices, -} from "@excalidraw/element/fractionalIndex"; - -import Scene from "@excalidraw/element/Scene"; - -import type { BindableProp, BindingProp } from "@excalidraw/element/binding"; - -import type { ElementUpdate } from "@excalidraw/element/mutateElement"; import type { ExcalidrawElement, @@ -54,16 +18,42 @@ import type { SceneElementsMap, } from "@excalidraw/element/types"; -import type { SubtypeOf, ValueOf } from "@excalidraw/common/utility-types"; - -import { getObservedAppState } from "./store"; +import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types"; import type { AppState, ObservedAppState, ObservedElementsAppState, ObservedStandaloneAppState, -} from "./types"; +} from "@excalidraw/excalidraw/types"; + +import { getObservedAppState } from "./store"; + +import { + BoundElement, + BindableElement, + bindingProperties, + updateBoundElements, +} from "./binding"; +import { LinearElementEditor } from "./linearElementEditor"; +import { mutateElement, newElementWith } from "./mutateElement"; +import { getBoundTextElementId, redrawTextBoundingBox } from "./textElement"; +import { + hasBoundTextElement, + isBindableElement, + isBoundToContainer, + isTextElement, +} from "./typeChecks"; + +import { getNonDeletedGroupIds } from "./groups"; + +import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; + +import Scene from "./Scene"; + +import type { BindableProp, BindingProp } from "./binding"; + +import type { ElementUpdate } from "./mutateElement"; /** * Represents the difference between two objects of the same type. @@ -74,7 +64,7 @@ import type { * * Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load. */ -class Delta { +export class Delta { private constructor( public readonly deleted: Partial, public readonly inserted: Partial, @@ -326,7 +316,7 @@ class Delta { } /** - * Returns all the object1 keys that have distinct values. + * Returns sorted object1 keys that have distinct values. */ public static getLeftDifferences( object1: T, @@ -335,11 +325,11 @@ class Delta { ) { return Array.from( this.distinctKeysIterator("left", object1, object2, skipShallowCompare), - ); + ).sort(); } /** - * Returns all the object2 keys that have distinct values. + * Returns sorted object2 keys that have distinct values. */ public static getRightDifferences( object1: T, @@ -348,7 +338,7 @@ class Delta { ) { return Array.from( this.distinctKeysIterator("right", object1, object2, skipShallowCompare), - ); + ).sort(); } /** @@ -409,51 +399,57 @@ class Delta { } /** - * Encapsulates the modifications captured as `Delta`/s. + * Encapsulates a set of application-level `Delta`s. */ -interface Change { +export interface DeltaContainer { /** - * Inverses the `Delta`s inside while creating a new `Change`. + * Inverses the `Delta`s while creating a new `DeltaContainer` instance. */ - inverse(): Change; + inverse(): DeltaContainer; /** - * Applies the `Change` to the previous object. + * Applies the `Delta`s to the previous object. * - * @returns a tuple of the next object `T` with applied change, and `boolean`, indicating whether the applied change resulted in a visible change. + * @returns a tuple of the next object `T` with applied `Delta`s, and `boolean`, indicating whether the applied deltas resulted in a visible change. */ applyTo(previous: T, ...options: unknown[]): [T, boolean]; /** - * Checks whether there are actually `Delta`s. + * Checks whether all `Delta`s are empty. */ isEmpty(): boolean; } -export class AppStateChange implements Change { - private constructor(private readonly delta: Delta) {} +export class AppStateDelta implements DeltaContainer { + private constructor(public readonly delta: Delta) {} public static calculate( prevAppState: T, nextAppState: T, - ): AppStateChange { + ): AppStateDelta { const delta = Delta.calculate( prevAppState, nextAppState, - undefined, - AppStateChange.postProcess, + // making the order of keys in deltas stable for hashing purposes + AppStateDelta.orderAppStateKeys, + AppStateDelta.postProcess, ); - return new AppStateChange(delta); + return new AppStateDelta(delta); + } + + public static restore(appStateDeltaDTO: DTO): AppStateDelta { + const { delta } = appStateDeltaDTO; + return new AppStateDelta(delta); } public static empty() { - return new AppStateChange(Delta.create({}, {})); + return new AppStateDelta(Delta.create({}, {})); } - public inverse(): AppStateChange { + public inverse(): AppStateDelta { const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted); - return new AppStateChange(inversedDelta); + return new AppStateDelta(inversedDelta); } public applyTo( @@ -544,40 +540,6 @@ export class AppStateChange implements Change { return Delta.isEmpty(this.delta); } - /** - * It is necessary to post process the partials in case of reference values, - * for which we need to calculate the real diff between `deleted` and `inserted`. - */ - private static postProcess( - deleted: Partial, - inserted: Partial, - ): [Partial, Partial] { - try { - Delta.diffObjects( - deleted, - inserted, - "selectedElementIds", - // ts language server has a bit trouble resolving this, so we are giving it a little push - (_) => true as ValueOf, - ); - Delta.diffObjects( - deleted, - inserted, - "selectedGroupIds", - (prevValue) => (prevValue ?? false) as ValueOf, - ); - } catch (e) { - // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it - console.error(`Couldn't postprocess appstate change deltas.`); - - if (isTestEnv() || isDevEnv()) { - throw e; - } - } finally { - return [deleted, inserted]; - } - } - /** * Mutates `nextAppState` be filtering out state related to deleted elements. * @@ -594,13 +556,13 @@ export class AppStateChange implements Change { const nextObservedAppState = getObservedAppState(nextAppState); const containsStandaloneDifference = Delta.isRightDifferent( - AppStateChange.stripElementsProps(prevObservedAppState), - AppStateChange.stripElementsProps(nextObservedAppState), + AppStateDelta.stripElementsProps(prevObservedAppState), + AppStateDelta.stripElementsProps(nextObservedAppState), ); const containsElementsDifference = Delta.isRightDifferent( - AppStateChange.stripStandaloneProps(prevObservedAppState), - AppStateChange.stripStandaloneProps(nextObservedAppState), + AppStateDelta.stripStandaloneProps(prevObservedAppState), + AppStateDelta.stripStandaloneProps(nextObservedAppState), ); if (!containsStandaloneDifference && !containsElementsDifference) { @@ -615,8 +577,8 @@ export class AppStateChange implements Change { if (containsElementsDifference) { // filter invisible changes on each iteration const changedElementsProps = Delta.getRightDifferences( - AppStateChange.stripStandaloneProps(prevObservedAppState), - AppStateChange.stripStandaloneProps(nextObservedAppState), + AppStateDelta.stripStandaloneProps(prevObservedAppState), + AppStateDelta.stripStandaloneProps(nextObservedAppState), ) as Array; let nonDeletedGroupIds = new Set(); @@ -633,7 +595,7 @@ export class AppStateChange implements Change { for (const key of changedElementsProps) { switch (key) { case "selectedElementIds": - nextAppState[key] = AppStateChange.filterSelectedElements( + nextAppState[key] = AppStateDelta.filterSelectedElements( nextAppState[key], nextElements, visibleDifferenceFlag, @@ -641,7 +603,7 @@ export class AppStateChange implements Change { break; case "selectedGroupIds": - nextAppState[key] = AppStateChange.filterSelectedGroups( + nextAppState[key] = AppStateDelta.filterSelectedGroups( nextAppState[key], nonDeletedGroupIds, visibleDifferenceFlag, @@ -677,7 +639,7 @@ export class AppStateChange implements Change { break; case "selectedLinearElementId": case "editingLinearElementId": - const appStateKey = AppStateChange.convertToAppStateKey(key); + const appStateKey = AppStateDelta.convertToAppStateKey(key); const linearElement = nextAppState[appStateKey]; if (!linearElement) { @@ -812,6 +774,51 @@ export class AppStateChange implements Change { ObservedElementsAppState >; } + + /** + * It is necessary to post process the partials in case of reference values, + * for which we need to calculate the real diff between `deleted` and `inserted`. + */ + private static postProcess( + deleted: Partial, + inserted: Partial, + ): [Partial, Partial] { + try { + Delta.diffObjects( + deleted, + inserted, + "selectedElementIds", + // ts language server has a bit trouble resolving this, so we are giving it a little push + (_) => true as ValueOf, + ); + Delta.diffObjects( + deleted, + inserted, + "selectedGroupIds", + (prevValue) => (prevValue ?? false) as ValueOf, + ); + } catch (e) { + // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it + console.error(`Couldn't postprocess appstate change deltas.`); + + if (isTestEnv() || isDevEnv()) { + throw e; + } + } finally { + return [deleted, inserted]; + } + } + + private static orderAppStateKeys(partial: Partial) { + const orderedPartial: { [key: string]: unknown } = {}; + + for (const key of Object.keys(partial).sort()) { + // relying on insertion order + orderedPartial[key] = partial[key as keyof ObservedAppState]; + } + + return orderedPartial as Partial; + } } type ElementPartial = Omit< @@ -823,50 +830,63 @@ type ElementPartial = Omit< * Elements change is a low level primitive to capture a change between two sets of elements. * It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions. */ -export class ElementsChange implements Change { +export class ElementsDelta implements DeltaContainer { private constructor( - private readonly added: Map>, - private readonly removed: Map>, - private readonly updated: Map>, + public readonly added: Record>, + public readonly removed: Record>, + public readonly updated: Record>, ) {} public static create( - added: Map>, - removed: Map>, - updated: Map>, - options = { shouldRedistribute: false }, + added: Record>, + removed: Record>, + updated: Record>, + options: { + shouldRedistribute: boolean; + } = { + shouldRedistribute: false, + }, ) { - let change: ElementsChange; + let delta: ElementsDelta; if (options.shouldRedistribute) { - const nextAdded = new Map>(); - const nextRemoved = new Map>(); - const nextUpdated = new Map>(); + const nextAdded: Record> = {}; + const nextRemoved: Record> = {}; + const nextUpdated: Record> = {}; - const deltas = [...added, ...removed, ...updated]; + const deltas = [ + ...Object.entries(added), + ...Object.entries(removed), + ...Object.entries(updated), + ]; for (const [id, delta] of deltas) { if (this.satisfiesAddition(delta)) { - nextAdded.set(id, delta); + nextAdded[id] = delta; } else if (this.satisfiesRemoval(delta)) { - nextRemoved.set(id, delta); + nextRemoved[id] = delta; } else { - nextUpdated.set(id, delta); + nextUpdated[id] = delta; } } - change = new ElementsChange(nextAdded, nextRemoved, nextUpdated); + delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated); } else { - change = new ElementsChange(added, removed, updated); + delta = new ElementsDelta(added, removed, updated); } if (isTestEnv() || isDevEnv()) { - ElementsChange.validate(change, "added", this.satisfiesAddition); - ElementsChange.validate(change, "removed", this.satisfiesRemoval); - ElementsChange.validate(change, "updated", this.satisfiesUpdate); + ElementsDelta.validate(delta, "added", this.satisfiesAddition); + ElementsDelta.validate(delta, "removed", this.satisfiesRemoval); + ElementsDelta.validate(delta, "updated", this.satisfiesUpdate); } - return change; + return delta; + } + + public static restore(elementsDeltaDTO: DTO): ElementsDelta { + const { added, removed, updated } = elementsDeltaDTO; + return ElementsDelta.create(added, removed, updated); } private static satisfiesAddition = ({ @@ -888,17 +908,17 @@ export class ElementsChange implements Change { }: Delta) => !!deleted.isDeleted === !!inserted.isDeleted; private static validate( - change: ElementsChange, + elementsDelta: ElementsDelta, type: "added" | "removed" | "updated", satifies: (delta: Delta) => boolean, ) { - for (const [id, delta] of change[type].entries()) { + for (const [id, delta] of Object.entries(elementsDelta[type])) { if (!satifies(delta)) { console.error( `Broken invariant for "${type}" delta, element "${id}", delta:`, delta, ); - throw new Error(`ElementsChange invariant broken for element "${id}".`); + throw new Error(`ElementsDelta invariant broken for element "${id}".`); } } } @@ -909,19 +929,19 @@ export class ElementsChange implements Change { * @param prevElements - Map representing the previous state of elements. * @param nextElements - Map representing the next state of elements. * - * @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements. + * @returns `ElementsDelta` instance representing the `Delta` changes between the two sets of elements. */ public static calculate( prevElements: Map, nextElements: Map, - ): ElementsChange { + ): ElementsDelta { if (prevElements === nextElements) { - return ElementsChange.empty(); + return ElementsDelta.empty(); } - const added = new Map>(); - const removed = new Map>(); - const updated = new Map>(); + const added: Record> = {}; + const removed: Record> = {}; + const updated: Record> = {}; // this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements for (const prevElement of prevElements.values()) { @@ -934,10 +954,10 @@ export class ElementsChange implements Change { const delta = Delta.create( deleted, inserted, - ElementsChange.stripIrrelevantProps, + ElementsDelta.stripIrrelevantProps, ); - removed.set(prevElement.id, delta); + removed[prevElement.id] = delta; } } @@ -954,10 +974,10 @@ export class ElementsChange implements Change { const delta = Delta.create( deleted, inserted, - ElementsChange.stripIrrelevantProps, + ElementsDelta.stripIrrelevantProps, ); - added.set(nextElement.id, delta); + added[nextElement.id] = delta; continue; } @@ -966,8 +986,8 @@ export class ElementsChange implements Change { const delta = Delta.calculate( prevElement, nextElement, - ElementsChange.stripIrrelevantProps, - ElementsChange.postProcess, + ElementsDelta.stripIrrelevantProps, + ElementsDelta.postProcess, ); if ( @@ -978,9 +998,9 @@ export class ElementsChange implements Change { ) { // notice that other props could have been updated as well if (prevElement.isDeleted && !nextElement.isDeleted) { - added.set(nextElement.id, delta); + added[nextElement.id] = delta; } else { - removed.set(nextElement.id, delta); + removed[nextElement.id] = delta; } continue; @@ -988,24 +1008,24 @@ export class ElementsChange implements Change { // making sure there are at least some changes if (!Delta.isEmpty(delta)) { - updated.set(nextElement.id, delta); + updated[nextElement.id] = delta; } } } - return ElementsChange.create(added, removed, updated); + return ElementsDelta.create(added, removed, updated); } public static empty() { - return ElementsChange.create(new Map(), new Map(), new Map()); + return ElementsDelta.create({}, {}, {}); } - public inverse(): ElementsChange { - const inverseInternal = (deltas: Map>) => { - const inversedDeltas = new Map>(); + public inverse(): ElementsDelta { + const inverseInternal = (deltas: Record>) => { + const inversedDeltas: Record> = {}; - for (const [id, delta] of deltas.entries()) { - inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted)); + for (const [id, delta] of Object.entries(deltas)) { + inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted); } return inversedDeltas; @@ -1016,14 +1036,14 @@ export class ElementsChange implements Change { const updated = inverseInternal(this.updated); // notice we inverse removed with added not to break the invariants - return ElementsChange.create(removed, added, updated); + return ElementsDelta.create(removed, added, updated); } public isEmpty(): boolean { return ( - this.added.size === 0 && - this.removed.size === 0 && - this.updated.size === 0 + Object.keys(this.added).length === 0 && + Object.keys(this.removed).length === 0 && + Object.keys(this.updated).length === 0 ); } @@ -1034,7 +1054,10 @@ export class ElementsChange implements Change { * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated * @returns new instance with modified delta/s */ - public applyLatestChanges(elements: SceneElementsMap): ElementsChange { + public applyLatestChanges( + elements: SceneElementsMap, + modifierOptions: "deleted" | "inserted", + ): ElementsDelta { const modifier = (element: OrderedExcalidrawElement) => (partial: ElementPartial) => { const latestPartial: { [key: string]: unknown } = {}; @@ -1055,11 +1078,11 @@ export class ElementsChange implements Change { }; const applyLatestChangesInternal = ( - deltas: Map>, + deltas: Record>, ) => { - const modifiedDeltas = new Map>(); + const modifiedDeltas: Record> = {}; - for (const [id, delta] of deltas.entries()) { + for (const [id, delta] of Object.entries(deltas)) { const existingElement = elements.get(id); if (existingElement) { @@ -1067,12 +1090,12 @@ export class ElementsChange implements Change { delta.deleted, delta.inserted, modifier(existingElement), - "inserted", + modifierOptions, ); - modifiedDeltas.set(id, modifiedDelta); + modifiedDeltas[id] = modifiedDelta; } else { - modifiedDeltas.set(id, delta); + modifiedDeltas[id] = delta; } } @@ -1083,16 +1106,16 @@ export class ElementsChange implements Change { const removed = applyLatestChangesInternal(this.removed); const updated = applyLatestChangesInternal(this.updated); - return ElementsChange.create(added, removed, updated, { + return ElementsDelta.create(added, removed, updated, { shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated }); } public applyTo( elements: SceneElementsMap, - snapshot: Map, + elementsSnapshot: Map, ): [SceneElementsMap, boolean] { - let nextElements = toBrandedType(new Map(elements)); + let nextElements = new Map(elements) as SceneElementsMap; let changedElements: Map; const flags = { @@ -1102,15 +1125,15 @@ export class ElementsChange implements Change { // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation) try { - const applyDeltas = ElementsChange.createApplier( + const applyDeltas = ElementsDelta.createApplier( nextElements, - snapshot, + elementsSnapshot, flags, ); - const addedElements = applyDeltas(this.added); - const removedElements = applyDeltas(this.removed); - const updatedElements = applyDeltas(this.updated); + const addedElements = applyDeltas("added", this.added); + const removedElements = applyDeltas("removed", this.removed); + const updatedElements = applyDeltas("updated", this.updated); const affectedElements = this.resolveConflicts(elements, nextElements); @@ -1122,7 +1145,7 @@ export class ElementsChange implements Change { ...affectedElements, ]); } catch (e) { - console.error(`Couldn't apply elements change`, e); + console.error(`Couldn't apply elements delta`, e); if (isTestEnv() || isDevEnv()) { throw e; @@ -1138,7 +1161,7 @@ export class ElementsChange implements Change { try { // the following reorder performs also mutations, but only on new instances of changed elements // (unless something goes really bad and it fallbacks to fixing all invalid indices) - nextElements = ElementsChange.reorderElements( + nextElements = ElementsDelta.reorderElements( nextElements, changedElements, flags, @@ -1149,9 +1172,9 @@ export class ElementsChange implements Change { // so we are creating a temp scene just to query and mutate elements const tempScene = new Scene(nextElements); - ElementsChange.redrawTextBoundingBoxes(tempScene, changedElements); + ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements); // Need ordered nextElements to avoid z-index binding issues - ElementsChange.redrawBoundArrows(tempScene, changedElements); + ElementsDelta.redrawBoundArrows(tempScene, changedElements); } catch (e) { console.error( `Couldn't mutate elements after applying elements change`, @@ -1166,36 +1189,42 @@ export class ElementsChange implements Change { } } - private static createApplier = ( - nextElements: SceneElementsMap, - snapshot: Map, - flags: { - containsVisibleDifference: boolean; - containsZindexDifference: boolean; - }, - ) => { - const getElement = ElementsChange.createGetter( - nextElements, - snapshot, - flags, - ); + private static createApplier = + ( + nextElements: SceneElementsMap, + snapshot: Map, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) => + ( + type: "added" | "removed" | "updated", + deltas: Record>, + ) => { + const getElement = ElementsDelta.createGetter( + type, + nextElements, + snapshot, + flags, + ); - return (deltas: Map>) => - Array.from(deltas.entries()).reduce((acc, [id, delta]) => { + return Object.entries(deltas).reduce((acc, [id, delta]) => { const element = getElement(id, delta.inserted); if (element) { - const newElement = ElementsChange.applyDelta(element, delta, flags); + const newElement = ElementsDelta.applyDelta(element, delta, flags); nextElements.set(newElement.id, newElement); acc.set(newElement.id, newElement); } return acc; }, new Map()); - }; + }; private static createGetter = ( + type: "added" | "removed" | "updated", elements: SceneElementsMap, snapshot: Map, flags: { @@ -1221,6 +1250,14 @@ export class ElementsChange implements Change { ) { flags.containsVisibleDifference = true; } + } else { + // not in elements, not in snapshot? element might have been added remotely! + element = newElementWith( + { id, version: 1 } as OrderedExcalidrawElement, + { + ...partial, + }, + ); } } @@ -1257,7 +1294,8 @@ export class ElementsChange implements Change { }); } - if (isImageElement(element)) { + // TODO: this looks wrong, shouldn't be here + if (element.type === "image") { const _delta = delta as Delta>; // we want to override `crop` only if modified so that we don't reset // when undoing/redoing unrelated change @@ -1270,10 +1308,12 @@ export class ElementsChange implements Change { } if (!flags.containsVisibleDifference) { - // strip away fractional as even if it would be different, it doesn't have to result in visible change + // strip away fractional index, as even if it would be different, it doesn't have to result in visible change const { index, ...rest } = directlyApplicablePartial; - const containsVisibleDifference = - ElementsChange.checkForVisibleDifference(element, rest); + const containsVisibleDifference = ElementsDelta.checkForVisibleDifference( + element, + rest, + ); flags.containsVisibleDifference = containsVisibleDifference; } @@ -1316,6 +1356,8 @@ export class ElementsChange implements Change { * Resolves conflicts for all previously added, removed and updated elements. * Updates the previous deltas with all the changes after conflict resolution. * + * // TODO: revisit since some bound arrows seem to be often redrawn incorrectly + * * @returns all elements affected by the conflict resolution */ private resolveConflicts( @@ -1346,7 +1388,7 @@ export class ElementsChange implements Change { nextElement, nextElements, updates as ElementUpdate, - ) as OrderedExcalidrawElement; + ); } nextAffectedElements.set(affectedElement.id, affectedElement); @@ -1354,20 +1396,21 @@ export class ElementsChange implements Change { }; // removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound - for (const [id] of this.removed) { - ElementsChange.unbindAffected(prevElements, nextElements, id, updater); + for (const id of Object.keys(this.removed)) { + ElementsDelta.unbindAffected(prevElements, nextElements, id, updater); } // added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound - for (const [id] of this.added) { - ElementsChange.rebindAffected(prevElements, nextElements, id, updater); + for (const id of Object.keys(this.added)) { + ElementsDelta.rebindAffected(prevElements, nextElements, id, updater); } // updated delta is affecting the binding only in case it contains changed binding or bindable property - for (const [id] of Array.from(this.updated).filter(([_, delta]) => - Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) => - bindingProperties.has(prop as BindingProp | BindableProp), - ), + for (const [id] of Array.from(Object.entries(this.updated)).filter( + ([_, delta]) => + Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) => + bindingProperties.has(prop as BindingProp | BindableProp), + ), )) { const updatedElement = nextElements.get(id); if (!updatedElement || updatedElement.isDeleted) { @@ -1375,7 +1418,7 @@ export class ElementsChange implements Change { continue; } - ElementsChange.rebindAffected(prevElements, nextElements, id, updater); + ElementsDelta.rebindAffected(prevElements, nextElements, id, updater); } // filter only previous elements, which were now affected @@ -1385,21 +1428,21 @@ export class ElementsChange implements Change { // calculate complete deltas for affected elements, and assign them back to all the deltas // technically we could do better here if perf. would become an issue - const { added, removed, updated } = ElementsChange.calculate( + const { added, removed, updated } = ElementsDelta.calculate( prevAffectedElements, nextAffectedElements, ); - for (const [id, delta] of added) { - this.added.set(id, delta); + for (const [id, delta] of Object.entries(added)) { + this.added[id] = delta; } - for (const [id, delta] of removed) { - this.removed.set(id, delta); + for (const [id, delta] of Object.entries(removed)) { + this.removed[id] = delta; } - for (const [id, delta] of updated) { - this.updated.set(id, delta); + for (const [id, delta] of Object.entries(updated)) { + this.updated[id] = delta; } return nextAffectedElements; @@ -1572,7 +1615,7 @@ export class ElementsChange implements Change { Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id); } catch (e) { // if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it - console.error(`Couldn't postprocess elements change deltas.`); + console.error(`Couldn't postprocess elements delta.`); if (isTestEnv() || isDevEnv()) { throw e; @@ -1585,8 +1628,7 @@ export class ElementsChange implements Change { private static stripIrrelevantProps( partial: Partial, ): ElementPartial { - const { id, updated, version, versionNonce, seed, ...strippedPartial } = - partial; + const { id, updated, version, versionNonce, ...strippedPartial } = partial; return strippedPartial; } diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index d7edec8ae..eafa609c4 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -1,3 +1,5 @@ +import { toIterable } from "@excalidraw/common"; + import { isInvisiblySmallElement } from "./sizeHelpers"; import { isLinearElementType } from "./typeChecks"; @@ -5,6 +7,7 @@ import type { ExcalidrawElement, NonDeletedExcalidrawElement, NonDeleted, + ElementsMapOrArray, } from "./types"; /** @@ -16,12 +19,10 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) => /** * Hashes elements' versionNonce (using djb2 algo). Order of elements matters. */ -export const hashElementsVersion = ( - elements: readonly ExcalidrawElement[], -): number => { +export const hashElementsVersion = (elements: ElementsMapOrArray): number => { let hash = 5381; - for (let i = 0; i < elements.length; i++) { - hash = (hash << 5) + hash + elements[i].versionNonce; + for (const element of toIterable(elements)) { + hash = (hash << 5) + hash + element.versionNonce; } return hash >>> 0; // Ensure unsigned 32-bit integer }; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 55e3f5c4f..a6e4a1af7 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -20,7 +20,7 @@ import { tupleToCoors, } from "@excalidraw/common"; -import type { Store } from "@excalidraw/excalidraw/store"; +import type { Store } from "@excalidraw/element/store"; import type { Radians } from "@excalidraw/math"; @@ -807,7 +807,7 @@ export class LinearElementEditor { }); ret.didAddPoint = true; } - store.shouldCaptureIncrement(); + store.scheduleCapture(); ret.linearElementEditor = { ...linearElementEditor, pointerDownState: { diff --git a/packages/element/src/store.ts b/packages/element/src/store.ts new file mode 100644 index 000000000..e50a94a86 --- /dev/null +++ b/packages/element/src/store.ts @@ -0,0 +1,968 @@ +import { + assertNever, + COLOR_PALETTE, + isDevEnv, + isTestEnv, + randomId, + Emitter, + toIterable, +} from "@excalidraw/common"; + +import type App from "@excalidraw/excalidraw/components/App"; + +import type { DTO, ValueOf } from "@excalidraw/common/utility-types"; + +import type { AppState, ObservedAppState } from "@excalidraw/excalidraw/types"; + +import { deepCopyElement } from "./duplicate"; +import { newElementWith } from "./mutateElement"; + +import { ElementsDelta, AppStateDelta, Delta } from "./delta"; + +import { hashElementsVersion, hashString } from "./index"; + +import type { OrderedExcalidrawElement, SceneElementsMap } from "./types"; + +export const CaptureUpdateAction = { + /** + * Immediately undoable. + * + * Use for updates which should be captured. + * Should be used for most of the local updates, except ephemerals such as dragging or resizing. + * + * These updates will _immediately_ make it to the local undo / redo stacks. + */ + IMMEDIATELY: "IMMEDIATELY", + /** + * Never undoable. + * + * Use for updates which should never be recorded, such as remote updates + * or scene initialization. + * + * These updates will _never_ make it to the local undo / redo stacks. + */ + NEVER: "NEVER", + /** + * Eventually undoable. + * + * Use for updates which should not be captured immediately - likely + * exceptions which are part of some async multi-step process. Otherwise, all + * such updates would end up being captured with the next + * `CaptureUpdateAction.IMMEDIATELY` - triggered either by the next `updateScene` + * or internally by the editor. + * + * These updates will _eventually_ make it to the local undo / redo stacks. + */ + EVENTUALLY: "EVENTUALLY", +} as const; + +export type CaptureUpdateActionType = ValueOf; + +type MicroActionsQueue = (() => void)[]; + +/** + * Store which captures the observed changes and emits them as `StoreIncrement` events. + */ +export class Store { + // internally used by history + public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>(); + public readonly onStoreIncrementEmitter = new Emitter< + [DurableIncrement | EphemeralIncrement] + >(); + + private scheduledMacroActions: Set = new Set(); + private scheduledMicroActions: MicroActionsQueue = []; + + private _snapshot = StoreSnapshot.empty(); + + public get snapshot() { + return this._snapshot; + } + + public set snapshot(snapshot: StoreSnapshot) { + this._snapshot = snapshot; + } + + constructor(private readonly app: App) {} + + public scheduleAction(action: CaptureUpdateActionType) { + this.scheduledMacroActions.add(action); + this.satisfiesScheduledActionsInvariant(); + } + + /** + * Use to schedule a delta calculation, which will consquentially be emitted as `DurableStoreIncrement` and pushed in the undo stack. + */ + // TODO: Suspicious that this is called so many places. Seems error-prone. + public scheduleCapture() { + this.scheduleAction(CaptureUpdateAction.IMMEDIATELY); + } + + /** + * Schedule special "micro" actions, to-be executed before the next commit, before it executes a scheduled "macro" action. + */ + public scheduleMicroAction( + params: + | { + action: CaptureUpdateActionType; + elements: SceneElementsMap | undefined; + appState: AppState | ObservedAppState | undefined; + } + | { + action: typeof CaptureUpdateAction.IMMEDIATELY; + change: StoreChange; + delta: StoreDelta; + } + | { + action: + | typeof CaptureUpdateAction.NEVER + | typeof CaptureUpdateAction.EVENTUALLY; + change: StoreChange; + }, + ) { + const { action } = params; + + let change: StoreChange; + + if ("change" in params) { + change = params.change; + } else { + // immediately create an immutable change of the scheduled updates, + // compared to the current state, so that they won't mutate later on during batching + const currentSnapshot = StoreSnapshot.create( + this.app.scene.getElementsMapIncludingDeleted(), + this.app.state, + ); + const scheduledSnapshot = currentSnapshot.maybeClone( + action, + params.elements, + params.appState, + ); + + change = StoreChange.create(currentSnapshot, scheduledSnapshot); + } + + const delta = "delta" in params ? params.delta : undefined; + + this.scheduledMicroActions.push(() => + this.processAction({ + action, + change, + delta, + }), + ); + } + + /** + * Performs the incoming `CaptureUpdateAction` and emits the corresponding `StoreIncrement`. + * Emits `DurableStoreIncrement` when action is "capture", emits `EphemeralStoreIncrement` otherwise. + * + * @emits StoreIncrement + */ + public commit( + elements: SceneElementsMap | undefined, + appState: AppState | ObservedAppState | undefined, + ): void { + // execute all scheduled micro actions first + // similar to microTasks, there can be many + this.flushMicroActions(); + + try { + // execute a single scheduled "macro" function + // similar to macro tasks, there can be only one within a single commit (loop) + const action = this.getScheduledMacroAction(); + this.processAction({ action, elements, appState }); + } finally { + this.satisfiesScheduledActionsInvariant(); + // defensively reset all scheduled "macro" actions, possibly cleans up other runtime garbage + this.scheduledMacroActions = new Set(); + } + } + + /** + * Clears the store instance. + */ + public clear(): void { + this.snapshot = StoreSnapshot.empty(); + this.scheduledMacroActions = new Set(); + } + + /** + * Performs delta & change calculation and emits a durable increment. + * + * @emits StoreIncrement. + */ + private emitDurableIncrement( + snapshot: StoreSnapshot, + change: StoreChange | undefined = undefined, + delta: StoreDelta | undefined = undefined, + ) { + const prevSnapshot = this.snapshot; + + let storeChange: StoreChange; + let storeDelta: StoreDelta; + + if (change) { + storeChange = change; + } else { + storeChange = StoreChange.create(prevSnapshot, snapshot); + } + + if (delta) { + // we might have the delta already (i.e. when applying history entry), thus we don't need to calculate it again + // using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again + storeDelta = delta; + } else { + // calculate the deltas based on the previous and next snapshot + const elementsDelta = snapshot.metadata.didElementsChange + ? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements) + : ElementsDelta.empty(); + + const appStateDelta = snapshot.metadata.didAppStateChange + ? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState) + : AppStateDelta.empty(); + + storeDelta = StoreDelta.create(elementsDelta, appStateDelta); + } + + if (!storeDelta.isEmpty()) { + const increment = new DurableIncrement(storeChange, storeDelta); + + // Notify listeners with the increment + this.onDurableIncrementEmitter.trigger(increment); + this.onStoreIncrementEmitter.trigger(increment); + } + } + + /** + * Performs change calculation and emits an ephemeral increment. + * + * @emits EphemeralStoreIncrement + */ + private emitEphemeralIncrement( + snapshot: StoreSnapshot, + change: StoreChange | undefined = undefined, + ) { + let storeChange: StoreChange; + + if (change) { + storeChange = change; + } else { + const prevSnapshot = this.snapshot; + storeChange = StoreChange.create(prevSnapshot, snapshot); + } + + const increment = new EphemeralIncrement(storeChange); + + // Notify listeners with the increment + this.onStoreIncrementEmitter.trigger(increment); + } + + private applyChangeToSnapshot(change: StoreChange) { + const prevSnapshot = this.snapshot; + const nextSnapshot = this.snapshot.applyChange(change); + + if (prevSnapshot === nextSnapshot) { + return null; + } + + return nextSnapshot; + } + + /** + * Clones the snapshot if there are changes detected. + */ + private maybeCloneSnapshot( + action: CaptureUpdateActionType, + elements: SceneElementsMap | undefined, + appState: AppState | ObservedAppState | undefined, + ) { + if (!elements && !appState) { + return null; + } + + const prevSnapshot = this.snapshot; + const nextSnapshot = this.snapshot.maybeClone(action, elements, appState); + + if (prevSnapshot === nextSnapshot) { + return null; + } + + return nextSnapshot; + } + + private flushMicroActions() { + for (const microAction of this.scheduledMicroActions) { + try { + microAction(); + } catch (error) { + console.error(`Failed to execute scheduled micro action`, error); + } + } + + this.scheduledMicroActions = []; + } + + private processAction( + params: + | { + action: CaptureUpdateActionType; + elements: SceneElementsMap | undefined; + appState: AppState | ObservedAppState | undefined; + } + | { + action: CaptureUpdateActionType; + change: StoreChange; + delta: StoreDelta | undefined; + }, + ) { + const { action } = params; + + // perf. optimisation, since "EVENTUALLY" does not update the snapshot, + // so if nobody is listening for increments, we don't need to even clone the snapshot + // as it's only needed for `StoreChange` computation inside `EphemeralIncrement` + if ( + action === CaptureUpdateAction.EVENTUALLY && + !this.onStoreIncrementEmitter.subscribers.length + ) { + return; + } + + let nextSnapshot: StoreSnapshot | null; + + if ("change" in params) { + nextSnapshot = this.applyChangeToSnapshot(params.change); + } else { + nextSnapshot = this.maybeCloneSnapshot( + action, + params.elements, + params.appState, + ); + } + + if (!nextSnapshot) { + // don't continue if there is not change detected + return; + } + + const change = "change" in params ? params.change : undefined; + const delta = "delta" in params ? params.delta : undefined; + + try { + switch (action) { + // only immediately emits a durable increment + case CaptureUpdateAction.IMMEDIATELY: + this.emitDurableIncrement(nextSnapshot, change, delta); + break; + // both never and eventually emit an ephemeral increment + case CaptureUpdateAction.NEVER: + case CaptureUpdateAction.EVENTUALLY: + this.emitEphemeralIncrement(nextSnapshot, change); + break; + default: + assertNever(action, `Unknown store action`); + } + } finally { + // update the snapshot no-matter what, as it would mess up with the next action + switch (action) { + // both immediately and never update the snapshot, unlike eventually + case CaptureUpdateAction.IMMEDIATELY: + case CaptureUpdateAction.NEVER: + this.snapshot = nextSnapshot; + break; + } + } + } + + /** + * Returns the scheduled macro action. + */ + private getScheduledMacroAction() { + let scheduledAction: CaptureUpdateActionType; + + if (this.scheduledMacroActions.has(CaptureUpdateAction.IMMEDIATELY)) { + // Capture has a precedence over update, since it also performs snapshot update + scheduledAction = CaptureUpdateAction.IMMEDIATELY; + } else if (this.scheduledMacroActions.has(CaptureUpdateAction.NEVER)) { + // Update has a precedence over none, since it also emits an (ephemeral) increment + scheduledAction = CaptureUpdateAction.NEVER; + } else { + // Default is to emit ephemeral increment and don't update the snapshot + scheduledAction = CaptureUpdateAction.EVENTUALLY; + } + + return scheduledAction; + } + + /** + * Ensures that the scheduled actions invariant is satisfied. + */ + private satisfiesScheduledActionsInvariant() { + if ( + !( + this.scheduledMacroActions.size >= 0 && + this.scheduledMacroActions.size <= + Object.keys(CaptureUpdateAction).length + ) + ) { + const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledMacroActions.size}".`; + console.error(message, this.scheduledMacroActions.values()); + + if (isTestEnv() || isDevEnv()) { + throw new Error(message); + } + } + } +} + +/** + * Repsents a change to the store containing changed elements and appState. + */ +export class StoreChange { + // so figuring out what has changed should ideally be just quick reference checks + // TODO: we might need to have binary files here as well, in order to be drop-in replacement for `onChange` + private constructor( + public readonly elements: Record, + public readonly appState: Partial, + ) {} + + public static create( + prevSnapshot: StoreSnapshot, + nextSnapshot: StoreSnapshot, + ) { + const changedElements = nextSnapshot.getChangedElements(prevSnapshot); + const changedAppState = nextSnapshot.getChangedAppState(prevSnapshot); + + return new StoreChange(changedElements, changedAppState); + } +} + +/** + * Encpasulates any change to the store (durable or ephemeral). + */ +export abstract class StoreIncrement { + protected constructor( + public readonly type: "durable" | "ephemeral", + public readonly change: StoreChange, + ) {} + + public static isDurable( + increment: StoreIncrement, + ): increment is DurableIncrement { + return increment.type === "durable"; + } + + public static isEphemeral( + increment: StoreIncrement, + ): increment is EphemeralIncrement { + return increment.type === "ephemeral"; + } +} + +/** + * Represents a durable change to the store. + */ +export class DurableIncrement extends StoreIncrement { + constructor( + public readonly change: StoreChange, + public readonly delta: StoreDelta, + ) { + super("durable", change); + } +} + +/** + * Represents an ephemeral change to the store. + */ +export class EphemeralIncrement extends StoreIncrement { + constructor(public readonly change: StoreChange) { + super("ephemeral", change); + } +} + +/** + * Represents a captured delta by the Store. + */ +export class StoreDelta { + protected constructor( + public readonly id: string, + public readonly elements: ElementsDelta, + public readonly appState: AppStateDelta, + ) {} + + /** + * Create a new instance of `StoreDelta`. + */ + public static create( + elements: ElementsDelta, + appState: AppStateDelta, + opts: { + id: string; + } = { + id: randomId(), + }, + ) { + return new this(opts.id, elements, appState); + } + + /** + * Restore a store delta instance from a DTO. + */ + public static restore(storeDeltaDTO: DTO) { + const { id, elements, appState } = storeDeltaDTO; + return new this( + id, + ElementsDelta.restore(elements), + AppStateDelta.restore(appState), + ); + } + + /** + * Parse and load the delta from the remote payload. + */ + public static load({ + id, + elements: { added, removed, updated }, + }: DTO) { + const elements = ElementsDelta.create(added, removed, updated, { + shouldRedistribute: false, + }); + + return new this(id, elements, AppStateDelta.empty()); + } + + /** + * Inverse store delta, creates new instance of `StoreDelta`. + */ + public static inverse(delta: StoreDelta): StoreDelta { + return this.create(delta.elements.inverse(), delta.appState.inverse()); + } + + /** + * Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`. + */ + public static applyLatestChanges( + delta: StoreDelta, + elements: SceneElementsMap, + modifierOptions: "deleted" | "inserted", + ): StoreDelta { + return this.create( + delta.elements.applyLatestChanges(elements, modifierOptions), + delta.appState, + { + id: delta.id, + }, + ); + } + + /** + * Apply the delta to the passed elements and appState, does not modify the snapshot. + */ + public static applyTo( + delta: StoreDelta, + elements: SceneElementsMap, + appState: AppState, + prevSnapshot: StoreSnapshot = StoreSnapshot.empty(), + ): [SceneElementsMap, AppState, boolean] { + const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( + elements, + prevSnapshot.elements, + ); + + const [nextAppState, appStateContainsVisibleChange] = + delta.appState.applyTo(appState, nextElements); + + const appliedVisibleChanges = + elementsContainVisibleChange || appStateContainsVisibleChange; + + return [nextElements, nextAppState, appliedVisibleChanges]; + } + + public isEmpty() { + return this.elements.isEmpty() && this.appState.isEmpty(); + } +} + +/** + * Represents a snapshot of the captured or updated changes in the store, + * used for producing deltas and emitting `DurableStoreIncrement`s. + */ +export class StoreSnapshot { + private _lastChangedElementsHash: number = 0; + private _lastChangedAppStateHash: number = 0; + + private constructor( + public readonly elements: SceneElementsMap, + public readonly appState: ObservedAppState, + public readonly metadata: { + didElementsChange: boolean; + didAppStateChange: boolean; + isEmpty?: boolean; + } = { + didElementsChange: false, + didAppStateChange: false, + isEmpty: false, + }, + ) {} + + public static create( + elements: SceneElementsMap, + appState: AppState | ObservedAppState, + metadata: { + didElementsChange: boolean; + didAppStateChange: boolean; + } = { + didElementsChange: false, + didAppStateChange: false, + }, + ) { + return new StoreSnapshot( + elements, + isObservedAppState(appState) ? appState : getObservedAppState(appState), + metadata, + ); + } + + public static empty() { + return new StoreSnapshot( + new Map() as SceneElementsMap, + getDefaultObservedAppState(), + { + didElementsChange: false, + didAppStateChange: false, + isEmpty: true, + }, + ); + } + + public getChangedElements(prevSnapshot: StoreSnapshot) { + const changedElements: Record = {}; + + for (const prevElement of toIterable(prevSnapshot.elements)) { + const nextElement = this.elements.get(prevElement.id); + + if (!nextElement) { + changedElements[prevElement.id] = newElementWith(prevElement, { + isDeleted: true, + }); + } + } + + for (const nextElement of toIterable(this.elements)) { + // Due to the structural clone inside `maybeClone`, we can perform just these reference checks + if (prevSnapshot.elements.get(nextElement.id) !== nextElement) { + changedElements[nextElement.id] = nextElement; + } + } + + return changedElements; + } + + public getChangedAppState( + prevSnapshot: StoreSnapshot, + ): Partial { + return Delta.getRightDifferences( + prevSnapshot.appState, + this.appState, + ).reduce( + (acc, key) => + Object.assign(acc, { + [key]: this.appState[key as keyof ObservedAppState], + }), + {} as Partial, + ); + } + + public isEmpty() { + return this.metadata.isEmpty; + } + + /** + * Apply the change and return a new snapshot instance. + */ + public applyChange(change: StoreChange): StoreSnapshot { + const nextElements = new Map(this.elements) as SceneElementsMap; + + for (const [id, changedElement] of Object.entries(change.elements)) { + nextElements.set(id, changedElement); + } + + const nextAppState = Object.assign( + {}, + this.appState, + change.appState, + ) as ObservedAppState; + + return StoreSnapshot.create(nextElements, nextAppState, { + // by default we assume that change is different from what we have in the snapshot + // so that we trigger the delta calculation and if it isn't different, delta will be empty + didElementsChange: Object.keys(change.elements).length > 0, + didAppStateChange: Object.keys(change.appState).length > 0, + }); + } + + /** + * Efficiently clone the existing snapshot, only if we detected changes. + * + * @returns same instance if there are no changes detected, new instance otherwise. + */ + public maybeClone( + action: CaptureUpdateActionType, + elements: SceneElementsMap | undefined, + appState: AppState | ObservedAppState | undefined, + ) { + const options = { + shouldCompareHashes: false, + }; + + if (action === CaptureUpdateAction.EVENTUALLY) { + // actions that do not update the snapshot immediately, must be additionally checked for changes against the latest hash + // as we are always comparing against the latest snapshot, so they would emit elements or appState as changed on every component update + // instead of just the first time the elements or appState actually changed + options.shouldCompareHashes = true; + } + + const nextElementsSnapshot = this.maybeCreateElementsSnapshot( + elements, + options, + ); + const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot( + appState, + options, + ); + + let didElementsChange = false; + let didAppStateChange = false; + + if (this.elements !== nextElementsSnapshot) { + didElementsChange = true; + } + + if (this.appState !== nextAppStateSnapshot) { + didAppStateChange = true; + } + + if (!didElementsChange && !didAppStateChange) { + return this; + } + + const snapshot = new StoreSnapshot( + nextElementsSnapshot, + nextAppStateSnapshot, + { + didElementsChange, + didAppStateChange, + }, + ); + + return snapshot; + } + + private maybeCreateAppStateSnapshot( + appState: AppState | ObservedAppState | undefined, + options: { + shouldCompareHashes: boolean; + } = { + shouldCompareHashes: false, + }, + ): ObservedAppState { + if (!appState) { + return this.appState; + } + + // Not watching over everything from the app state, just the relevant props + const nextAppStateSnapshot = !isObservedAppState(appState) + ? getObservedAppState(appState) + : appState; + + const didAppStateChange = this.detectChangedAppState( + nextAppStateSnapshot, + options, + ); + + if (!didAppStateChange) { + return this.appState; + } + + return nextAppStateSnapshot; + } + + private maybeCreateElementsSnapshot( + elements: SceneElementsMap | undefined, + options: { + shouldCompareHashes: boolean; + } = { + shouldCompareHashes: false, + }, + ): SceneElementsMap { + if (!elements) { + return this.elements; + } + + const changedElements = this.detectChangedElements(elements, options); + + if (!changedElements?.size) { + return this.elements; + } + + const elementsSnapshot = this.createElementsSnapshot(changedElements); + return elementsSnapshot; + } + + private detectChangedAppState( + nextObservedAppState: ObservedAppState, + options: { + shouldCompareHashes: boolean; + } = { + shouldCompareHashes: false, + }, + ): boolean | undefined { + if (this.appState === nextObservedAppState) { + return; + } + + const didAppStateChange = Delta.isRightDifferent( + this.appState, + nextObservedAppState, + ); + + if (!didAppStateChange) { + return; + } + + const changedAppStateHash = hashString( + JSON.stringify(nextObservedAppState), + ); + + if ( + options.shouldCompareHashes && + this._lastChangedAppStateHash === changedAppStateHash + ) { + return; + } + + this._lastChangedAppStateHash = changedAppStateHash; + + return didAppStateChange; + } + + /** + * Detect if there any changed elements. + */ + private detectChangedElements( + nextElements: SceneElementsMap, + options: { + shouldCompareHashes: boolean; + } = { + shouldCompareHashes: false, + }, + ): SceneElementsMap | undefined { + if (this.elements === nextElements) { + return; + } + + const changedElements: SceneElementsMap = new Map() as SceneElementsMap; + + for (const prevElement of toIterable(this.elements)) { + const nextElement = nextElements.get(prevElement.id); + + if (!nextElement) { + // element was deleted + changedElements.set( + prevElement.id, + newElementWith(prevElement, { isDeleted: true }), + ); + } + } + + for (const nextElement of toIterable(nextElements)) { + const prevElement = this.elements.get(nextElement.id); + + if ( + !prevElement || // element was added + prevElement.version < nextElement.version // element was updated + ) { + changedElements.set(nextElement.id, nextElement); + } + } + + if (!changedElements.size) { + return; + } + + const changedElementsHash = hashElementsVersion(changedElements); + + if ( + options.shouldCompareHashes && + this._lastChangedElementsHash === changedElementsHash + ) { + return; + } + + this._lastChangedElementsHash = changedElementsHash; + + return changedElements; + } + + /** + * Perform structural clone, deep cloning only elements that changed. + */ + private createElementsSnapshot(changedElements: SceneElementsMap) { + const clonedElements = new Map() as SceneElementsMap; + + for (const prevElement of toIterable(this.elements)) { + // Clone previous elements, never delete, in case nextElements would be just a subset of previous elements + // i.e. during collab, persist or whenenever isDeleted elements get cleared + clonedElements.set(prevElement.id, prevElement); + } + + for (const changedElement of toIterable(changedElements)) { + // TODO: consider just creating new instance, once we can ensure that all reference properties on every element are immutable + // TODO: consider creating a lazy deep clone, having a one-time-usage proxy over the snapshotted element and deep cloning only if it gets mutated + clonedElements.set(changedElement.id, deepCopyElement(changedElement)); + } + + return clonedElements; + } +} + +// hidden non-enumerable property for runtime checks +const hiddenObservedAppStateProp = "__observedAppState"; + +const getDefaultObservedAppState = (): ObservedAppState => { + return { + name: null, + editingGroupId: null, + viewBackgroundColor: COLOR_PALETTE.white, + selectedElementIds: {}, + selectedGroupIds: {}, + editingLinearElementId: null, + selectedLinearElementId: null, + croppingElementId: null, + }; +}; + +export const getObservedAppState = (appState: AppState): ObservedAppState => { + const observedAppState = { + name: appState.name, + editingGroupId: appState.editingGroupId, + viewBackgroundColor: appState.viewBackgroundColor, + selectedElementIds: appState.selectedElementIds, + selectedGroupIds: appState.selectedGroupIds, + editingLinearElementId: appState.editingLinearElement?.elementId || null, + selectedLinearElementId: appState.selectedLinearElement?.elementId || null, + croppingElementId: appState.croppingElementId, + }; + + Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { + value: true, + enumerable: false, + }); + + return observedAppState; +}; + +const isObservedAppState = ( + appState: AppState | ObservedAppState, +): appState is ObservedAppState => + !!Reflect.get(appState, hiddenObservedAppStateProp); diff --git a/packages/element/tests/delta.test.tsx b/packages/element/tests/delta.test.tsx new file mode 100644 index 000000000..48e925c30 --- /dev/null +++ b/packages/element/tests/delta.test.tsx @@ -0,0 +1,143 @@ +import type { ObservedAppState } from "@excalidraw/excalidraw/types"; +import type { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; + +import { AppStateDelta } from "../src/delta"; + +describe("AppStateDelta", () => { + describe("ensure stable delta properties order", () => { + it("should maintain stable order for root properties", () => { + const name = "untitled scene"; + const selectedLinearElementId = "id1" as LinearElementEditor["elementId"]; + + const commonAppState = { + viewBackgroundColor: "#ffffff", + selectedElementIds: {}, + selectedGroupIds: {}, + editingGroupId: null, + croppingElementId: null, + editingLinearElementId: null, + }; + + const prevAppState1: ObservedAppState = { + ...commonAppState, + name: "", + selectedLinearElementId: null, + }; + + const nextAppState1: ObservedAppState = { + ...commonAppState, + name, + selectedLinearElementId, + }; + + const prevAppState2: ObservedAppState = { + selectedLinearElementId: null, + name: "", + ...commonAppState, + }; + + const nextAppState2: ObservedAppState = { + selectedLinearElementId, + name, + ...commonAppState, + }; + + const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1); + const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2); + + expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2)); + }); + + it("should maintain stable order for selectedElementIds", () => { + const commonAppState = { + name: "", + viewBackgroundColor: "#ffffff", + selectedGroupIds: {}, + editingGroupId: null, + croppingElementId: null, + selectedLinearElementId: null, + editingLinearElementId: null, + }; + + const prevAppState1: ObservedAppState = { + ...commonAppState, + selectedElementIds: { id5: true, id2: true, id4: true }, + }; + + const nextAppState1: ObservedAppState = { + ...commonAppState, + selectedElementIds: { + id1: true, + id2: true, + id3: true, + }, + }; + + const prevAppState2: ObservedAppState = { + ...commonAppState, + selectedElementIds: { id4: true, id2: true, id5: true }, + }; + + const nextAppState2: ObservedAppState = { + ...commonAppState, + selectedElementIds: { + id3: true, + id2: true, + id1: true, + }, + }; + + const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1); + const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2); + + expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2)); + }); + + it("should maintain stable order for selectedGroupIds", () => { + const commonAppState = { + name: "", + viewBackgroundColor: "#ffffff", + selectedElementIds: {}, + editingGroupId: null, + croppingElementId: null, + selectedLinearElementId: null, + editingLinearElementId: null, + }; + + const prevAppState1: ObservedAppState = { + ...commonAppState, + selectedGroupIds: { id5: false, id2: true, id4: true, id0: true }, + }; + + const nextAppState1: ObservedAppState = { + ...commonAppState, + selectedGroupIds: { + id0: true, + id1: true, + id2: false, + id3: true, + }, + }; + + const prevAppState2: ObservedAppState = { + ...commonAppState, + selectedGroupIds: { id0: true, id4: true, id2: true, id5: false }, + }; + + const nextAppState2: ObservedAppState = { + ...commonAppState, + selectedGroupIds: { + id3: true, + id2: false, + id1: true, + id0: true, + }, + }; + + const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1); + const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2); + + expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2)); + }); + }); +}); diff --git a/packages/excalidraw/actions/actionAddToLibrary.ts b/packages/excalidraw/actions/actionAddToLibrary.ts index 9216e52c2..cb45f64d6 100644 --- a/packages/excalidraw/actions/actionAddToLibrary.ts +++ b/packages/excalidraw/actions/actionAddToLibrary.ts @@ -1,8 +1,9 @@ import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common"; import { deepCopyElement } from "@excalidraw/element/duplicate"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx index 0ef938c67..918bdd8f4 100644 --- a/packages/excalidraw/actions/actionAlign.tsx +++ b/packages/excalidraw/actions/actionAlign.tsx @@ -8,6 +8,8 @@ import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { alignElements } from "@excalidraw/element/align"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { Alignment } from "@excalidraw/element/align"; @@ -25,7 +27,6 @@ import { import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index c7843656c..c740d6e90 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -33,6 +33,8 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex"; import { newElement } from "@excalidraw/element/newElement"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement, ExcalidrawLinearElement, @@ -44,8 +46,6 @@ import type { Mutable } from "@excalidraw/common/utility-types"; import type { Radians } from "@excalidraw/math"; -import { CaptureUpdateAction } from "../store"; - import { register } from "./register"; import type { AppState } from "../types"; diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index a8bd56e82..7d6bb6aad 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -17,6 +17,8 @@ import { getNonDeletedElements } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element/mutateElement"; import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import { @@ -44,7 +46,6 @@ import { t } from "../i18n"; import { getNormalizedZoom } from "../scene"; import { centerScrollOn } from "../scene/scroll"; import { getStateForZoom } from "../scene/zoom"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index 9de6d70f4..2494595a8 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -3,6 +3,8 @@ import { getTextFromElements } from "@excalidraw/element/textElement"; import { CODES, KEYS, isFirefox } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { copyTextToSystemClipboard, copyToClipboard, @@ -15,8 +17,6 @@ import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons"; import { exportCanvas, prepareElementsForExport } from "../data/index"; import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; - import { actionDeleteSelected } from "./actionDeleteSelected"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionCropEditor.tsx b/packages/excalidraw/actions/actionCropEditor.tsx index 1a7b6da69..b6e801785 100644 --- a/packages/excalidraw/actions/actionCropEditor.tsx +++ b/packages/excalidraw/actions/actionCropEditor.tsx @@ -1,11 +1,12 @@ import { isImageElement } from "@excalidraw/element/typeChecks"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawImageElement } from "@excalidraw/element/types"; import { ToolButton } from "../components/ToolButton"; import { cropIcon } from "../components/icons"; import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index e183d05f4..696563ad7 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -17,11 +17,12 @@ import { selectGroupsForSelectedElements, } from "@excalidraw/element/groups"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import { t } from "../i18n"; import { getSelectedElements, isSomeElementSelected } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { TrashIcon } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx index 9f05ab6bf..ab964d3b3 100644 --- a/packages/excalidraw/actions/actionDistribute.tsx +++ b/packages/excalidraw/actions/actionDistribute.tsx @@ -8,6 +8,8 @@ import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/fra import { distributeElements } from "@excalidraw/element/distribute"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { Distribution } from "@excalidraw/element/distribute"; @@ -21,7 +23,6 @@ import { import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index 034edf543..882f8716a 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -18,12 +18,13 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex"; import { duplicateElements } from "@excalidraw/element/duplicate"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { ToolButton } from "../components/ToolButton"; import { DuplicateIcon } from "../components/icons"; import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionElementLink.ts b/packages/excalidraw/actions/actionElementLink.ts index 24ea8bbd6..ad8d01687 100644 --- a/packages/excalidraw/actions/actionElementLink.ts +++ b/packages/excalidraw/actions/actionElementLink.ts @@ -4,11 +4,12 @@ import { getLinkIdAndTypeFromSelection, } from "@excalidraw/element/elementLink"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { copyTextToSystemClipboard } from "../clipboard"; import { copyIcon, elementLinkIcon } from "../components/icons"; import { t } from "../i18n"; import { getSelectedElements } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionElementLock.ts b/packages/excalidraw/actions/actionElementLock.ts index 6bc238a59..0e97f1955 100644 --- a/packages/excalidraw/actions/actionElementLock.ts +++ b/packages/excalidraw/actions/actionElementLock.ts @@ -4,12 +4,13 @@ import { newElementWith } from "@excalidraw/element/mutateElement"; import { isFrameLikeElement } from "@excalidraw/element/typeChecks"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import { LockedIcon, UnlockedIcon } from "../components/icons"; import { getSelectedElements } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionEmbeddable.ts b/packages/excalidraw/actions/actionEmbeddable.ts index 556652240..987b2b45a 100644 --- a/packages/excalidraw/actions/actionEmbeddable.ts +++ b/packages/excalidraw/actions/actionEmbeddable.ts @@ -1,7 +1,8 @@ import { updateActiveTool } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { setCursorForShape } from "../cursor"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index 8fcaea21b..f8a9dca82 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -7,6 +7,8 @@ import { import { getNonDeletedElements } from "@excalidraw/element"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { Theme } from "@excalidraw/element/types"; import { useDevice } from "../components/App"; @@ -24,7 +26,6 @@ import { resaveAsImageWithScene } from "../data/resave"; import { t } from "../i18n"; import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getExportSize } from "../scene/export"; -import { CaptureUpdateAction } from "../store"; import "../components/ToolIcon.scss"; diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 22638ee91..6ddb8ab52 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -16,11 +16,12 @@ import { isPathALoop } from "@excalidraw/element/shapes"; import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { t } from "../i18n"; import { resetCursor } from "../cursor"; import { done } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index becc8a976..f6c4f0c71 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -15,6 +15,8 @@ import { import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame"; import { CODES, KEYS, arrayToMap } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawArrowElement, ExcalidrawElbowArrowElement, @@ -24,7 +26,6 @@ import type { } from "@excalidraw/element/types"; import { getSelectedElements } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { flipHorizontal, flipVertical } from "../components/icons"; diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts index 7882d26f6..f5e91fd93 100644 --- a/packages/excalidraw/actions/actionFrame.ts +++ b/packages/excalidraw/actions/actionFrame.ts @@ -14,12 +14,13 @@ import { getElementsInGroup } from "@excalidraw/element/groups"; import { getCommonBounds } from "@excalidraw/element/bounds"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import { setCursorForShape } from "../cursor"; import { frameToolIcon } from "../components/icons"; import { getSelectedElements } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx index 6b47ef969..de3f6b266 100644 --- a/packages/excalidraw/actions/actionGroup.tsx +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -28,6 +28,8 @@ import { import { syncMovedIndices } from "@excalidraw/element/fractionalIndex"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement, ExcalidrawTextElement, @@ -40,7 +42,6 @@ import { UngroupIcon, GroupIcon } from "../components/icons"; import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index a0dfb85df..6477f795f 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -1,5 +1,9 @@ import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + +import { orderByFractionalIndex } from "@excalidraw/element/fractionalIndex"; + import type { SceneElementsMap } from "@excalidraw/element/types"; import { ToolButton } from "../components/ToolButton"; @@ -7,10 +11,8 @@ import { UndoIcon, RedoIcon } from "../components/icons"; import { HistoryChangedEvent } from "../history"; import { useEmitter } from "../hooks/useEmitter"; import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; import type { History } from "../history"; -import type { Store } from "../store"; import type { AppClassProperties, AppState } from "../types"; import type { Action, ActionResult } from "./types"; @@ -35,7 +37,11 @@ const executeHistoryAction = ( } const [nextElementsMap, nextAppState] = result; - const nextElements = Array.from(nextElementsMap.values()); + + // order by fractional indices in case the map was accidently modified in the meantime + const nextElements = orderByFractionalIndex( + Array.from(nextElementsMap.values()), + ); return { appState: nextAppState, @@ -47,9 +53,9 @@ const executeHistoryAction = ( return { captureUpdate: CaptureUpdateAction.EVENTUALLY }; }; -type ActionCreator = (history: History, store: Store) => Action; +type ActionCreator = (history: History) => Action; -export const createUndoAction: ActionCreator = (history, store) => ({ +export const createUndoAction: ActionCreator = (history) => ({ name: "undo", label: "buttons.undo", icon: UndoIcon, @@ -57,11 +63,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({ viewMode: false, perform: (elements, appState, value, app) => executeHistoryAction(app, appState, () => - history.undo( - arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` - appState, - store.snapshot, - ), + history.undo(arrayToMap(elements) as SceneElementsMap, appState), ), keyTest: (event) => event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey, @@ -88,19 +90,15 @@ export const createUndoAction: ActionCreator = (history, store) => ({ }, }); -export const createRedoAction: ActionCreator = (history, store) => ({ +export const createRedoAction: ActionCreator = (history) => ({ name: "redo", label: "buttons.redo", icon: RedoIcon, trackEvent: { category: "history" }, viewMode: false, - perform: (elements, appState, _, app) => + perform: (elements, appState, __, app) => executeHistoryAction(app, appState, () => - history.redo( - arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` - appState, - store.snapshot, - ), + history.redo(arrayToMap(elements) as SceneElementsMap, appState), ), keyTest: (event) => (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx index 1645554bf..1b122187f 100644 --- a/packages/excalidraw/actions/actionLinearEditor.tsx +++ b/packages/excalidraw/actions/actionLinearEditor.tsx @@ -4,6 +4,8 @@ import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks"; import { arrayToMap } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawLinearElement } from "@excalidraw/element/types"; import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; @@ -11,7 +13,6 @@ import { ToolButton } from "../components/ToolButton"; import { lineEditorIcon } from "../components/icons"; import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionLink.tsx b/packages/excalidraw/actions/actionLink.tsx index 71426267d..d7a5ca7d2 100644 --- a/packages/excalidraw/actions/actionLink.tsx +++ b/packages/excalidraw/actions/actionLink.tsx @@ -2,13 +2,14 @@ import { isEmbeddableElement } from "@excalidraw/element/typeChecks"; import { KEYS, getShortcutKey } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { ToolButton } from "../components/ToolButton"; import { getContextMenuLabel } from "../components/hyperlink/Hyperlink"; import { LinkIcon } from "../components/icons"; import { t } from "../i18n"; import { getSelectedElements } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionMenu.tsx b/packages/excalidraw/actions/actionMenu.tsx index 67863e020..8cdc489b4 100644 --- a/packages/excalidraw/actions/actionMenu.tsx +++ b/packages/excalidraw/actions/actionMenu.tsx @@ -4,12 +4,12 @@ import { getNonDeletedElements } from "@excalidraw/element"; import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { ToolButton } from "../components/ToolButton"; import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons"; import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; - import { register } from "./register"; export const actionToggleCanvasMenu = register({ diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index 738386839..637df0450 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -1,5 +1,7 @@ import clsx from "clsx"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { getClientColor } from "../clients"; import { Avatar } from "../components/Avatar"; import { @@ -8,7 +10,6 @@ import { microphoneMutedIcon, } from "../components/icons"; import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index df07960af..8f1bfead7 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -54,6 +54,8 @@ import { hasStrokeColor } from "@excalidraw/element/comparisons"; import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { LocalPoint } from "@excalidraw/math"; import type { @@ -70,6 +72,8 @@ import type { import type Scene from "@excalidraw/element/Scene"; +import type { CaptureUpdateActionType } from "@excalidraw/element/store"; + import { trackEvent } from "../analytics"; import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ColorPicker } from "../components/ColorPicker/ColorPicker"; @@ -131,11 +135,9 @@ import { getTargetElements, isSomeElementSelected, } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; -import type { CaptureUpdateActionType } from "../store"; import type { AppClassProperties, AppState, Primitive } from "../types"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; diff --git a/packages/excalidraw/actions/actionSelectAll.ts b/packages/excalidraw/actions/actionSelectAll.ts index ea13636b7..9386bc9a1 100644 --- a/packages/excalidraw/actions/actionSelectAll.ts +++ b/packages/excalidraw/actions/actionSelectAll.ts @@ -6,9 +6,9 @@ import { arrayToMap, KEYS } from "@excalidraw/common"; import { selectGroupsForSelectedElements } from "@excalidraw/element/groups"; -import type { ExcalidrawElement } from "@excalidraw/element/types"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; -import { CaptureUpdateAction } from "../store"; +import type { ExcalidrawElement } from "@excalidraw/element/types"; import { selectAllIcon } from "../components/icons"; diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts index 08b32e227..f80a56990 100644 --- a/packages/excalidraw/actions/actionStyles.ts +++ b/packages/excalidraw/actions/actionStyles.ts @@ -24,13 +24,14 @@ import { redrawTextBoundingBox, } from "@excalidraw/element/textElement"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawTextElement } from "@excalidraw/element/types"; import { paintIcon } from "../components/icons"; import { t } from "../i18n"; import { getSelectedElements } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionTextAutoResize.ts b/packages/excalidraw/actions/actionTextAutoResize.ts index 4a36cab40..15af36658 100644 --- a/packages/excalidraw/actions/actionTextAutoResize.ts +++ b/packages/excalidraw/actions/actionTextAutoResize.ts @@ -5,8 +5,9 @@ import { measureText } from "@excalidraw/element/textMeasurements"; import { isTextElement } from "@excalidraw/element/typeChecks"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { getSelectedElements } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionToggleGridMode.tsx b/packages/excalidraw/actions/actionToggleGridMode.tsx index 9415051f3..543485d8a 100644 --- a/packages/excalidraw/actions/actionToggleGridMode.tsx +++ b/packages/excalidraw/actions/actionToggleGridMode.tsx @@ -1,7 +1,8 @@ import { CODES, KEYS } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { gridIcon } from "../components/icons"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx index ba092bff8..1eef483aa 100644 --- a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx +++ b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx @@ -1,7 +1,8 @@ import { CODES, KEYS } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { magnetIcon } from "../components/icons"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionToggleSearchMenu.ts b/packages/excalidraw/actions/actionToggleSearchMenu.ts index ce384fc66..b7821bce4 100644 --- a/packages/excalidraw/actions/actionToggleSearchMenu.ts +++ b/packages/excalidraw/actions/actionToggleSearchMenu.ts @@ -5,8 +5,9 @@ import { DEFAULT_SIDEBAR, } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { searchIcon } from "../components/icons"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionToggleShapeSwitch.tsx b/packages/excalidraw/actions/actionToggleShapeSwitch.tsx index 39e7566fb..aea8f986e 100644 --- a/packages/excalidraw/actions/actionToggleShapeSwitch.tsx +++ b/packages/excalidraw/actions/actionToggleShapeSwitch.tsx @@ -1,3 +1,5 @@ +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import { @@ -5,7 +7,6 @@ import { convertElementTypePopupAtom, } from "../components/ConvertElementTypePopup"; import { editorJotaiStore } from "../editor-jotai"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionToggleStats.tsx b/packages/excalidraw/actions/actionToggleStats.tsx index ffa812e96..d044c01fb 100644 --- a/packages/excalidraw/actions/actionToggleStats.tsx +++ b/packages/excalidraw/actions/actionToggleStats.tsx @@ -1,7 +1,8 @@ import { CODES, KEYS } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { abacusIcon } from "../components/icons"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionToggleViewMode.tsx b/packages/excalidraw/actions/actionToggleViewMode.tsx index e42a7a102..f511ec619 100644 --- a/packages/excalidraw/actions/actionToggleViewMode.tsx +++ b/packages/excalidraw/actions/actionToggleViewMode.tsx @@ -1,7 +1,8 @@ import { CODES, KEYS } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { eyeIcon } from "../components/icons"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionToggleZenMode.tsx b/packages/excalidraw/actions/actionToggleZenMode.tsx index e56e02ca7..a9dc8dd1f 100644 --- a/packages/excalidraw/actions/actionToggleZenMode.tsx +++ b/packages/excalidraw/actions/actionToggleZenMode.tsx @@ -1,7 +1,8 @@ import { CODES, KEYS } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { coffeeIcon } from "../components/icons"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionZindex.tsx b/packages/excalidraw/actions/actionZindex.tsx index 8eb5a50f2..753e42321 100644 --- a/packages/excalidraw/actions/actionZindex.tsx +++ b/packages/excalidraw/actions/actionZindex.tsx @@ -7,6 +7,8 @@ import { moveAllRight, } from "@excalidraw/element/zindex"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { BringForwardIcon, BringToFrontIcon, @@ -14,7 +16,6 @@ import { SendToBackIcon, } from "../components/icons"; import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index c4a4d2cce..a857be2f1 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -3,7 +3,8 @@ import type { OrderedExcalidrawElement, } from "@excalidraw/element/types"; -import type { CaptureUpdateActionType } from "../store"; +import type { CaptureUpdateActionType } from "@excalidraw/element/store"; + import type { AppClassProperties, AppState, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index ddb071981..d94d39e77 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -101,6 +101,7 @@ import { type EXPORT_IMAGE_TYPES, randomInteger, CLASSES, + Emitter, } from "@excalidraw/common"; import { @@ -303,6 +304,8 @@ import { isNonDeletedElement } from "@excalidraw/element"; import Scene from "@excalidraw/element/Scene"; +import { Store, CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ElementUpdate } from "@excalidraw/element/mutateElement"; import type { LocalPoint, Radians } from "@excalidraw/math"; @@ -331,6 +334,7 @@ import type { ExcalidrawNonSelectionElement, ExcalidrawArrowElement, ExcalidrawElbowArrowElement, + SceneElementsMap, } from "@excalidraw/element/types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; @@ -454,9 +458,7 @@ import { resetCursor, setCursorForShape, } from "../cursor"; -import { Emitter } from "../emitter"; import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; -import { Store, CaptureUpdateAction } from "../store"; import { LaserTrails } from "../laser-trails"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { textWysiwyg } from "../wysiwyg/textWysiwyg"; @@ -761,8 +763,8 @@ class App extends React.Component { this.renderer = new Renderer(this.scene); this.visibleElements = []; - this.store = new Store(); - this.history = new History(); + this.store = new Store(this); + this.history = new History(this.store); if (excalidrawAPI) { const api: ExcalidrawImperativeAPI = { @@ -792,6 +794,7 @@ class App extends React.Component { updateFrameRendering: this.updateFrameRendering, toggleSidebar: this.toggleSidebar, onChange: (cb) => this.onChangeEmitter.on(cb), + onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb), onPointerDown: (cb) => this.onPointerDownEmitter.on(cb), onPointerUp: (cb) => this.onPointerUpEmitter.on(cb), onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb), @@ -810,15 +813,11 @@ class App extends React.Component { }; this.fonts = new Fonts(this.scene); - this.history = new History(); + this.history = new History(this.store); this.actionManager.registerAll(actions); - this.actionManager.registerAction( - createUndoAction(this.history, this.store), - ); - this.actionManager.registerAction( - createRedoAction(this.history, this.store), - ); + this.actionManager.registerAction(createUndoAction(this.history)); + this.actionManager.registerAction(createRedoAction(this.history)); } updateEditorAtom = ( @@ -1899,6 +1898,10 @@ class App extends React.Component { return this.scene.getElementsIncludingDeleted(); }; + public getSceneElementsMapIncludingDeleted = () => { + return this.scene.getElementsMapIncludingDeleted(); + }; + public getSceneElements = () => { return this.scene.getNonDeletedElements(); }; @@ -2215,11 +2218,7 @@ class App extends React.Component { return; } - if (actionResult.captureUpdate === CaptureUpdateAction.NEVER) { - this.store.shouldUpdateSnapshot(); - } else if (actionResult.captureUpdate === CaptureUpdateAction.IMMEDIATELY) { - this.store.shouldCaptureIncrement(); - } + this.store.scheduleAction(actionResult.captureUpdate); let didUpdate = false; @@ -2292,10 +2291,7 @@ class App extends React.Component { didUpdate = true; } - if ( - !didUpdate && - actionResult.captureUpdate !== CaptureUpdateAction.EVENTUALLY - ) { + if (!didUpdate) { this.scene.triggerUpdate(); } }); @@ -2547,10 +2543,19 @@ class App extends React.Component { }); } - this.store.onStoreIncrementEmitter.on((increment) => { - this.history.record(increment.elementsChange, increment.appStateChange); + this.store.onDurableIncrementEmitter.on((increment) => { + this.history.record(increment.delta); }); + const { onIncrement } = this.props; + + // per. optimmisation, only subscribe if there is the `onIncrement` prop registered, to avoid unnecessary computation + if (onIncrement) { + this.store.onStoreIncrementEmitter.on((increment) => { + onIncrement(increment); + }); + } + this.scene.onUpdate(this.triggerRender); this.addEventListeners(); @@ -2610,6 +2615,7 @@ class App extends React.Component { this.eraserTrail.stop(); this.onChangeEmitter.clear(); this.store.onStoreIncrementEmitter.clear(); + this.store.onDurableIncrementEmitter.clear(); ShapeCache.destroy(); SnapCache.destroy(); clearTimeout(touchTimeout); @@ -2903,7 +2909,7 @@ class App extends React.Component { this.state.editingLinearElement && !this.state.selectedElementIds[this.state.editingLinearElement.elementId] ) { - // defer so that the shouldCaptureIncrement flag isn't reset via current update + // defer so that the scheduleCapture flag isn't reset via current update setTimeout(() => { // execute only if the condition still holds when the deferred callback // executes (it can be scheduled multiple times depending on how @@ -3358,7 +3364,7 @@ class App extends React.Component { this.addMissingFiles(opts.files); } - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); const nextElementsToSelect = excludeElementsInFramesFromSelection(duplicatedElements); @@ -3619,7 +3625,7 @@ class App extends React.Component { PLAIN_PASTE_TOAST_SHOWN = true; } - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); } setAppState: React.Component["setState"] = ( @@ -3975,51 +3981,37 @@ class App extends React.Component { */ captureUpdate?: SceneData["captureUpdate"]; }) => { - const nextElements = syncInvalidIndices(sceneData.elements ?? []); + const { elements, appState, collaborators, captureUpdate } = sceneData; - if ( - sceneData.captureUpdate && - sceneData.captureUpdate !== CaptureUpdateAction.EVENTUALLY - ) { - const prevCommittedAppState = this.store.snapshot.appState; - const prevCommittedElements = this.store.snapshot.elements; + const nextElements = elements ? syncInvalidIndices(elements) : undefined; - const nextCommittedAppState = sceneData.appState - ? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState` - : prevCommittedAppState; + if (captureUpdate) { + const nextElementsMap = elements + ? (arrayToMap(nextElements ?? []) as SceneElementsMap) + : undefined; - const nextCommittedElements = sceneData.elements - ? this.store.filterUncomittedElements( - this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements - arrayToMap(nextElements), // We expect all (already reconciled) elements - ) - : prevCommittedElements; + const nextAppState = appState + ? // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState` + Object.assign({}, this.store.snapshot.appState, appState) + : undefined; - // WARN: store action always performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter - // do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well - if (sceneData.captureUpdate === CaptureUpdateAction.IMMEDIATELY) { - this.store.captureIncrement( - nextCommittedElements, - nextCommittedAppState, - ); - } else if (sceneData.captureUpdate === CaptureUpdateAction.NEVER) { - this.store.updateSnapshot( - nextCommittedElements, - nextCommittedAppState, - ); - } + this.store.scheduleMicroAction({ + action: captureUpdate, + elements: nextElementsMap, + appState: nextAppState, + }); } - if (sceneData.appState) { - this.setState(sceneData.appState); + if (appState) { + this.setState(appState); } - if (sceneData.elements) { + if (nextElements) { this.scene.replaceAllElements(nextElements); } - if (sceneData.collaborators) { - this.setState({ collaborators: sceneData.collaborators }); + if (collaborators) { + this.setState({ collaborators }); } }, ); @@ -4202,7 +4194,7 @@ class App extends React.Component { direction: event.shiftKey ? "left" : "right", }) ) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); } } if (conversionType) { @@ -4519,7 +4511,7 @@ class App extends React.Component { this.state.editingLinearElement.elementId !== selectedElements[0].id ) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); if (!isElbowArrow(selectedElement)) { this.setState({ editingLinearElement: new LinearElementEditor( @@ -4845,7 +4837,7 @@ class App extends React.Component { } as const; if (nextActiveTool.type === "freedraw") { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); } if (nextActiveTool.type === "lasso") { @@ -5062,7 +5054,7 @@ class App extends React.Component { ]); } if (!isDeleted || isExistingElement) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); } flushSync(() => { @@ -5475,7 +5467,7 @@ class App extends React.Component { }; private startImageCropping = (image: ExcalidrawImageElement) => { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); this.setState({ croppingElementId: image.id, }); @@ -5483,7 +5475,7 @@ class App extends React.Component { private finishImageCropping = () => { if (this.state.croppingElementId) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); this.setState({ croppingElementId: null, }); @@ -5518,7 +5510,7 @@ class App extends React.Component { selectedElements[0].id) && !isElbowArrow(selectedElements[0]) ) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); this.setState({ editingLinearElement: new LinearElementEditor( selectedElements[0], @@ -5546,7 +5538,7 @@ class App extends React.Component { : -1; if (midPoint && midPoint > -1) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); LinearElementEditor.deleteFixedSegment( selectedElements[0], this.scene, @@ -5608,7 +5600,7 @@ class App extends React.Component { getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds); if (selectedGroupId) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); this.setState((prevState) => ({ ...prevState, ...selectGroupsForSelectedElements( @@ -9131,7 +9123,7 @@ class App extends React.Component { if (isLinearElement(newElement)) { if (newElement!.points.length > 1) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); } const pointerCoords = viewportCoordsToSceneCoords( childEvent, @@ -9404,7 +9396,7 @@ class App extends React.Component { } if (resizingElement) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); } if (resizingElement && isInvisiblySmallElement(resizingElement)) { @@ -9744,7 +9736,7 @@ class App extends React.Component { this.state.selectedElementIds, ) ) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); } if ( @@ -9837,7 +9829,7 @@ class App extends React.Component { this.elementsPendingErasure = new Set(); if (didChange) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); this.scene.replaceAllElements(elements); } }; @@ -10517,8 +10509,13 @@ class App extends React.Component { // restore the fractional indices by mutating elements syncInvalidIndices(elements.concat(ret.data.elements)); - // update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo - this.store.updateSnapshot(arrayToMap(elements), this.state); + // don't capture and only update the store snapshot for old elements, + // otherwise we would end up with duplicated fractional indices on undo + this.store.scheduleMicroAction({ + action: CaptureUpdateAction.NEVER, + elements: arrayToMap(elements) as SceneElementsMap, + appState: undefined, + }); this.setState({ isLoading: true }); this.syncActionResult({ diff --git a/packages/excalidraw/components/Stats/DragInput.tsx b/packages/excalidraw/components/Stats/DragInput.tsx index 6fdf909b2..208b48f6c 100644 --- a/packages/excalidraw/components/Stats/DragInput.tsx +++ b/packages/excalidraw/components/Stats/DragInput.tsx @@ -5,11 +5,12 @@ import { EVENT, KEYS, cloneJSON } from "@excalidraw/common"; import { deepCopyElement } from "@excalidraw/element/duplicate"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type Scene from "@excalidraw/element/Scene"; -import { CaptureUpdateAction } from "../../store"; import { useApp } from "../App"; import { InlineIcon } from "../InlineIcon"; diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index 74252657e..5a9b7fc15 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -14,6 +14,7 @@ import { resolvablePromise, toValidURL, Queue, + Emitter, } from "@excalidraw/common"; import { hashElementsVersion, hashString } from "@excalidraw/element"; @@ -26,7 +27,6 @@ import type { MaybePromise } from "@excalidraw/common/utility-types"; import { atom, editorJotaiStore } from "../editor-jotai"; -import { Emitter } from "../emitter"; import { AbortError } from "../errors"; import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg"; import { t } from "../i18n"; diff --git a/packages/excalidraw/history.ts b/packages/excalidraw/history.ts index 0481c8411..f3022fd41 100644 --- a/packages/excalidraw/history.ts +++ b/packages/excalidraw/history.ts @@ -1,12 +1,17 @@ +import { Emitter } from "@excalidraw/common"; + +import { + CaptureUpdateAction, + StoreChange, + StoreDelta, + type Store, +} from "@excalidraw/element/store"; + import type { SceneElementsMap } from "@excalidraw/element/types"; -import { Emitter } from "./emitter"; - -import type { AppStateChange, ElementsChange } from "./change"; -import type { Snapshot } from "./store"; import type { AppState } from "./types"; -type HistoryStack = HistoryEntry[]; +class HistoryEntry extends StoreDelta {} export class HistoryChangedEvent { constructor( @@ -20,8 +25,8 @@ export class History { [HistoryChangedEvent] >(); - private readonly undoStack: HistoryStack = []; - private readonly redoStack: HistoryStack = []; + public readonly undoStack: HistoryEntry[] = []; + public readonly redoStack: HistoryEntry[] = []; public get isUndoStackEmpty() { return this.undoStack.length === 0; @@ -31,60 +36,52 @@ export class History { return this.redoStack.length === 0; } + constructor(private readonly store: Store) {} + public clear() { this.undoStack.length = 0; this.redoStack.length = 0; } /** - * Record a local change which will go into the history + * Record a non-empty local durable increment, which will go into the undo stack.. + * Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action. */ - public record( - elementsChange: ElementsChange, - appStateChange: AppStateChange, - ) { - const entry = HistoryEntry.create(appStateChange, elementsChange); - - if (!entry.isEmpty()) { - // we have the latest changes, no need to `applyLatest`, which is done within `History.push` - this.undoStack.push(entry.inverse()); - - if (!entry.elementsChange.isEmpty()) { - // don't reset redo stack on local appState changes, - // as a simple click (unselect) could lead to losing all the redo entries - // only reset on non empty elements changes! - this.redoStack.length = 0; - } - - this.onHistoryChangedEmitter.trigger( - new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty), - ); + public record(delta: StoreDelta) { + if (delta.isEmpty() || delta instanceof HistoryEntry) { + return; } + + // construct history entry, so once it's emitted, it's not recorded again + const entry = HistoryEntry.inverse(delta); + + this.undoStack.push(entry); + + if (!entry.elements.isEmpty()) { + // don't reset redo stack on local appState changes, + // as a simple click (unselect) could lead to losing all the redo entries + // only reset on non empty elements changes! + this.redoStack.length = 0; + } + + this.onHistoryChangedEmitter.trigger( + new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty), + ); } - public undo( - elements: SceneElementsMap, - appState: AppState, - snapshot: Readonly, - ) { + public undo(elements: SceneElementsMap, appState: AppState) { return this.perform( elements, appState, - snapshot, () => History.pop(this.undoStack), (entry: HistoryEntry) => History.push(this.redoStack, entry, elements), ); } - public redo( - elements: SceneElementsMap, - appState: AppState, - snapshot: Readonly, - ) { + public redo(elements: SceneElementsMap, appState: AppState) { return this.perform( elements, appState, - snapshot, () => History.pop(this.redoStack), (entry: HistoryEntry) => History.push(this.undoStack, entry, elements), ); @@ -93,7 +90,6 @@ export class History { private perform( elements: SceneElementsMap, appState: AppState, - snapshot: Readonly, pop: () => HistoryEntry | null, push: (entry: HistoryEntry) => void, ): [SceneElementsMap, AppState] | void { @@ -104,6 +100,10 @@ export class History { return; } + const action = CaptureUpdateAction.IMMEDIATELY; + + let prevSnapshot = this.store.snapshot; + let nextElements = elements; let nextAppState = appState; let containsVisibleChange = false; @@ -112,9 +112,29 @@ export class History { while (historyEntry) { try { [nextElements, nextAppState, containsVisibleChange] = - historyEntry.applyTo(nextElements, nextAppState, snapshot); + StoreDelta.applyTo( + historyEntry, + nextElements, + nextAppState, + prevSnapshot, + ); + + const nextSnapshot = prevSnapshot.maybeClone( + action, + nextElements, + nextAppState, + ); + + // schedule immediate capture, so that it's emitted for the sync purposes + this.store.scheduleMicroAction({ + action, + change: StoreChange.create(prevSnapshot, nextSnapshot), + delta: historyEntry, + }); + + prevSnapshot = nextSnapshot; } finally { - // make sure to always push / pop, even if the increment is corrupted + // make sure to always push, even if the delta is corrupted push(historyEntry); } @@ -135,7 +155,7 @@ export class History { } } - private static pop(stack: HistoryStack): HistoryEntry | null { + private static pop(stack: HistoryEntry[]): HistoryEntry | null { if (!stack.length) { return null; } @@ -150,63 +170,17 @@ export class History { } private static push( - stack: HistoryStack, + stack: HistoryEntry[], entry: HistoryEntry, prevElements: SceneElementsMap, ) { - const updatedEntry = entry.inverse().applyLatestChanges(prevElements); + const inversedEntry = HistoryEntry.inverse(entry); + const updatedEntry = HistoryEntry.applyLatestChanges( + inversedEntry, + prevElements, + "inserted", + ); + return stack.push(updatedEntry); } } - -export class HistoryEntry { - private constructor( - public readonly appStateChange: AppStateChange, - public readonly elementsChange: ElementsChange, - ) {} - - public static create( - appStateChange: AppStateChange, - elementsChange: ElementsChange, - ) { - return new HistoryEntry(appStateChange, elementsChange); - } - - public inverse(): HistoryEntry { - return new HistoryEntry( - this.appStateChange.inverse(), - this.elementsChange.inverse(), - ); - } - - public applyTo( - elements: SceneElementsMap, - appState: AppState, - snapshot: Readonly, - ): [SceneElementsMap, AppState, boolean] { - const [nextElements, elementsContainVisibleChange] = - this.elementsChange.applyTo(elements, snapshot.elements); - - const [nextAppState, appStateContainsVisibleChange] = - this.appStateChange.applyTo(appState, nextElements); - - const appliedVisibleChanges = - elementsContainVisibleChange || appStateContainsVisibleChange; - - return [nextElements, nextAppState, appliedVisibleChanges]; - } - - /** - * Apply latest (remote) changes to the history entry, creates new instance of `HistoryEntry`. - */ - public applyLatestChanges(elements: SceneElementsMap): HistoryEntry { - const updatedElementsChange = - this.elementsChange.applyLatestChanges(elements); - - return HistoryEntry.create(this.appStateChange, updatedElementsChange); - } - - public isEmpty(): boolean { - return this.appStateChange.isEmpty() && this.elementsChange.isEmpty(); - } -} diff --git a/packages/excalidraw/hooks/useEmitter.ts b/packages/excalidraw/hooks/useEmitter.ts index eebbaaf30..3ecb24796 100644 --- a/packages/excalidraw/hooks/useEmitter.ts +++ b/packages/excalidraw/hooks/useEmitter.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import type { Emitter } from "../emitter"; +import type { Emitter } from "@excalidraw/common"; export const useEmitter = ( emitter: Emitter<[TEvent]>, diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 17c59a1b5..07fbdfdbb 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -23,6 +23,7 @@ polyfill(); const ExcalidrawBase = (props: ExcalidrawProps) => { const { onChange, + onIncrement, initialData, excalidrawAPI, isCollaborating = false, @@ -114,6 +115,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {