@oh-just-another/state 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/actions/actionClipboard.d.ts +6 -0
- package/dist/actions/actionClipboard.d.ts.map +1 -0
- package/dist/actions/actionClipboard.js +26 -0
- package/dist/actions/actionClipboard.js.map +1 -0
- package/dist/actions/actionGrouping.d.ts +5 -0
- package/dist/actions/actionGrouping.d.ts.map +1 -0
- package/dist/actions/actionGrouping.js +26 -0
- package/dist/actions/actionGrouping.js.map +1 -0
- package/dist/actions/actionHistory.d.ts +5 -0
- package/dist/actions/actionHistory.d.ts.map +1 -0
- package/dist/actions/actionHistory.js +25 -0
- package/dist/actions/actionHistory.js.map +1 -0
- package/dist/actions/actionKeyboard.d.ts +3 -0
- package/dist/actions/actionKeyboard.d.ts.map +1 -0
- package/dist/actions/actionKeyboard.js +130 -0
- package/dist/actions/actionKeyboard.js.map +1 -0
- package/dist/actions/actionLayout.d.ts +3 -0
- package/dist/actions/actionLayout.d.ts.map +1 -0
- package/dist/actions/actionLayout.js +65 -0
- package/dist/actions/actionLayout.js.map +1 -0
- package/dist/actions/actionMode.d.ts +13 -0
- package/dist/actions/actionMode.d.ts.map +1 -0
- package/dist/actions/actionMode.js +116 -0
- package/dist/actions/actionMode.js.map +1 -0
- package/dist/actions/actionSelection.d.ts +9 -0
- package/dist/actions/actionSelection.d.ts.map +1 -0
- package/dist/actions/actionSelection.js +57 -0
- package/dist/actions/actionSelection.js.map +1 -0
- package/dist/actions/actionView.d.ts +7 -0
- package/dist/actions/actionView.d.ts.map +1 -0
- package/dist/actions/actionView.js +14 -0
- package/dist/actions/actionView.js.map +1 -0
- package/dist/actions/actionZOrder.d.ts +7 -0
- package/dist/actions/actionZOrder.d.ts.map +1 -0
- package/dist/actions/actionZOrder.js +42 -0
- package/dist/actions/actionZOrder.js.map +1 -0
- package/dist/actions/actionZoom.d.ts +8 -0
- package/dist/actions/actionZoom.d.ts.map +1 -0
- package/dist/actions/actionZoom.js +61 -0
- package/dist/actions/actionZoom.js.map +1 -0
- package/dist/actions/index.d.ts +12 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/actions/index.js +11 -0
- package/dist/actions/index.js.map +1 -0
- package/dist/actions/registry.d.ts +68 -0
- package/dist/actions/registry.d.ts.map +1 -0
- package/dist/actions/registry.js +300 -0
- package/dist/actions/registry.js.map +1 -0
- package/dist/actions/types.d.ts +103 -0
- package/dist/actions/types.d.ts.map +1 -0
- package/dist/actions/types.js +10 -0
- package/dist/actions/types.js.map +1 -0
- package/dist/animation-tick.d.ts +28 -0
- package/dist/animation-tick.d.ts.map +1 -0
- package/dist/animation-tick.js +50 -0
- package/dist/animation-tick.js.map +1 -0
- package/dist/auto-compact.d.ts +27 -0
- package/dist/auto-compact.d.ts.map +1 -0
- package/dist/auto-compact.js +46 -0
- package/dist/auto-compact.js.map +1 -0
- package/dist/auto-layout-scheduler.d.ts +50 -0
- package/dist/auto-layout-scheduler.d.ts.map +1 -0
- package/dist/auto-layout-scheduler.js +94 -0
- package/dist/auto-layout-scheduler.js.map +1 -0
- package/dist/built-in-handlers.d.ts +24 -0
- package/dist/built-in-handlers.d.ts.map +1 -0
- package/dist/built-in-handlers.js +210 -0
- package/dist/built-in-handlers.js.map +1 -0
- package/dist/clipboard.d.ts +51 -0
- package/dist/clipboard.d.ts.map +1 -0
- package/dist/clipboard.js +105 -0
- package/dist/clipboard.js.map +1 -0
- package/dist/constants.d.ts +544 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +537 -0
- package/dist/constants.js.map +1 -0
- package/dist/dom-events.d.ts +13 -0
- package/dist/dom-events.d.ts.map +1 -0
- package/dist/dom-events.js +70 -0
- package/dist/dom-events.js.map +1 -0
- package/dist/editor/anchor-points.d.ts +22 -0
- package/dist/editor/anchor-points.d.ts.map +1 -0
- package/dist/editor/anchor-points.js +28 -0
- package/dist/editor/anchor-points.js.map +1 -0
- package/dist/editor/applies/create.d.ts +60 -0
- package/dist/editor/applies/create.d.ts.map +1 -0
- package/dist/editor/applies/create.js +107 -0
- package/dist/editor/applies/create.js.map +1 -0
- package/dist/editor/applies/edge.d.ts +32 -0
- package/dist/editor/applies/edge.d.ts.map +1 -0
- package/dist/editor/applies/edge.js +44 -0
- package/dist/editor/applies/edge.js.map +1 -0
- package/dist/editor/applies/link-move.d.ts +53 -0
- package/dist/editor/applies/link-move.d.ts.map +1 -0
- package/dist/editor/applies/link-move.js +160 -0
- package/dist/editor/applies/link-move.js.map +1 -0
- package/dist/editor/applies/move.d.ts +38 -0
- package/dist/editor/applies/move.d.ts.map +1 -0
- package/dist/editor/applies/move.js +83 -0
- package/dist/editor/applies/move.js.map +1 -0
- package/dist/editor/applies/resize.d.ts +68 -0
- package/dist/editor/applies/resize.d.ts.map +1 -0
- package/dist/editor/applies/resize.js +199 -0
- package/dist/editor/applies/resize.js.map +1 -0
- package/dist/editor/applies/selection.d.ts +34 -0
- package/dist/editor/applies/selection.d.ts.map +1 -0
- package/dist/editor/applies/selection.js +65 -0
- package/dist/editor/applies/selection.js.map +1 -0
- package/dist/editor/applies/snap-grid.d.ts +45 -0
- package/dist/editor/applies/snap-grid.d.ts.map +1 -0
- package/dist/editor/applies/snap-grid.js +90 -0
- package/dist/editor/applies/snap-grid.js.map +1 -0
- package/dist/editor/container-ops.d.ts +74 -0
- package/dist/editor/container-ops.d.ts.map +1 -0
- package/dist/editor/container-ops.js +262 -0
- package/dist/editor/container-ops.js.map +1 -0
- package/dist/editor/event-fanout.d.ts +48 -0
- package/dist/editor/event-fanout.d.ts.map +1 -0
- package/dist/editor/event-fanout.js +53 -0
- package/dist/editor/event-fanout.js.map +1 -0
- package/dist/editor/gesture-tx.d.ts +77 -0
- package/dist/editor/gesture-tx.d.ts.map +1 -0
- package/dist/editor/gesture-tx.js +93 -0
- package/dist/editor/gesture-tx.js.map +1 -0
- package/dist/editor/hit-test.d.ts +178 -0
- package/dist/editor/hit-test.d.ts.map +1 -0
- package/dist/editor/hit-test.js +460 -0
- package/dist/editor/hit-test.js.map +1 -0
- package/dist/editor/long-press.d.ts +34 -0
- package/dist/editor/long-press.d.ts.map +1 -0
- package/dist/editor/long-press.js +58 -0
- package/dist/editor/long-press.js.map +1 -0
- package/dist/editor/pinch.d.ts +37 -0
- package/dist/editor/pinch.d.ts.map +1 -0
- package/dist/editor/pinch.js +90 -0
- package/dist/editor/pinch.js.map +1 -0
- package/dist/editor/pointer-binding.d.ts +18 -0
- package/dist/editor/pointer-binding.d.ts.map +1 -0
- package/dist/editor/pointer-binding.js +1087 -0
- package/dist/editor/pointer-binding.js.map +1 -0
- package/dist/editor/public/annotations.d.ts +54 -0
- package/dist/editor/public/annotations.d.ts.map +1 -0
- package/dist/editor/public/annotations.js +106 -0
- package/dist/editor/public/annotations.js.map +1 -0
- package/dist/editor/public/arrange-group.d.ts +81 -0
- package/dist/editor/public/arrange-group.d.ts.map +1 -0
- package/dist/editor/public/arrange-group.js +193 -0
- package/dist/editor/public/arrange-group.js.map +1 -0
- package/dist/editor/public/brush.d.ts +33 -0
- package/dist/editor/public/brush.d.ts.map +1 -0
- package/dist/editor/public/brush.js +54 -0
- package/dist/editor/public/brush.js.map +1 -0
- package/dist/editor/public/clipboard.d.ts +31 -0
- package/dist/editor/public/clipboard.d.ts.map +1 -0
- package/dist/editor/public/clipboard.js +35 -0
- package/dist/editor/public/clipboard.js.map +1 -0
- package/dist/editor/public/image-insert.d.ts +30 -0
- package/dist/editor/public/image-insert.d.ts.map +1 -0
- package/dist/editor/public/image-insert.js +57 -0
- package/dist/editor/public/image-insert.js.map +1 -0
- package/dist/editor/public/layers.d.ts +56 -0
- package/dist/editor/public/layers.d.ts.map +1 -0
- package/dist/editor/public/layers.js +104 -0
- package/dist/editor/public/layers.js.map +1 -0
- package/dist/editor/public/link.d.ts +25 -0
- package/dist/editor/public/link.d.ts.map +1 -0
- package/dist/editor/public/link.js +74 -0
- package/dist/editor/public/link.js.map +1 -0
- package/dist/editor/public/placement.d.ts +60 -0
- package/dist/editor/public/placement.d.ts.map +1 -0
- package/dist/editor/public/placement.js +111 -0
- package/dist/editor/public/placement.js.map +1 -0
- package/dist/editor/public/selection-ops.d.ts +78 -0
- package/dist/editor/public/selection-ops.d.ts.map +1 -0
- package/dist/editor/public/selection-ops.js +226 -0
- package/dist/editor/public/selection-ops.js.map +1 -0
- package/dist/editor/public/text-edit.d.ts +17 -0
- package/dist/editor/public/text-edit.d.ts.map +1 -0
- package/dist/editor/public/text-edit.js +28 -0
- package/dist/editor/public/text-edit.js.map +1 -0
- package/dist/editor/public/z-order.d.ts +47 -0
- package/dist/editor/public/z-order.d.ts.map +1 -0
- package/dist/editor/public/z-order.js +151 -0
- package/dist/editor/public/z-order.js.map +1 -0
- package/dist/editor/public/zoom-pan.d.ts +44 -0
- package/dist/editor/public/zoom-pan.d.ts.map +1 -0
- package/dist/editor/public/zoom-pan.js +116 -0
- package/dist/editor/public/zoom-pan.js.map +1 -0
- package/dist/editor/render-orchestrator.d.ts +11 -0
- package/dist/editor/render-orchestrator.d.ts.map +1 -0
- package/dist/editor/render-orchestrator.js +473 -0
- package/dist/editor/render-orchestrator.js.map +1 -0
- package/dist/editor/resize-helpers.d.ts +30 -0
- package/dist/editor/resize-helpers.d.ts.map +1 -0
- package/dist/editor/resize-helpers.js +121 -0
- package/dist/editor/resize-helpers.js.map +1 -0
- package/dist/editor/shape-filters.d.ts +11 -0
- package/dist/editor/shape-filters.d.ts.map +1 -0
- package/dist/editor/shape-filters.js +19 -0
- package/dist/editor/shape-filters.js.map +1 -0
- package/dist/editor/shape-traits.d.ts +16 -0
- package/dist/editor/shape-traits.d.ts.map +1 -0
- package/dist/editor/shape-traits.js +29 -0
- package/dist/editor/shape-traits.js.map +1 -0
- package/dist/editor/viewport-helpers.d.ts +24 -0
- package/dist/editor/viewport-helpers.d.ts.map +1 -0
- package/dist/editor/viewport-helpers.js +63 -0
- package/dist/editor/viewport-helpers.js.map +1 -0
- package/dist/editor-events.d.ts +33 -0
- package/dist/editor-events.d.ts.map +1 -0
- package/dist/editor-events.js +2 -0
- package/dist/editor-events.js.map +1 -0
- package/dist/editor.d.ts +1841 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +4803 -0
- package/dist/editor.js.map +1 -0
- package/dist/file-drop.d.ts +99 -0
- package/dist/file-drop.d.ts.map +1 -0
- package/dist/file-drop.js +198 -0
- package/dist/file-drop.js.map +1 -0
- package/dist/frame-helpers.d.ts +30 -0
- package/dist/frame-helpers.d.ts.map +1 -0
- package/dist/frame-helpers.js +105 -0
- package/dist/frame-helpers.js.map +1 -0
- package/dist/group-helpers.d.ts +39 -0
- package/dist/group-helpers.d.ts.map +1 -0
- package/dist/group-helpers.js +116 -0
- package/dist/group-helpers.js.map +1 -0
- package/dist/handle.d.ts +73 -0
- package/dist/handle.d.ts.map +1 -0
- package/dist/handle.js +184 -0
- package/dist/handle.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/interactive.d.ts +21 -0
- package/dist/interactive.d.ts.map +1 -0
- package/dist/interactive.js +11 -0
- package/dist/interactive.js.map +1 -0
- package/dist/link-selection.d.ts +21 -0
- package/dist/link-selection.d.ts.map +1 -0
- package/dist/link-selection.js +37 -0
- package/dist/link-selection.js.map +1 -0
- package/dist/machine.d.ts +322 -0
- package/dist/machine.d.ts.map +1 -0
- package/dist/machine.js +568 -0
- package/dist/machine.js.map +1 -0
- package/dist/modes.d.ts +24 -0
- package/dist/modes.d.ts.map +1 -0
- package/dist/modes.js +2 -0
- package/dist/modes.js.map +1 -0
- package/dist/overlay.d.ts +262 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +673 -0
- package/dist/overlay.js.map +1 -0
- package/dist/platform.d.ts +70 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +115 -0
- package/dist/platform.js.map +1 -0
- package/dist/selection.d.ts +18 -0
- package/dist/selection.d.ts.map +1 -0
- package/dist/selection.js +31 -0
- package/dist/selection.js.map +1 -0
- package/package.json +59 -0
package/dist/editor.js
ADDED
|
@@ -0,0 +1,4803 @@
|
|
|
1
|
+
import { createActor } from "xstate";
|
|
2
|
+
import { createEmitter } from "@oh-just-another/events";
|
|
3
|
+
import { elementId as castElementId } from "@oh-just-another/types";
|
|
4
|
+
import { addElement, anchorSnapper, apply, buildSpatialIndex, getBinaryFile, isElementHidden, isElementLocked, runAutoLayout, DEFAULT_LAYER_ID, findNearestAnchor, getAnchorWorld, getAnchorOutwardNormal, routeElbowLink, routeElbowPreview, getLink, getLinkPath, getElement, getElementAt, getElementAtIndexed, getElementWorldBounds, getElementRenderBounds, isFrame, FRAME_HEADER_HEIGHT, setTextMeasurer, getScreenToWorld, gridSnapper, snapExcludedAnchors, orderForTop, outlineSnapper, removeElement, SnapEngine, isNoop, invert, updateLink, updateElement, isSnapToGridEnabled, resolveSnapSpacing, } from "@oh-just-another/scene";
|
|
5
|
+
import { layerId as castLayerId, } from "@oh-just-another/types";
|
|
6
|
+
import { bounds as B, matrix } from "@oh-just-another/math";
|
|
7
|
+
import { caretGeometry, computeLinkWorldBounds, layoutText, onAnimationContentReady, pointToCaretIndex, selectionRects as textSelectionRects, setActiveRasterizer, setActiveTextShaper, setAnimationClock, ElementCache, } from "@oh-just-another/renderer-core";
|
|
8
|
+
import { History, } from "@oh-just-another/history";
|
|
9
|
+
import { ANCHOR_CLICK_NEW_ELEMENT_GAP, DEFAULT_LINK_ROUTING, WAYPOINT_COLLAPSE_RADIUS, } from "./constants.js";
|
|
10
|
+
import { FileDropRegistry } from "./file-drop.js";
|
|
11
|
+
import { imageFileDropHandler, videoFileDropHandler } from "./built-in-handlers.js";
|
|
12
|
+
import { AnimationTick } from "./animation-tick.js";
|
|
13
|
+
import { computeDimElements as computeDimElementsHelper, isDescendantOfGroup as isDescendantOfGroupHelper, pickDrillTarget as pickDrillTargetHelper, promoteToGroupRoot as promoteToGroupRootHelper, topGroupAncestor as topGroupAncestorHelper, } from "./group-helpers.js";
|
|
14
|
+
import { assignFrameMembers as assignFrameMembersHelper, nextFrameName as nextFrameNameHelper, reconcileFrameMembership as reconcileFrameMembershipHelper, } from "./frame-helpers.js";
|
|
15
|
+
import { AutoCompactScheduler } from "./auto-compact.js";
|
|
16
|
+
import { AutoLayoutScheduler } from "./auto-layout-scheduler.js";
|
|
17
|
+
import { DEFAULT_SNAP_THRESHOLD, LINK_ENDPOINT_HANDLE_RADIUS, LINK_HIT_THRESHOLD, LARGE_SCENE_HIT_THRESHOLD, TOUCH_LINK_HANDLE_HIT_SLOP, TOUCH_LINK_HIT_THRESHOLD, TOUCH_HANDLE_HIT_SLOP, ANCHOR_START_HIT_SLOP, ANCHOR_DOT_CLICK_RADIUS, ANCHOR_DOT_ACTIVE_RADIUS, LINK_START_ANCHOR_OUTSET, TOUCH_ANCHOR_START_HIT_SLOP, TOUCH_ANCHOR_DOT_CLICK_RADIUS, DOUBLE_CLICK_MS, DOUBLE_CLICK_TOLERANCE_PX, WHEEL_ZOOM_STEP, ANIMATION_MIN_INTERVAL_MS, ANIMATION_MAX_INTERVAL_MS, ANIMATION_COST_FACTOR, HEAVY_GIF_BYTES, GIF_AUTOSTOP_MS, CARET_BLINK_INTERVAL_MS, } from "./constants.js";
|
|
18
|
+
import { HANDLE_HIT_SLOP, cursorForHandle } from "./handle.js";
|
|
19
|
+
import { anchorOverlayPoints } from "./editor/anchor-points.js";
|
|
20
|
+
import { interactionMachine, } from "./machine.js";
|
|
21
|
+
import { createEventCache, fanOutEvents, primeEventCache, } from "./editor/event-fanout.js";
|
|
22
|
+
import { GestureController } from "./editor/gesture-tx.js";
|
|
23
|
+
import { LongPressController } from "./editor/long-press.js";
|
|
24
|
+
import { pickPressTarget } from "./editor/hit-test.js";
|
|
25
|
+
import { PinchController } from "./editor/pinch.js";
|
|
26
|
+
import { applyContainerDrop as applyContainerDropPure, clampContainerToChildren as clampContainerToChildrenPure, maybeGrowContainer as maybeGrowContainerPure, } from "./editor/container-ops.js";
|
|
27
|
+
import { computeGroupResizePatches, computeElementResize, computeTextResize, } from "./editor/applies/resize.js";
|
|
28
|
+
import { bindPointerEvents as bindPointerEventsExternal } from "./editor/pointer-binding.js";
|
|
29
|
+
import { beginBrushStroke as beginBrushStrokePure, commitBrushStroke as commitBrushStrokePure, extendBrushStroke as extendBrushStrokePure, newBrushId, } from "./editor/public/brush.js";
|
|
30
|
+
import { copySelected as copySelectedPure, pasteFromClipboard, selectionFromPasted, } from "./editor/public/clipboard.js";
|
|
31
|
+
import { computeCreateLayer, computeMoveSelectionToLayer, computeRemoveLayer, computeRenameLayer, computeToggleLayerLock, computeToggleLayerVisibility, newLayerId, } from "./editor/public/layers.js";
|
|
32
|
+
import { computePan, computeResetZoom, computeSetGrid, computeViewportResize, computeZoomAt, computeZoomToFit, computeZoomToBounds, } from "./editor/public/zoom-pan.js";
|
|
33
|
+
import { computeAddAnnotation, computeAddComment, computeRemoveAnnotation, computeRemoveComment, computeToggleAnnotationResolved, hitAnnotation as hitAnnotationPure, } from "./editor/public/annotations.js";
|
|
34
|
+
import { canBeginTextEdit } from "./editor/public/text-edit.js";
|
|
35
|
+
import { compactLayerZOrderPatches, computeBringForward, computeBringToFront, computeSendBackward, computeSendToBack, } from "./editor/public/z-order.js";
|
|
36
|
+
import { computeArrangeAsGrid, computeArrangeAsStack, computeGroupSelected, computeUngroup, expandSelectionWithDescendants, newGroupElementId, pickFocusCycle, selectionRoots, } from "./editor/public/arrange-group.js";
|
|
37
|
+
import { buildImageElement, computeAddBinaryFile, hasAnimatedElement, } from "./editor/public/image-insert.js";
|
|
38
|
+
import { computeDeleteSelection, computeDuplicateSelection, computeMoveSelectionBy, computeSelectAll, computeSelectAllLinks, computeSetSelection, computeUpdateStyle, computeUpdateTextProps, describeNudge as describeNudgePure, selectionFromNewIds, } from "./editor/public/selection-ops.js";
|
|
39
|
+
import { computeSetLink, normalizeHref, safeHref } from "./editor/public/link.js";
|
|
40
|
+
import { beginPlacementState, buildElementAtCursor, buildTextElementAt, computePlacementCancel, computePlacementContainerDrop, computePlacementUpdate, newElementIdAtCursor, } from "./editor/public/placement.js";
|
|
41
|
+
import { renderEditor } from "./editor/render-orchestrator.js";
|
|
42
|
+
import { combinedSelectionBounds as combinedSelectionBoundsPure, computeViewportWorld as computeViewportWorldPure, groupChildrenUnion as groupChildrenUnionPure, } from "./editor/viewport-helpers.js";
|
|
43
|
+
import { computeHiddenElements as computeHiddenElementsPure } from "./editor/shape-filters.js";
|
|
44
|
+
import { selectByBounds as selectByBoundsPure, selectByBoundsLive as selectByBoundsLivePure, selectLinksByBoundsLive as selectLinksByBoundsLivePure, } from "./editor/applies/selection.js";
|
|
45
|
+
import { computeLinkEndpointUpdate, computeLinkPreviewEndpoints } from "./editor/applies/edge.js";
|
|
46
|
+
import { computeAnnotationMovePatch, computeGroupMovePatches, computeElementMovePatch, } from "./editor/applies/move.js";
|
|
47
|
+
import { computeMovingLinkPatches, computeMovingLinkForNudge } from "./editor/applies/link-move.js";
|
|
48
|
+
import { computeCreateLink, computeCreateElement, newLinkId, newElementId, } from "./editor/applies/create.js";
|
|
49
|
+
import { snapCreateBounds, snapGroupDelta, snapMoveDelta, snapResizeDelta, } from "./editor/applies/snap-grid.js";
|
|
50
|
+
import {} from "./overlay.js";
|
|
51
|
+
import * as Selection from "./selection.js";
|
|
52
|
+
import * as LinkSelection from "./link-selection.js";
|
|
53
|
+
/**
|
|
54
|
+
* Index-access helper for provably-valid indices: throws instead of
|
|
55
|
+
* returning `undefined` so callers stay non-nullable without `!`.
|
|
56
|
+
*/
|
|
57
|
+
const req = (v) => {
|
|
58
|
+
if (v === undefined)
|
|
59
|
+
throw new Error("packages/state: index out of range");
|
|
60
|
+
return v;
|
|
61
|
+
};
|
|
62
|
+
/** Resize handle → cursor override role. */
|
|
63
|
+
const RESIZE_ROLE = {
|
|
64
|
+
nw: "resize-nwse",
|
|
65
|
+
se: "resize-nwse",
|
|
66
|
+
ne: "resize-nesw",
|
|
67
|
+
sw: "resize-nesw",
|
|
68
|
+
n: "resize-ns",
|
|
69
|
+
s: "resize-ns",
|
|
70
|
+
e: "resize-ew",
|
|
71
|
+
w: "resize-ew",
|
|
72
|
+
};
|
|
73
|
+
/** Build a CSS `cursor` value from a {@link CursorSpec}. */
|
|
74
|
+
const cssCursor = (spec, fallbackKeyword) => {
|
|
75
|
+
if (typeof spec === "string")
|
|
76
|
+
return spec;
|
|
77
|
+
const hx = spec.hotspot?.x ?? 0;
|
|
78
|
+
const hy = spec.hotspot?.y ?? 0;
|
|
79
|
+
const img = spec.url2x !== undefined
|
|
80
|
+
? `image-set(url("${spec.url}") 1x, url("${spec.url2x}") 2x)`
|
|
81
|
+
: `url("${spec.url}")`;
|
|
82
|
+
return `${img} ${String(hx)} ${String(hy)}, ${spec.fallback ?? fallbackKeyword}`;
|
|
83
|
+
};
|
|
84
|
+
export class Editor {
|
|
85
|
+
host;
|
|
86
|
+
mainTarget;
|
|
87
|
+
overlayTarget;
|
|
88
|
+
backgroundTarget;
|
|
89
|
+
onAfterRender;
|
|
90
|
+
/**
|
|
91
|
+
* Debug: when true the overlay paints every element's mouse hit-zones
|
|
92
|
+
* (handle slop / edge endpoint / edge body). Toggled by the host
|
|
93
|
+
* debug panel via `setDebugHitZones`; read by the render orchestrator.
|
|
94
|
+
* View-only — never persisted or recorded in history.
|
|
95
|
+
*/
|
|
96
|
+
debugHitZones = false;
|
|
97
|
+
/**
|
|
98
|
+
* When false the background grid is not painted. Toggled via `toggleGrid`
|
|
99
|
+
* (`g` hotkey, standard parity). View-only — never persisted or in history.
|
|
100
|
+
*/
|
|
101
|
+
gridVisible = true;
|
|
102
|
+
actor;
|
|
103
|
+
listeners = new Set();
|
|
104
|
+
/**
|
|
105
|
+
* Typed event surface. Specific events (`mode`, `selection`,
|
|
106
|
+
* `scene`, `history`, `viewport`) fan out of `notify()` based on
|
|
107
|
+
* what actually changed since the last fire, so subscribers only
|
|
108
|
+
* wake up when their slice flips. `change` still fires once per
|
|
109
|
+
* `notify()` for callers that don't care which slice.
|
|
110
|
+
*
|
|
111
|
+
* The `subscribe()` set runs in lockstep with the typed events.
|
|
112
|
+
*/
|
|
113
|
+
events = createEmitter();
|
|
114
|
+
/**
|
|
115
|
+
* Last-emitted snapshot of every observable slice. Used by
|
|
116
|
+
* `fanOutEvents` (in `editor/event-fanout.ts`) to decide which
|
|
117
|
+
* typed events to fire on each `notify()` — only the slices
|
|
118
|
+
* whose identity changed since the previous notify get an event.
|
|
119
|
+
*/
|
|
120
|
+
eventCache = createEventCache();
|
|
121
|
+
unbind;
|
|
122
|
+
_scene;
|
|
123
|
+
_selection = Selection.EMPTY;
|
|
124
|
+
/**
|
|
125
|
+
* Snapshot of an in-progress annotation drag (press on pin → move
|
|
126
|
+
* pointer → release). `originPosition` is the annotation's stored
|
|
127
|
+
* position at press time; per-move handler computes a delta from
|
|
128
|
+
* the current pointer in world space and writes it back.
|
|
129
|
+
*/
|
|
130
|
+
annotationDrag = null;
|
|
131
|
+
/** Live preview while drawing a new shape; null when not drawing. */
|
|
132
|
+
drawingPreview = null;
|
|
133
|
+
edgePreview = null;
|
|
134
|
+
/**
|
|
135
|
+
* Active "drag a link from a start-anchor" gesture. Set when a
|
|
136
|
+
* press lands on one of the selected element's link-start dots; lets
|
|
137
|
+
* the user draw a link straight from the dot without switching to the
|
|
138
|
+
* draw-edge tool. `fromWorld` is the true anchor world point (the link
|
|
139
|
+
* origin, un-offset); `origin` is the press point (for the drag
|
|
140
|
+
* threshold). Read by the pointer handlers (drive preview / commit on
|
|
141
|
+
* up) and the render orchestrator (keep the source's start dots visible
|
|
142
|
+
* during the drag). Null when no such drag is in flight. */
|
|
143
|
+
linkDragFromAnchor = null;
|
|
144
|
+
/**
|
|
145
|
+
* Element being hovered while draw-edge mode is active. Drives the port-
|
|
146
|
+
* overlay render so the user sees attachment points. `null` outside
|
|
147
|
+
* draw-edge mode or when the pointer is over empty canvas.
|
|
148
|
+
*/
|
|
149
|
+
hoveredLinkTarget = null;
|
|
150
|
+
/**
|
|
151
|
+
* Last idle cursor position (world) in select mode — the overlay grows the
|
|
152
|
+
* SINGLE selected element's link-start dot nearest it
|
|
153
|
+
* (`ANCHOR_DOT_HOVER_GROW_RADIUS`). Reset to null on press / gesture.
|
|
154
|
+
*/
|
|
155
|
+
hoverCursorWorld = null;
|
|
156
|
+
/**
|
|
157
|
+
* When a link is dropped on empty canvas, the edge is created with a
|
|
158
|
+
* free `point` end and this records where, so the host can pop a
|
|
159
|
+
* mini shape-picker at that spot (standard). Picking a shape re-points the
|
|
160
|
+
* end to the new element; dismissing (Esc / click-away) leaves the free
|
|
161
|
+
* end on the canvas. `null` when no menu is pending.
|
|
162
|
+
*/
|
|
163
|
+
pendingLinkDropMenu = null;
|
|
164
|
+
/**
|
|
165
|
+
* Currently selected links (connectors). Links are first-class members
|
|
166
|
+
* of the selection: they coexist with selected elements, join Cmd+A and
|
|
167
|
+
* marquee, and multi-select via Shift-click. Endpoint drag handles show
|
|
168
|
+
* only when EXACTLY one link is selected and no elements are (see
|
|
169
|
+
* `selectedLink`). Empty set = no link selected.
|
|
170
|
+
*/
|
|
171
|
+
_selectedLinks = LinkSelection.EMPTY;
|
|
172
|
+
/**
|
|
173
|
+
* Currently focused annotation thread — overlay highlights its pin
|
|
174
|
+
* with an accent ring and hosts (e.g. `<CommentsPopover>`) render
|
|
175
|
+
* the thread for this id. Independent of shape / edge selection so
|
|
176
|
+
* users can edit shapes while a comment thread is open.
|
|
177
|
+
*/
|
|
178
|
+
_selectedAnnotation = null;
|
|
179
|
+
/**
|
|
180
|
+
* Author identity used for comments posted via `addComment` /
|
|
181
|
+
* `addAnnotation` without an explicit author. Hosts set this once
|
|
182
|
+
* (typically from the same user object passed to `bindAwareness`).
|
|
183
|
+
* Defaults to a synthetic local user.
|
|
184
|
+
*/
|
|
185
|
+
commentAuthor = { id: "local", name: "You" };
|
|
186
|
+
/**
|
|
187
|
+
* Mid-drag preview state when the user is dragging an edge endpoint.
|
|
188
|
+
* Drawn as an overlay line + handle dot so the user sees the target.
|
|
189
|
+
*/
|
|
190
|
+
linkEndpointDrag = null;
|
|
191
|
+
/**
|
|
192
|
+
* Host-managed waypoint (bend-point) drag of the selected link. `index`
|
|
193
|
+
* is the position in `edge.waypoints`. `pendingInsert` means the gesture
|
|
194
|
+
* began on a segment midpoint and will splice a new waypoint on the
|
|
195
|
+
* first move (so a no-move click adds nothing). Live-mutated through the
|
|
196
|
+
* gesture transaction → one undo step per drag.
|
|
197
|
+
*/
|
|
198
|
+
linkWaypointDrag = null;
|
|
199
|
+
/**
|
|
200
|
+
* Host-managed elbow segment drag. `index` is the segment in the routed
|
|
201
|
+
* chain `[from, ...routedPoints, to]`; `axis` is its orientation. Dragging
|
|
202
|
+
* pins the segment's perpendicular coordinate into `Link.fixedSegments`;
|
|
203
|
+
* the reroute pass re-flows the rest. One undo step via the gesture tx.
|
|
204
|
+
*/
|
|
205
|
+
linkSegmentDrag = null;
|
|
206
|
+
/** Live lasso bounds during a rubber-band select gesture. */
|
|
207
|
+
lassoPreview = null;
|
|
208
|
+
/**
|
|
209
|
+
* Selection captured at lasso-press time. Used to compute the live
|
|
210
|
+
* preview correctly: in `replace` mode the lasso starts from empty
|
|
211
|
+
* each frame; in `add` mode it starts from this snapshot so shapes
|
|
212
|
+
* the user already had selected don't blink out and back.
|
|
213
|
+
*/
|
|
214
|
+
lassoBaseSelection = null;
|
|
215
|
+
/** Link-selection counterpart of `lassoBaseSelection` for the marquee. */
|
|
216
|
+
lassoBaseLinks = null;
|
|
217
|
+
/**
|
|
218
|
+
* Snapshot of every selected shape's `position` at press-down. Used to
|
|
219
|
+
* translate the whole group additively during a multi-shape drag. The
|
|
220
|
+
* machine still emits per-shape MOVE_SHAPE — the editor intercepts and
|
|
221
|
+
* fans out when this map is populated.
|
|
222
|
+
*/
|
|
223
|
+
groupMoveOrigin = null;
|
|
224
|
+
/**
|
|
225
|
+
* Press-time snapshot of connectors that must follow a multi-element
|
|
226
|
+
* drag rigidly — both endpoints bound to moved elements, carrying
|
|
227
|
+
* absolute geometry (waypoints / fixedSegments / routedPoints). Each
|
|
228
|
+
* frame translates from these originals so the shift never compounds.
|
|
229
|
+
* Cleared on gesture commit / cancel alongside `groupMoveOrigin`.
|
|
230
|
+
*/
|
|
231
|
+
groupLinkMoveOrigin = null;
|
|
232
|
+
/**
|
|
233
|
+
* Per-shape snapshot for a group-resize gesture — `bounds` is the
|
|
234
|
+
* shape's world AABB at press-down. Editor scales the relative
|
|
235
|
+
* position / size against the combined bounds delta each frame.
|
|
236
|
+
*/
|
|
237
|
+
groupResizeOrigin = null;
|
|
238
|
+
/**
|
|
239
|
+
* Pristine shape snapshot for a single-shape text resize, captured on
|
|
240
|
+
* the gesture's first tick. Font scaling is computed against this base
|
|
241
|
+
* so it never compounds across pointermove ticks. Cleared on gesture
|
|
242
|
+
* end (commit / cancel).
|
|
243
|
+
*/
|
|
244
|
+
_resizeOriginElement = null;
|
|
245
|
+
/**
|
|
246
|
+
* Active layer — new shapes created via `addElement` / `applyCreate` land
|
|
247
|
+
* here when their input doesn't specify a `layerId`. Defaults to the
|
|
248
|
+
* scene's `DEFAULT_LAYER_ID`; hosts switch via `setActiveLayer`.
|
|
249
|
+
*/
|
|
250
|
+
_activeLayerId = castLayerId(DEFAULT_LAYER_ID);
|
|
251
|
+
nextId = 0;
|
|
252
|
+
/** Generate a short unique id with a stable prefix. */
|
|
253
|
+
uniqueId(prefix) {
|
|
254
|
+
return `${prefix}-${++this.nextId}-${Date.now().toString(36)}`;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Snap engine — defaults to grid + anchor + outline contributors.
|
|
258
|
+
*/
|
|
259
|
+
snapEngine = new SnapEngine([
|
|
260
|
+
gridSnapper,
|
|
261
|
+
anchorSnapper,
|
|
262
|
+
outlineSnapper,
|
|
263
|
+
]);
|
|
264
|
+
/** Snap threshold in world units. */
|
|
265
|
+
snapThreshold = DEFAULT_SNAP_THRESHOLD;
|
|
266
|
+
/**
|
|
267
|
+
* Transient flag set by the host while a snap-suppress modifier
|
|
268
|
+
* (Cmd / Ctrl) is held during a drag — lets the user pull a shape off
|
|
269
|
+
* the grid for one gesture without toggling snap off. Read by the
|
|
270
|
+
* move / resize / create wrappers; never persisted.
|
|
271
|
+
*/
|
|
272
|
+
snapSuppressed = false;
|
|
273
|
+
/**
|
|
274
|
+
* Persistent world-bounds cache shared with `renderScene` for viewport
|
|
275
|
+
* culling. Object-identity keyed — invalidates automatically whenever
|
|
276
|
+
* a scene op replaces the shape ref. Could be exposed for hit-test
|
|
277
|
+
* sharing in a follow-up.
|
|
278
|
+
*/
|
|
279
|
+
boundsCache = new ElementCache();
|
|
280
|
+
/**
|
|
281
|
+
* Lazy SpatialGrid for hit-test acceleration in large scenes.
|
|
282
|
+
* Built on demand when `scene.elements.size >= LARGE_SCENE_HIT_THRESHOLD`
|
|
283
|
+
* and the cached index's source-scene reference is stale (any scene
|
|
284
|
+
* op replaces the `_scene` field, invalidating identity).
|
|
285
|
+
*/
|
|
286
|
+
spatialIndexCache = null;
|
|
287
|
+
/**
|
|
288
|
+
* The group the user has "entered" via double-click. While set, the
|
|
289
|
+
* hit-test stops promoting children of this group to the group root,
|
|
290
|
+
* letting the user directly manipulate inner shapes. Cleared on
|
|
291
|
+
* escape, click outside the group's descendants, or `cancelInteraction`.
|
|
292
|
+
*/
|
|
293
|
+
_enteredGroup = null;
|
|
294
|
+
/**
|
|
295
|
+
* Double-click detection state. Updated on every non-drag pointer
|
|
296
|
+
* up; the next pointer-up within `DOUBLE_CLICK_MS` and within
|
|
297
|
+
* `DOUBLE_CLICK_TOLERANCE_PX` of `lastClickWorldPoint` counts as a
|
|
298
|
+
* double-click. Used to trigger group drill-down (enter isolation).
|
|
299
|
+
*/
|
|
300
|
+
lastClickAt = 0;
|
|
301
|
+
lastClickWorldPoint = null;
|
|
302
|
+
/**
|
|
303
|
+
* Separate double-click tracker for link edit handles (waypoint /
|
|
304
|
+
* segment). Kept apart from `lastClickAt` because a handle press
|
|
305
|
+
* returns early in `onDown` (begin-drag) and never reaches the up-side
|
|
306
|
+
* double-click path that updates `lastClickAt`. Updated by
|
|
307
|
+
* `isHandleDoubleClick` on each handle press.
|
|
308
|
+
*/
|
|
309
|
+
lastHandleClickAt = 0;
|
|
310
|
+
lastHandleClickWorld = null;
|
|
311
|
+
/**
|
|
312
|
+
* In-progress brush stroke. Hosts push points via
|
|
313
|
+
* `extendBrushStroke`; the overlay reads it through
|
|
314
|
+
* `pendingBrushStroke` to draw a live preview.
|
|
315
|
+
*/
|
|
316
|
+
brushStroke = null;
|
|
317
|
+
/**
|
|
318
|
+
* Last world-space pointer position observed by the host's onMove
|
|
319
|
+
* handler. `paste()` uses it as the default drop target so a fresh
|
|
320
|
+
* paste lands under the cursor instead of overlapping the originals.
|
|
321
|
+
* `null` until the pointer first enters the host.
|
|
322
|
+
*/
|
|
323
|
+
lastPointerWorld = null;
|
|
324
|
+
/** Host-registered custom cursor images per role (see `setCursorOverride`). */
|
|
325
|
+
cursorOverrides = new Map();
|
|
326
|
+
/**
|
|
327
|
+
* Scene rendered on the last frame. Used to compute a dirty rect by
|
|
328
|
+
* identity-diffing against the current scene — every shape / edge
|
|
329
|
+
* whose ref didn't change is also pixel-identical to its last paint
|
|
330
|
+
* and gets skipped together with the surrounding clear. `null` until
|
|
331
|
+
* the first render.
|
|
332
|
+
*/
|
|
333
|
+
lastRenderedScene = null;
|
|
334
|
+
/**
|
|
335
|
+
* Last-painted isolation root — paired with `lastRenderedScene` so
|
|
336
|
+
* the dirty-rect optimization invalidates when the user enters or
|
|
337
|
+
* exits a group, even when the scene reference is unchanged. Without
|
|
338
|
+
* this, drilling into a group never triggers a redraw → the dim
|
|
339
|
+
* pass would never visibly apply.
|
|
340
|
+
*/
|
|
341
|
+
lastRenderedEnteredGroup = null;
|
|
342
|
+
/**
|
|
343
|
+
* Fractional-order compaction scheduler (microtask-coalesced).
|
|
344
|
+
* Triggered from every `notify()`; only does real work when at
|
|
345
|
+
* least one shape/edge order string crossed AUTO_COMPACT_THRESHOLD.
|
|
346
|
+
* See `./auto-compact.ts` for the extracted logic.
|
|
347
|
+
*/
|
|
348
|
+
autoCompactScheduler = new AutoCompactScheduler({
|
|
349
|
+
getScene: () => this._scene,
|
|
350
|
+
compact: (layerId) => {
|
|
351
|
+
this.compactLayerZOrder(layerId, { recordHistory: false });
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
/**
|
|
355
|
+
* Auto-layout scheduler — microtask-coalesced re-run of every
|
|
356
|
+
* shape carrying `metadata.autoLayout`. See
|
|
357
|
+
* `./auto-layout-scheduler.ts` for the extracted logic.
|
|
358
|
+
*/
|
|
359
|
+
autoLayoutScheduler = new AutoLayoutScheduler({
|
|
360
|
+
getScene: () => this._scene,
|
|
361
|
+
applyPatch: (patch) => {
|
|
362
|
+
this._scene = apply(this._scene, patch);
|
|
363
|
+
if (this.gestureTx)
|
|
364
|
+
this.gestureTx.add(patch);
|
|
365
|
+
else
|
|
366
|
+
this._history.push(patch);
|
|
367
|
+
},
|
|
368
|
+
growContainer: (parentId, childId) => {
|
|
369
|
+
this.maybeGrowContainer(parentId, childId);
|
|
370
|
+
},
|
|
371
|
+
onMutated: () => {
|
|
372
|
+
// Re-render only; do NOT call notify() — that would re-schedule
|
|
373
|
+
// the check and risk a microtask loop. Listeners already saw
|
|
374
|
+
// the previous notify; the auto-layout adjustment is a
|
|
375
|
+
// synchronous fix-up on top of the same external event.
|
|
376
|
+
this.scheduleRender();
|
|
377
|
+
for (const fn of this.listeners)
|
|
378
|
+
fn();
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
/**
|
|
382
|
+
* Element id that the user started dragging on press-down. Tracked
|
|
383
|
+
* separately from the state machine so the editor knows what to
|
|
384
|
+
* (re)parent / drop into a container on pointerup. `null` between
|
|
385
|
+
* gestures, set in onDown when press lands on a shape and cleared
|
|
386
|
+
* in onUp / cancel.
|
|
387
|
+
*/
|
|
388
|
+
dragElementId = null;
|
|
389
|
+
/**
|
|
390
|
+
* Element that the current press added to the selection additively
|
|
391
|
+
* (shift / meta click on an unselected shape). The press promotes it
|
|
392
|
+
* so a subsequent drag moves it; on a *tap* the up-handler would
|
|
393
|
+
* otherwise `SELECT_TOGGLE` it straight back off, so it consults this
|
|
394
|
+
* to skip that redundant toggle. Reset at every press-down.
|
|
395
|
+
*/
|
|
396
|
+
additivePressAdded = null;
|
|
397
|
+
/**
|
|
398
|
+
* Live container highlight: the container shape the dragged item is
|
|
399
|
+
* currently hovering over. Drawn by the overlay as a dashed
|
|
400
|
+
* accent rect on the container's drop-zone so the user sees where the
|
|
401
|
+
* shape will land after release.
|
|
402
|
+
*/
|
|
403
|
+
containerHover = null;
|
|
404
|
+
/**
|
|
405
|
+
* Remote peer cursors / selections, pushed in by the host (typically
|
|
406
|
+
* a `bindAwareness(editor, awareness)` helper in `@collab`). The
|
|
407
|
+
* editor only renders them; it doesn't fetch or interpret. Each
|
|
408
|
+
* setter triggers `render()` so the overlay updates immediately.
|
|
409
|
+
*/
|
|
410
|
+
_peerCursors = [];
|
|
411
|
+
_peerSelections = [];
|
|
412
|
+
/**
|
|
413
|
+
* Subscribers notified on every host pointer move (world-space). Used
|
|
414
|
+
* by `@collab` to broadcast the local cursor into awareness.
|
|
415
|
+
*/
|
|
416
|
+
cursorListeners = new Set();
|
|
417
|
+
/**
|
|
418
|
+
* Active screen-space pointer positions keyed by `pointerId`. With
|
|
419
|
+
* one entry the editor's normal single-pointer flow applies. With
|
|
420
|
+
* two or more entries we enter a pinch / pan gesture and bypass the
|
|
421
|
+
* interaction machine — `pinchOrigin` holds the baseline.
|
|
422
|
+
*/
|
|
423
|
+
activePointers = new Map();
|
|
424
|
+
/**
|
|
425
|
+
* One-finger-pan candidate: set at pointer-down when a TOUCH press lands
|
|
426
|
+
* on empty canvas in select mode. A tap (no movement) still falls through
|
|
427
|
+
* to select/deselect; once the finger drags past slop, onMove promotes
|
|
428
|
+
* this to a real pan instead of a marquee lasso (mobile convention).
|
|
429
|
+
* Screen-space origin point.
|
|
430
|
+
*/
|
|
431
|
+
touchPanCandidate = null;
|
|
432
|
+
// Pinch gesture state lives in PinchController (./editor/pinch.ts)
|
|
433
|
+
// — `pinch.isActive()` replaces the old `pinchOrigin !== null` check.
|
|
434
|
+
pinch;
|
|
435
|
+
/** Bridge for `editor/container-ops.ts`. Built lazily in constructor. */
|
|
436
|
+
containerOpsRef;
|
|
437
|
+
/**
|
|
438
|
+
* Space-bar held → next pointer drag pans the canvas instead of
|
|
439
|
+
* doing whatever the current mode would do. Visual cursor goes to
|
|
440
|
+
* "grab" / "grabbing". Wires a window-level keydown/keyup listener
|
|
441
|
+
* in `bindPointerEvents`.
|
|
442
|
+
*/
|
|
443
|
+
spaceHeld = false;
|
|
444
|
+
/**
|
|
445
|
+
* Host-supplied tile compositor — when set (via
|
|
446
|
+
* `EditorOptions.useTileCache` + `tileCompose`), the per-frame
|
|
447
|
+
* render path delegates to it instead of `renderScene`. Stays
|
|
448
|
+
* `null` for the typical small-scene case.
|
|
449
|
+
*/
|
|
450
|
+
tileComposeFn;
|
|
451
|
+
/**
|
|
452
|
+
* Per-shape change record (before/after world bbox) since the last
|
|
453
|
+
* tile-cache invalidation pass. Populated by `computeDirtyWorld`'s
|
|
454
|
+
* diff loop when `tileComposeFn` is on; forwarded to the compositor
|
|
455
|
+
* each frame so it can invalidate by add / remove / move correctly.
|
|
456
|
+
* (A plain id set lost adds — new id wasn't in the tile reverse
|
|
457
|
+
* index yet.)
|
|
458
|
+
*/
|
|
459
|
+
tileDirtyElements = new Map();
|
|
460
|
+
/**
|
|
461
|
+
* Tool-lock flag (standard model). When `false` (default), a
|
|
462
|
+
* draw-mode (`draw-rect` / `draw-ellipse` / `draw-edge` / `brush`)
|
|
463
|
+
* auto-reverts to `select` after a successful create. When `true`,
|
|
464
|
+
* the mode persists so the user can draw many shapes in a row
|
|
465
|
+
* without re-selecting the tool.
|
|
466
|
+
*
|
|
467
|
+
* Toggled via `Editor.setToolLocked(bool)` and surfaced in the
|
|
468
|
+
* toolbar as a lock affordance next to the active tool.
|
|
469
|
+
*/
|
|
470
|
+
_toolLocked = false;
|
|
471
|
+
/**
|
|
472
|
+
* Host-extensible file-drop dispatch. Built-ins (image / scene
|
|
473
|
+
* JSON) register themselves at editor construction; hosts add
|
|
474
|
+
* more via `registerFileDropHandler`.
|
|
475
|
+
*/
|
|
476
|
+
fileDropRegistry = new FileDropRegistry();
|
|
477
|
+
/**
|
|
478
|
+
* Active pan gesture (right-click drag or Space + left drag).
|
|
479
|
+
* `pointerId` is captured by the host so move/up events keep
|
|
480
|
+
* arriving even after the cursor leaves the host bounds.
|
|
481
|
+
* `startPoint` is the press position — used to decide "click vs
|
|
482
|
+
* drag" at pointerup (a near-zero displacement right-click is a
|
|
483
|
+
* context-menu request, not a pan). `lastPoint` tracks the
|
|
484
|
+
* previous move so per-frame delta is correct.
|
|
485
|
+
*
|
|
486
|
+
* `button` records which mouse button started the gesture so we
|
|
487
|
+
* only treat right-click releases as potential context-menu
|
|
488
|
+
* triggers (Space + left-drag never opens a menu).
|
|
489
|
+
*/
|
|
490
|
+
panGesture = null;
|
|
491
|
+
/**
|
|
492
|
+
* Set on a right-click pointerdown so the upcoming native
|
|
493
|
+
* `contextmenu` event can be unconditionally preventDefault'ed
|
|
494
|
+
* (the gesture decides whether to fire the menu manually on
|
|
495
|
+
* pointerup based on whether the user dragged).
|
|
496
|
+
*/
|
|
497
|
+
suppressNextContextMenu = false;
|
|
498
|
+
/**
|
|
499
|
+
* Long-press tracking. Starts on `pointerdown`; cancelled on
|
|
500
|
+
* `pointermove > LONG_PRESS_MAX_MOVEMENT_PX` or `pointerup` before
|
|
501
|
+
* the timer fires. Hosts subscribe via `onLongPress` to surface a
|
|
502
|
+
* context menu (mobile alternative to right-click).
|
|
503
|
+
*/
|
|
504
|
+
// Long-press timer + origin live in LongPressController
|
|
505
|
+
// (./editor/long-press.ts). The Set of subscribers stays here
|
|
506
|
+
// because `onLongPress` is part of the public Editor API.
|
|
507
|
+
longPress;
|
|
508
|
+
longPressListeners = new Set();
|
|
509
|
+
/**
|
|
510
|
+
* Live-region announcements for assistive tech. The editor pushes
|
|
511
|
+
* short, human-readable strings ("Selected Rectangle", "Moved 5 px
|
|
512
|
+
* right") that hosts pipe into an `aria-live=polite` region.
|
|
513
|
+
*/
|
|
514
|
+
announceListeners = new Set();
|
|
515
|
+
/**
|
|
516
|
+
* Resolved primary input modality + derived hit slops. Computed once
|
|
517
|
+
* in the constructor from `EditorOptions.inputMode` (default `"auto"`
|
|
518
|
+
* uses `matchMedia('(pointer: coarse)')`).
|
|
519
|
+
*/
|
|
520
|
+
inputMode;
|
|
521
|
+
handleHitSlop;
|
|
522
|
+
edgeHandleHitSlop;
|
|
523
|
+
edgeHitThreshold;
|
|
524
|
+
/** Link-start anchor-dot grab/click hit radii — touch-enlarged in touch mode. */
|
|
525
|
+
anchorStartHitSlop;
|
|
526
|
+
anchorClickRadius;
|
|
527
|
+
_history;
|
|
528
|
+
/** Open transaction during a single drag/resize gesture. */
|
|
529
|
+
gestureTx = null;
|
|
530
|
+
/**
|
|
531
|
+
* Immutable snapshot of `_scene` taken when a gesture transaction opens.
|
|
532
|
+
* The history transaction only records patches for undo — cancelling it does
|
|
533
|
+
* NOT roll back `_scene`. Keeping the pre-gesture scene lets Escape (and any
|
|
534
|
+
* cancel) restore it, so a drag/move/resize/endpoint-rebind aborted with Esc
|
|
535
|
+
* leaves the scene exactly as it was. Cleared on commit.
|
|
536
|
+
*/
|
|
537
|
+
gestureStartScene = null;
|
|
538
|
+
/**
|
|
539
|
+
* Wraps gesture lifecycle (transaction open/commit/cancel +
|
|
540
|
+
* post-create mode revert) so editor.ts doesn't carry the bodies.
|
|
541
|
+
* Implementation lives in `./editor/gesture-tx.ts`; the
|
|
542
|
+
* controller calls back through the narrow `GestureRef` bridge
|
|
543
|
+
* built lazily below.
|
|
544
|
+
*/
|
|
545
|
+
gestures;
|
|
546
|
+
constructor(options) {
|
|
547
|
+
this.host = options.host;
|
|
548
|
+
this.mainTarget = options.mainTarget;
|
|
549
|
+
this.overlayTarget = options.overlayTarget;
|
|
550
|
+
this.backgroundTarget = options.backgroundTarget ?? null;
|
|
551
|
+
this.onAfterRender = options.onAfterRender ?? null;
|
|
552
|
+
this._scene = options.initialScene;
|
|
553
|
+
this._history = isHistoryProvider(options.history)
|
|
554
|
+
? options.history
|
|
555
|
+
: new History(options.history ?? {});
|
|
556
|
+
// Build the gesture controller against a narrow getter/setter
|
|
557
|
+
// bridge to the editor's mutable state. The bridge is a thin
|
|
558
|
+
// adapter — keeps `gestureTx`/`dragElementId` etc. as `private`
|
|
559
|
+
// fields on Editor (instead of forcing them public to satisfy
|
|
560
|
+
// structural implements), and lets the controller live in its
|
|
561
|
+
// own module without importing Editor.
|
|
562
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias -- getters/setters in the literal rebind `this`; alias keeps Editor reference
|
|
563
|
+
const self = this;
|
|
564
|
+
this.gestures = new GestureController({
|
|
565
|
+
get history() {
|
|
566
|
+
return self._history;
|
|
567
|
+
},
|
|
568
|
+
get gestureTx() {
|
|
569
|
+
return self.gestureTx;
|
|
570
|
+
},
|
|
571
|
+
set gestureTx(v) {
|
|
572
|
+
self.gestureTx = v;
|
|
573
|
+
},
|
|
574
|
+
get groupMoveOrigin() {
|
|
575
|
+
return self.groupMoveOrigin;
|
|
576
|
+
},
|
|
577
|
+
set groupMoveOrigin(v) {
|
|
578
|
+
self.groupMoveOrigin = v;
|
|
579
|
+
},
|
|
580
|
+
get groupLinkMoveOrigin() {
|
|
581
|
+
return self.groupLinkMoveOrigin;
|
|
582
|
+
},
|
|
583
|
+
set groupLinkMoveOrigin(v) {
|
|
584
|
+
self.groupLinkMoveOrigin = v;
|
|
585
|
+
},
|
|
586
|
+
get groupResizeOrigin() {
|
|
587
|
+
return self.groupResizeOrigin;
|
|
588
|
+
},
|
|
589
|
+
set groupResizeOrigin(v) {
|
|
590
|
+
self.groupResizeOrigin = v;
|
|
591
|
+
},
|
|
592
|
+
get dragElementId() {
|
|
593
|
+
return self.dragElementId;
|
|
594
|
+
},
|
|
595
|
+
set dragElementId(v) {
|
|
596
|
+
self.dragElementId = v;
|
|
597
|
+
},
|
|
598
|
+
get containerHover() {
|
|
599
|
+
return self.containerHover;
|
|
600
|
+
},
|
|
601
|
+
set containerHover(v) {
|
|
602
|
+
self.containerHover = v;
|
|
603
|
+
},
|
|
604
|
+
get toolLocked() {
|
|
605
|
+
return self._toolLocked;
|
|
606
|
+
},
|
|
607
|
+
get mode() {
|
|
608
|
+
return self.mode;
|
|
609
|
+
},
|
|
610
|
+
setMode: (m) => {
|
|
611
|
+
self.setMode(m);
|
|
612
|
+
},
|
|
613
|
+
notify: () => {
|
|
614
|
+
self.notify();
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
this.tileComposeFn =
|
|
618
|
+
options.useTileCache === true && options.tileCompose ? options.tileCompose : null;
|
|
619
|
+
// If the host plugged a TextShaper, install it process-globally so the
|
|
620
|
+
// built-in text renderer's wrap path uses it instead of
|
|
621
|
+
// Canvas2D.measureText. Hosts that don't care leave the field unset and
|
|
622
|
+
// the default behaviour is unchanged.
|
|
623
|
+
if (options.textShaper)
|
|
624
|
+
setActiveTextShaper(options.textShaper);
|
|
625
|
+
// Same pattern for the Rasterizer. The WebGL2 backend reads
|
|
626
|
+
// `getActiveRasterizer()` from its curve methods and routes through WASM
|
|
627
|
+
// flatten / strokeToFill when set. Other backends (Canvas2D, SVG) leave
|
|
628
|
+
// the field alone — native ctx.bezierCurveTo beats any WASM round-trip
|
|
629
|
+
// there.
|
|
630
|
+
if (options.rasterizer)
|
|
631
|
+
setActiveRasterizer(options.rasterizer);
|
|
632
|
+
// Drive the scene text bounder from the renderer's own metrics so
|
|
633
|
+
// the selection box hugs the rendered text (the WebGL2 MSDF font's
|
|
634
|
+
// advances differ from any geometric estimate). Measuring sets the
|
|
635
|
+
// font on the main target — harmless, every draw re-sets its own.
|
|
636
|
+
setTextMeasurer((text, family, size, opts) => {
|
|
637
|
+
this.mainTarget.setFont(family, size, {
|
|
638
|
+
...(opts?.bold ? { weight: "bold" } : {}),
|
|
639
|
+
...(opts?.italic ? { style: "italic" } : {}),
|
|
640
|
+
});
|
|
641
|
+
return this.mainTarget.measureText(text).width;
|
|
642
|
+
});
|
|
643
|
+
// Resolve input mode + derived hit slops once. `auto` reads
|
|
644
|
+
// `matchMedia('(pointer: coarse)')` when available; SSR falls
|
|
645
|
+
// back to `mouse`.
|
|
646
|
+
const requested = options.inputMode ?? "auto";
|
|
647
|
+
if (requested === "touch") {
|
|
648
|
+
this.inputMode = "touch";
|
|
649
|
+
}
|
|
650
|
+
else if (requested === "mouse") {
|
|
651
|
+
this.inputMode = "mouse";
|
|
652
|
+
}
|
|
653
|
+
else if (typeof window !== "undefined" &&
|
|
654
|
+
typeof window.matchMedia === "function" &&
|
|
655
|
+
window.matchMedia("(pointer: coarse)").matches) {
|
|
656
|
+
this.inputMode = "touch";
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
this.inputMode = "mouse";
|
|
660
|
+
}
|
|
661
|
+
this.handleHitSlop = this.inputMode === "touch" ? TOUCH_HANDLE_HIT_SLOP : HANDLE_HIT_SLOP;
|
|
662
|
+
this.edgeHandleHitSlop =
|
|
663
|
+
this.inputMode === "touch" ? TOUCH_LINK_HANDLE_HIT_SLOP : LINK_ENDPOINT_HANDLE_RADIUS;
|
|
664
|
+
this.edgeHitThreshold =
|
|
665
|
+
this.inputMode === "touch" ? TOUCH_LINK_HIT_THRESHOLD : LINK_HIT_THRESHOLD;
|
|
666
|
+
this.anchorStartHitSlop =
|
|
667
|
+
this.inputMode === "touch" ? TOUCH_ANCHOR_START_HIT_SLOP : ANCHOR_START_HIT_SLOP;
|
|
668
|
+
this.anchorClickRadius =
|
|
669
|
+
this.inputMode === "touch" ? TOUCH_ANCHOR_DOT_CLICK_RADIUS : ANCHOR_DOT_CLICK_RADIUS;
|
|
670
|
+
this.actor = createActor(interactionMachine);
|
|
671
|
+
this.actor.subscribe({
|
|
672
|
+
next: () => {
|
|
673
|
+
// Render on any state change so drawing rubber-band updates.
|
|
674
|
+
this.scheduleRender();
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
this.actor.on("*", (event) => {
|
|
678
|
+
this.applyEmit(event);
|
|
679
|
+
});
|
|
680
|
+
this.actor.start();
|
|
681
|
+
// Built-in file-drop handlers — registered before any host-side
|
|
682
|
+
// wiring so subsequent host `registerFileDropHandler` calls land
|
|
683
|
+
// *after* and only fire for files we don't already handle.
|
|
684
|
+
this.fileDropRegistry.register(imageFileDropHandler);
|
|
685
|
+
this.fileDropRegistry.register(videoFileDropHandler);
|
|
686
|
+
if (options.initialMode) {
|
|
687
|
+
this.actor.send({ type: "SET_MODE", mode: options.initialMode });
|
|
688
|
+
}
|
|
689
|
+
// Long-press controller — fired on touch-hold; fans out to
|
|
690
|
+
// host-registered listeners (mobile alt to right-click).
|
|
691
|
+
this.longPress = new LongPressController((p) => this.screenToWorld(p), (payload) => {
|
|
692
|
+
for (const fn of this.longPressListeners)
|
|
693
|
+
fn(payload);
|
|
694
|
+
});
|
|
695
|
+
// Pinch gesture controller — two-finger pan + zoom. Hooks into
|
|
696
|
+
// the editor's own zoomAt / panBy / screenToWorld.
|
|
697
|
+
this.pinch = new PinchController((p) => this.screenToWorld(p), (factor, anchorWorld) => {
|
|
698
|
+
this.zoomAt(factor, anchorWorld);
|
|
699
|
+
}, (delta) => {
|
|
700
|
+
this.panBy(delta);
|
|
701
|
+
});
|
|
702
|
+
// Bridge for container-ops module — narrow surface that the
|
|
703
|
+
// pure functions in editor/container-ops.ts call back into.
|
|
704
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias -- bridge literal rebinds `this`; alias keeps Editor reference
|
|
705
|
+
const self2 = this;
|
|
706
|
+
this.containerOpsRef = {
|
|
707
|
+
get scene() {
|
|
708
|
+
return self2._scene;
|
|
709
|
+
},
|
|
710
|
+
get dragElementId() {
|
|
711
|
+
return self2.dragElementId;
|
|
712
|
+
},
|
|
713
|
+
get containerHover() {
|
|
714
|
+
return self2.containerHover;
|
|
715
|
+
},
|
|
716
|
+
applyPatch(patch, nextScene) {
|
|
717
|
+
self2._scene = nextScene;
|
|
718
|
+
self2.beginOrAttachGesture().add(patch);
|
|
719
|
+
},
|
|
720
|
+
};
|
|
721
|
+
this.unbind = this.bindPointerEvents();
|
|
722
|
+
// Pause animation playback when the tab / window is hidden (browsers
|
|
723
|
+
// throttle rAF to ~1fps in background but don't stop it; an explicit
|
|
724
|
+
// stop saves the decode + render entirely). Resume when visible again,
|
|
725
|
+
// viewport permitting.
|
|
726
|
+
if (typeof document !== "undefined") {
|
|
727
|
+
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
|
728
|
+
}
|
|
729
|
+
// Restore GIF/video bytes onto animated image shapes loaded from
|
|
730
|
+
// an initial scene (e.g. localStorage), then arm the tick so the
|
|
731
|
+
// animation plays from first paint.
|
|
732
|
+
this.rehydrateAnimatedImages();
|
|
733
|
+
this.maybeAnimate();
|
|
734
|
+
// An animated adapter (GIF) decodes asynchronously; when a decode
|
|
735
|
+
// completes it nudges us here. Re-render so a PAUSED animated shape
|
|
736
|
+
// (reduced-motion / auto-stopped / frozen) — which has no tick to
|
|
737
|
+
// pick the frames up — paints its decoded frame after reload.
|
|
738
|
+
this.animationContentOff = onAnimationContentReady(() => {
|
|
739
|
+
this.scheduleRender();
|
|
740
|
+
});
|
|
741
|
+
// First paint — synchronous so the canvas isn't blank for one
|
|
742
|
+
// frame on mount. Hosts that mount + immediately read the
|
|
743
|
+
// bitmap also get a consistent first frame.
|
|
744
|
+
this.forceRender();
|
|
745
|
+
// Prime the typed-event cache with the editor's initial state so
|
|
746
|
+
// the *first* user-driven update only emits on a real flip.
|
|
747
|
+
// Without this, an `editor.on("mode", fn)` listener installed
|
|
748
|
+
// before any change would fire on the very next `setMode(current)`
|
|
749
|
+
// call because every cached slice would still be `null`.
|
|
750
|
+
primeEventCache(this.eventCache, this.observableSnapshot());
|
|
751
|
+
}
|
|
752
|
+
/** Snapshot used by event-fanout. Kept private — internal API. */
|
|
753
|
+
observableSnapshot() {
|
|
754
|
+
return {
|
|
755
|
+
mode: this.mode,
|
|
756
|
+
selection: this._selection,
|
|
757
|
+
selectedLinks: this._selectedLinks,
|
|
758
|
+
scene: this._scene,
|
|
759
|
+
canUndo: this.canUndo,
|
|
760
|
+
canRedo: this.canRedo,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
// --- Public state ---
|
|
764
|
+
get scene() {
|
|
765
|
+
return this._scene;
|
|
766
|
+
}
|
|
767
|
+
get selection() {
|
|
768
|
+
return this._selection;
|
|
769
|
+
}
|
|
770
|
+
get mode() {
|
|
771
|
+
return this.actor.getSnapshot().context.mode;
|
|
772
|
+
}
|
|
773
|
+
get history() {
|
|
774
|
+
return this._history;
|
|
775
|
+
}
|
|
776
|
+
get canUndo() {
|
|
777
|
+
return this._history.canUndo;
|
|
778
|
+
}
|
|
779
|
+
get canRedo() {
|
|
780
|
+
return this._history.canRedo;
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* The DOM element the editor was mounted onto. Read-only — external
|
|
784
|
+
* code reads it for screen-↔-world coordinate conversions on events
|
|
785
|
+
* whose coordinates are in client-space (e.g. global `contextmenu`).
|
|
786
|
+
*/
|
|
787
|
+
get hostElement() {
|
|
788
|
+
return this.host;
|
|
789
|
+
}
|
|
790
|
+
/** Subscribe to scene/selection/mode/history changes. */
|
|
791
|
+
subscribe(fn) {
|
|
792
|
+
this.listeners.add(fn);
|
|
793
|
+
return () => this.listeners.delete(fn);
|
|
794
|
+
}
|
|
795
|
+
// --- Collab: remote presence push + local cursor push ---
|
|
796
|
+
/**
|
|
797
|
+
* Subscribe to local world-space pointer movement. Fires on every
|
|
798
|
+
* `pointermove` over the host. `@collab` uses this to broadcast the
|
|
799
|
+
* local cursor into the awareness room.
|
|
800
|
+
*/
|
|
801
|
+
onCursorMove(fn) {
|
|
802
|
+
this.cursorListeners.add(fn);
|
|
803
|
+
return () => this.cursorListeners.delete(fn);
|
|
804
|
+
}
|
|
805
|
+
// --- Annotations ---
|
|
806
|
+
/** Set the local user's identity for comments authored via this editor. */
|
|
807
|
+
setCommentAuthor(author) {
|
|
808
|
+
this.commentAuthor = author;
|
|
809
|
+
}
|
|
810
|
+
/** Currently focused annotation id (or null when nothing is open). */
|
|
811
|
+
get selectedAnnotation() {
|
|
812
|
+
return this._selectedAnnotation;
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Open or close an annotation thread. `null` clears the focus. The
|
|
816
|
+
* overlay highlights the pin; `<CommentsPopover>` reads this and
|
|
817
|
+
* renders the thread.
|
|
818
|
+
*/
|
|
819
|
+
setSelectedAnnotation(id) {
|
|
820
|
+
if (this._selectedAnnotation === id)
|
|
821
|
+
return;
|
|
822
|
+
this._selectedAnnotation = id;
|
|
823
|
+
this.notify();
|
|
824
|
+
}
|
|
825
|
+
// Pure bodies in `./editor/public/annotations.ts`.
|
|
826
|
+
addAnnotation(opts) {
|
|
827
|
+
const result = computeAddAnnotation(this._scene, opts, this.commentAuthor, (p) => this.uniqueId(p));
|
|
828
|
+
this._scene = result.scene;
|
|
829
|
+
this._history.push(result.patch);
|
|
830
|
+
this._selectedAnnotation = result.id;
|
|
831
|
+
this.notify();
|
|
832
|
+
this.announce("Annotation added");
|
|
833
|
+
return result.id;
|
|
834
|
+
}
|
|
835
|
+
removeAnnotation(id) {
|
|
836
|
+
const result = computeRemoveAnnotation(this._scene, id);
|
|
837
|
+
if (!result)
|
|
838
|
+
return;
|
|
839
|
+
this._scene = result.scene;
|
|
840
|
+
this._history.push(result.patch);
|
|
841
|
+
if (this._selectedAnnotation === id)
|
|
842
|
+
this._selectedAnnotation = null;
|
|
843
|
+
this.notify();
|
|
844
|
+
this.announce("Annotation removed");
|
|
845
|
+
}
|
|
846
|
+
toggleAnnotationResolved(id) {
|
|
847
|
+
const result = computeToggleAnnotationResolved(this._scene, id);
|
|
848
|
+
if (!result)
|
|
849
|
+
return;
|
|
850
|
+
this._scene = result.scene;
|
|
851
|
+
this._history.push(result.patch);
|
|
852
|
+
this.notify();
|
|
853
|
+
this.announce(result.wasResolved ? "Annotation reopened" : "Annotation resolved");
|
|
854
|
+
}
|
|
855
|
+
addComment(annotationId, body, author) {
|
|
856
|
+
const result = computeAddComment(this._scene, annotationId, body, author ?? this.commentAuthor, (p) => this.uniqueId(p));
|
|
857
|
+
if (!result)
|
|
858
|
+
return;
|
|
859
|
+
this._scene = result.scene;
|
|
860
|
+
this._history.push(result.patch);
|
|
861
|
+
this.notify();
|
|
862
|
+
}
|
|
863
|
+
removeComment(annotationId, commentId) {
|
|
864
|
+
const result = computeRemoveComment(this._scene, annotationId, commentId);
|
|
865
|
+
if (!result)
|
|
866
|
+
return;
|
|
867
|
+
this._scene = result.scene;
|
|
868
|
+
this._history.push(result.patch);
|
|
869
|
+
this.notify();
|
|
870
|
+
}
|
|
871
|
+
hitAnnotation(worldPoint) {
|
|
872
|
+
return hitAnnotationPure(this._scene, worldPoint);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Subscribe to long-press events — a stationary touch / mouse-press
|
|
876
|
+
* held longer than `LONG_PRESS_DELAY_MS`. The mobile-equivalent of
|
|
877
|
+
* right-click; hosts open a context menu from this. The event
|
|
878
|
+
* carries both screen-space (for menu positioning) and world-space
|
|
879
|
+
* (for target hit-test) coordinates.
|
|
880
|
+
*/
|
|
881
|
+
onLongPress(fn) {
|
|
882
|
+
this.longPressListeners.add(fn);
|
|
883
|
+
return () => this.longPressListeners.delete(fn);
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Subscribe to accessibility live-region announcements. The host
|
|
887
|
+
* pipes these strings into an `aria-live="polite"` element so a
|
|
888
|
+
* screen-reader user hears the editor's status changes. Strings
|
|
889
|
+
* are short and pre-localised by the caller of `announce`.
|
|
890
|
+
*/
|
|
891
|
+
onAnnounce(fn) {
|
|
892
|
+
this.announceListeners.add(fn);
|
|
893
|
+
return () => this.announceListeners.delete(fn);
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Push a live-region message to all `onAnnounce` listeners. Hosts
|
|
897
|
+
* (and plugins) call this when something happened that an SR user
|
|
898
|
+
* should hear: selection changed, shape moved, mode switched, etc.
|
|
899
|
+
* The editor itself emits a small set of canonical messages from
|
|
900
|
+
* `focusCycle` / `moveSelectionBy` / `cancelInteraction`.
|
|
901
|
+
*/
|
|
902
|
+
announce(message) {
|
|
903
|
+
if (!message)
|
|
904
|
+
return;
|
|
905
|
+
for (const fn of this.announceListeners)
|
|
906
|
+
fn(message);
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Replace the remote peer cursors painted by the overlay. Pass an
|
|
910
|
+
* empty array to clear. The host is expected to filter out the
|
|
911
|
+
* local user's cursor before calling.
|
|
912
|
+
*/
|
|
913
|
+
setPeerCursors(cursors) {
|
|
914
|
+
this._peerCursors = cursors;
|
|
915
|
+
this.scheduleRender();
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Replace the remote peer selections painted by the overlay. The
|
|
919
|
+
* host resolves a peer's `selection: ElementId[]` into world bounds
|
|
920
|
+
* before passing them in.
|
|
921
|
+
*/
|
|
922
|
+
setPeerSelections(selections) {
|
|
923
|
+
this._peerSelections = selections;
|
|
924
|
+
this.scheduleRender();
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Toggle the debug hit-zone overlay (host debug panel). When on, the
|
|
928
|
+
* overlay paints every element's mouse hit-targets so the tuned slop
|
|
929
|
+
* values can be eyeballed. View-only — not recorded in history.
|
|
930
|
+
*/
|
|
931
|
+
setDebugHitZones(on) {
|
|
932
|
+
if (this.debugHitZones === on)
|
|
933
|
+
return;
|
|
934
|
+
this.debugHitZones = on;
|
|
935
|
+
this.scheduleRender();
|
|
936
|
+
}
|
|
937
|
+
/** Show/hide the background grid (standard `g`). View-only — not in history. */
|
|
938
|
+
setGridVisible(on) {
|
|
939
|
+
if (this.gridVisible === on)
|
|
940
|
+
return;
|
|
941
|
+
this.gridVisible = on;
|
|
942
|
+
this.scheduleRender();
|
|
943
|
+
}
|
|
944
|
+
/** Toggle background grid visibility. */
|
|
945
|
+
toggleGrid() {
|
|
946
|
+
this.setGridVisible(!this.gridVisible);
|
|
947
|
+
}
|
|
948
|
+
/** Whether the active draw-mode sticks after a create (toolbar lock). */
|
|
949
|
+
get toolLocked() {
|
|
950
|
+
return this._toolLocked;
|
|
951
|
+
}
|
|
952
|
+
/** All currently-selected link (connector) ids. */
|
|
953
|
+
get selectedLinks() {
|
|
954
|
+
return this._selectedLinks;
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* The sole selected link — non-null ONLY when exactly one link and no
|
|
958
|
+
* elements are selected. Drives endpoint handles, the edge-style panel,
|
|
959
|
+
* caption edit and `updateSelectedLink`; a multi/mixed selection yields
|
|
960
|
+
* null so those single-link affordances stay hidden.
|
|
961
|
+
*/
|
|
962
|
+
get selectedLink() {
|
|
963
|
+
if (this._selection.size > 0)
|
|
964
|
+
return null;
|
|
965
|
+
return LinkSelection.sole(this._selectedLinks);
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Apply an in-place mutation to the currently-selected edge as a
|
|
969
|
+
* single history step. The `updater` receives a clone of the edge
|
|
970
|
+
* and returns the next version (callers should produce a new
|
|
971
|
+
* object — Link is readonly). No-op when no edge is selected.
|
|
972
|
+
*/
|
|
973
|
+
updateSelectedLink(updater) {
|
|
974
|
+
const id = this.selectedLink;
|
|
975
|
+
if (id === null)
|
|
976
|
+
return;
|
|
977
|
+
const r = updateLink(this._scene, id, updater);
|
|
978
|
+
this._scene = r.scene;
|
|
979
|
+
this._history.push(r.patch);
|
|
980
|
+
this.notify();
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Register a file-drop handler. Handlers are tried in registration
|
|
984
|
+
* order; the first whose `accept(file)` returns true takes the
|
|
985
|
+
* file. Repeated calls with the same `id` replace the previous
|
|
986
|
+
* handler (idempotent for module-load wiring).
|
|
987
|
+
*/
|
|
988
|
+
registerFileDropHandler(handler) {
|
|
989
|
+
this.fileDropRegistry.register(handler);
|
|
990
|
+
}
|
|
991
|
+
/** Drop a registered handler. */
|
|
992
|
+
unregisterFileDropHandler(id) {
|
|
993
|
+
this.fileDropRegistry.unregister(id);
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Dispatch a dropped file (or pasted file from clipboard) into
|
|
997
|
+
* the registered handlers. Returns `true` when a handler accepted
|
|
998
|
+
* the file, `false` otherwise — UI can show an "unsupported file"
|
|
999
|
+
* toast on `false`. `worldPoint` is where the file should land
|
|
1000
|
+
* (drop-point projected to world coords; for paste, host can use
|
|
1001
|
+
* cursor world point or viewport centre).
|
|
1002
|
+
*/
|
|
1003
|
+
async dispatchFileDrop(file, worldPoint) {
|
|
1004
|
+
const ctx = { editor: this, worldPoint };
|
|
1005
|
+
return this.fileDropRegistry.dispatch(file, ctx);
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Toggle the tool-lock affordance. With `true`, draw-modes persist
|
|
1009
|
+
* after each successful shape create — the user keeps drawing
|
|
1010
|
+
* rectangles without re-pressing R. With `false` (default), the
|
|
1011
|
+
* editor reverts to `select` after each create.
|
|
1012
|
+
*/
|
|
1013
|
+
setToolLocked(locked) {
|
|
1014
|
+
if (this._toolLocked === locked)
|
|
1015
|
+
return;
|
|
1016
|
+
this._toolLocked = locked;
|
|
1017
|
+
this.notify();
|
|
1018
|
+
}
|
|
1019
|
+
// Body moved to `./editor/gesture-tx.ts`.
|
|
1020
|
+
maybeRevertModeAfterCreate() {
|
|
1021
|
+
this.gestures.maybeRevertModeAfterCreate();
|
|
1022
|
+
}
|
|
1023
|
+
setMode(mode) {
|
|
1024
|
+
// Switching tools commits any in-flight text edit (standard: leaving the
|
|
1025
|
+
// editing context ends it, keeping the typed text).
|
|
1026
|
+
if (this._editingTextElement !== null)
|
|
1027
|
+
this.commitTextEdit();
|
|
1028
|
+
// Cancel any in-progress drag gesture so the partial state is not recorded.
|
|
1029
|
+
if (this.gestureTx) {
|
|
1030
|
+
this.gestureTx.cancel();
|
|
1031
|
+
this.gestureTx = null;
|
|
1032
|
+
}
|
|
1033
|
+
// Hide the port overlay when leaving draw-edge.
|
|
1034
|
+
if (mode !== "draw-edge" && this.hoveredLinkTarget !== null) {
|
|
1035
|
+
this.hoveredLinkTarget = null;
|
|
1036
|
+
}
|
|
1037
|
+
this.actor.send({ type: "SET_MODE", mode });
|
|
1038
|
+
// Cursor affordance follows the new mode (hand → grab, draw tools →
|
|
1039
|
+
// crosshair, etc.) — recompute through the single chokepoint.
|
|
1040
|
+
this.refreshCursor();
|
|
1041
|
+
this.notify();
|
|
1042
|
+
}
|
|
1043
|
+
/** Undo the latest record. No-op if there is nothing to undo. */
|
|
1044
|
+
undo() {
|
|
1045
|
+
const inverse = this._history.undo();
|
|
1046
|
+
if (!inverse)
|
|
1047
|
+
return false;
|
|
1048
|
+
this._scene = apply(this._scene, inverse);
|
|
1049
|
+
this.pruneSelection();
|
|
1050
|
+
this.notify();
|
|
1051
|
+
return true;
|
|
1052
|
+
}
|
|
1053
|
+
/** Redo the undone record. */
|
|
1054
|
+
redo() {
|
|
1055
|
+
const patch = this._history.redo();
|
|
1056
|
+
if (!patch)
|
|
1057
|
+
return false;
|
|
1058
|
+
this._scene = apply(this._scene, patch);
|
|
1059
|
+
this.pruneSelection();
|
|
1060
|
+
this.notify();
|
|
1061
|
+
return true;
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Add a shape to the scene and push a single record onto the history stack.
|
|
1065
|
+
* Returns the resulting patch (useful for tests). Intended for code paths
|
|
1066
|
+
* that create shapes outside of a pointer gesture — drag-from-palette,
|
|
1067
|
+
* paste, programmatic insert.
|
|
1068
|
+
*/
|
|
1069
|
+
addElement(shape, options) {
|
|
1070
|
+
const result = addElement(this._scene, shape);
|
|
1071
|
+
this._scene = result.scene;
|
|
1072
|
+
if (options?.select ?? true) {
|
|
1073
|
+
this._selection = Selection.single(shape.id);
|
|
1074
|
+
}
|
|
1075
|
+
this._history.push(result.patch);
|
|
1076
|
+
this.notify();
|
|
1077
|
+
return result.patch;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Insert an image at the given world position. Wraps `addElement`
|
|
1081
|
+
* with the image-shape boilerplate (id, layer, order, scale=1,
|
|
1082
|
+
* rotation=0). Returns the new shape's id so callers can chain
|
|
1083
|
+
* (e.g. immediately reparent into a container).
|
|
1084
|
+
*
|
|
1085
|
+
* `src` is anything the renderer accepts — a data-URL from the
|
|
1086
|
+
* file-drop handler, a host CDN URL, an SVG string in
|
|
1087
|
+
* `image/svg+xml;base64,...` form.
|
|
1088
|
+
*/
|
|
1089
|
+
// Pure body in `./editor/public/image-insert.ts`.
|
|
1090
|
+
insertImage(input) {
|
|
1091
|
+
const id = castElementId(this.uniqueId("img"));
|
|
1092
|
+
const shape = buildImageElement(this._scene, input, id, this._activeLayerId);
|
|
1093
|
+
this.addElement(shape);
|
|
1094
|
+
if (input.animated) {
|
|
1095
|
+
this.initPlayback(id);
|
|
1096
|
+
this.maybeAnimate();
|
|
1097
|
+
}
|
|
1098
|
+
return id;
|
|
1099
|
+
}
|
|
1100
|
+
async addBinaryFile(blob, name) {
|
|
1101
|
+
const result = await computeAddBinaryFile(this._scene, blob, name, () => ++this.nextId);
|
|
1102
|
+
this._scene = result.scene;
|
|
1103
|
+
this._history.push(result.patch);
|
|
1104
|
+
return result.id;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Animation tick — runs while any shape carries
|
|
1108
|
+
* `metadata.animated` (GIFs today; future video / lottie).
|
|
1109
|
+
* Forces a full re-render every frame so drawImage picks up the
|
|
1110
|
+
* current frame of natively-animated elements. Self-terminates
|
|
1111
|
+
* when no animated shapes remain.
|
|
1112
|
+
*
|
|
1113
|
+
* Lifecycle managed by the `AnimationTick` helper (see
|
|
1114
|
+
* `./animation-tick.ts`). `insertImage({animated:true})` and
|
|
1115
|
+
* `loadScene` start the tick; `dispose()` stops it.
|
|
1116
|
+
*/
|
|
1117
|
+
/** EMA of animation-tick render cost (ms) — drives the adaptive throttle. */
|
|
1118
|
+
gifRenderCostEma = 0;
|
|
1119
|
+
/** Wall-clock of the last animation-tick render — for the interval throttle. */
|
|
1120
|
+
lastGifTickMs = 0;
|
|
1121
|
+
animationTick = new AnimationTick({
|
|
1122
|
+
// Keep ticking only while an animated shape is actually on-screen.
|
|
1123
|
+
// Frame selection is wall-clock-based, so when the GIF scrolls back
|
|
1124
|
+
// into view the tick resumes on the correct frame. The tick is re-armed
|
|
1125
|
+
// on viewport changes via `maybeAnimate()` in `notify()`.
|
|
1126
|
+
isAnimated: () => this.hasVisibleAnimatedElement(),
|
|
1127
|
+
onTick: () => {
|
|
1128
|
+
// Adaptive throttle — skip this rAF if an animation frame was rendered
|
|
1129
|
+
// too recently. The target interval grows with the measured render
|
|
1130
|
+
// cost so a heavy scene drops GIF fps instead of blowing the frame
|
|
1131
|
+
// budget.
|
|
1132
|
+
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
1133
|
+
const target = Math.min(ANIMATION_MAX_INTERVAL_MS, Math.max(ANIMATION_MIN_INTERVAL_MS, this.gifRenderCostEma * ANIMATION_COST_FACTOR));
|
|
1134
|
+
if (now - this.lastGifTickMs < target)
|
|
1135
|
+
return;
|
|
1136
|
+
this.lastGifTickMs = now;
|
|
1137
|
+
// Freeze heavy GIFs that have played long enough.
|
|
1138
|
+
this.autoStopHeavyGifs();
|
|
1139
|
+
// Force a full re-render: the scene reference hasn't changed,
|
|
1140
|
+
// but the animation adapter advanced the GIF frame. Re-painting
|
|
1141
|
+
// picks up the current frame.
|
|
1142
|
+
this.lastRenderedScene = null;
|
|
1143
|
+
this.render();
|
|
1144
|
+
const cost = (typeof performance !== "undefined" ? performance.now() : Date.now()) - now;
|
|
1145
|
+
// EMA so a single spike doesn't overreact; decays back when load drops.
|
|
1146
|
+
this.gifRenderCostEma = this.gifRenderCostEma * 0.8 + cost * 0.2;
|
|
1147
|
+
},
|
|
1148
|
+
});
|
|
1149
|
+
// Pure body in `./editor/public/image-insert.ts`.
|
|
1150
|
+
hasAnimatedElement() {
|
|
1151
|
+
return hasAnimatedElement(this._scene);
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* True when at least one animated shape's world AABB intersects the
|
|
1155
|
+
* current viewport. Drives viewport-culling of the animation tick —
|
|
1156
|
+
* off-screen GIFs don't burn decode / render cost, and the wall-clock
|
|
1157
|
+
* frame selection means they show the right frame the moment they
|
|
1158
|
+
* scroll back in.
|
|
1159
|
+
*/
|
|
1160
|
+
hasVisibleAnimatedElement() {
|
|
1161
|
+
if (!hasAnimatedElement(this._scene))
|
|
1162
|
+
return false;
|
|
1163
|
+
const viewport = this.computeViewportWorld();
|
|
1164
|
+
if (!viewport)
|
|
1165
|
+
return true; // no viewport yet — don't suppress
|
|
1166
|
+
for (const shape of this._scene.elements.values()) {
|
|
1167
|
+
if (shape.metadata?.animated !== true)
|
|
1168
|
+
continue;
|
|
1169
|
+
if (B.intersects(getElementWorldBounds(shape), viewport))
|
|
1170
|
+
return true;
|
|
1171
|
+
}
|
|
1172
|
+
return false;
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Re-arm the animation tick after a change that may have brought an
|
|
1176
|
+
* animated shape into (or out of) view — pan / zoom / scene edit.
|
|
1177
|
+
* `AnimationTick.start()` no-ops when already running or when
|
|
1178
|
+
* `isAnimated()` is false, so this is cheap to call from `notify()`.
|
|
1179
|
+
*/
|
|
1180
|
+
maybeAnimate() {
|
|
1181
|
+
if (this.hasVisibleAnimatedElement())
|
|
1182
|
+
this.animationTick.start();
|
|
1183
|
+
}
|
|
1184
|
+
/** Bound `visibilitychange` handler — pause/resume the tick. */
|
|
1185
|
+
onVisibilityChange = () => {
|
|
1186
|
+
if (typeof document === "undefined")
|
|
1187
|
+
return;
|
|
1188
|
+
if (document.hidden) {
|
|
1189
|
+
this.animationTick.stop();
|
|
1190
|
+
}
|
|
1191
|
+
else {
|
|
1192
|
+
this.maybeAnimate();
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
// ── Per-shape GIF playback (auto-stop + reduced-motion) ──────
|
|
1196
|
+
/**
|
|
1197
|
+
* Transient per-shape playback state for animated images. `originMs`
|
|
1198
|
+
* is the wall-clock the current play run started; `frozenMs` is the
|
|
1199
|
+
* playback offset a paused shape is held at. Not serialised — purely
|
|
1200
|
+
* a runtime view, rebuilt on insert / rehydrate.
|
|
1201
|
+
*/
|
|
1202
|
+
playbackState = new Map();
|
|
1203
|
+
/** Element id currently hovered — a hovered heavy GIF keeps playing
|
|
1204
|
+
* (its auto-stop timer is held off). Set by the pointer hover path. */
|
|
1205
|
+
hoveredAnimatedId = null;
|
|
1206
|
+
static nowMs() {
|
|
1207
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
1208
|
+
}
|
|
1209
|
+
static prefersReducedMotion() {
|
|
1210
|
+
if (typeof matchMedia !== "function")
|
|
1211
|
+
return false;
|
|
1212
|
+
try {
|
|
1213
|
+
return matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
1214
|
+
}
|
|
1215
|
+
catch {
|
|
1216
|
+
return false;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Seed playback for a freshly-animated shape. Start paused (frozen on
|
|
1221
|
+
* frame 0) when the user prefers reduced motion; playing otherwise.
|
|
1222
|
+
*/
|
|
1223
|
+
initPlayback(id) {
|
|
1224
|
+
if (this.playbackState.has(id))
|
|
1225
|
+
return;
|
|
1226
|
+
const now = Editor.nowMs();
|
|
1227
|
+
this.playbackState.set(id, {
|
|
1228
|
+
playing: !Editor.prefersReducedMotion(),
|
|
1229
|
+
originMs: now,
|
|
1230
|
+
playStartMs: now,
|
|
1231
|
+
frozenMs: 0,
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
/** Playback timestamp fed to the renderer's animation clock for a
|
|
1235
|
+
* shape: wall-clock when unmanaged, play offset when playing, the
|
|
1236
|
+
* frozen frame when paused. */
|
|
1237
|
+
playbackClock(elementId) {
|
|
1238
|
+
const st = this.playbackState.get(elementId);
|
|
1239
|
+
const now = Editor.nowMs();
|
|
1240
|
+
if (!st)
|
|
1241
|
+
return now;
|
|
1242
|
+
return st.playing ? now - st.originMs : st.frozenMs;
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Toggle GIF playback for a shape — wired to a click on an animated
|
|
1246
|
+
* image (resume after auto-stop, play after reduced-motion). Resuming
|
|
1247
|
+
* continues from the frozen frame.
|
|
1248
|
+
*/
|
|
1249
|
+
togglePlayback(id) {
|
|
1250
|
+
const now = Editor.nowMs();
|
|
1251
|
+
const st = this.playbackState.get(id);
|
|
1252
|
+
if (!st) {
|
|
1253
|
+
this.playbackState.set(id, { playing: true, originMs: now, playStartMs: now, frozenMs: 0 });
|
|
1254
|
+
}
|
|
1255
|
+
else if (st.playing) {
|
|
1256
|
+
st.frozenMs = now - st.originMs;
|
|
1257
|
+
st.playing = false;
|
|
1258
|
+
}
|
|
1259
|
+
else {
|
|
1260
|
+
// Resume from the frozen frame AND restart the auto-stop timer,
|
|
1261
|
+
// otherwise a heavy GIF (frozen past GIF_AUTOSTOP_MS) would
|
|
1262
|
+
// re-trip auto-stop on the very next tick — playing one frame
|
|
1263
|
+
// then freezing again.
|
|
1264
|
+
st.originMs = now - st.frozenMs;
|
|
1265
|
+
st.playStartMs = now;
|
|
1266
|
+
st.playing = true;
|
|
1267
|
+
}
|
|
1268
|
+
this.maybeAnimate();
|
|
1269
|
+
this.scheduleRender();
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Hover entered an animated shape: resume it if paused and hold off
|
|
1273
|
+
* its auto-stop timer while the pointer stays over it (the auto-stop
|
|
1274
|
+
* pass keeps pushing `playStartMs` forward for the hovered shape).
|
|
1275
|
+
* Pass `null` when the pointer leaves all shapes.
|
|
1276
|
+
*/
|
|
1277
|
+
hoverAnimatedElement(id) {
|
|
1278
|
+
if (this.hoveredAnimatedId === id)
|
|
1279
|
+
return;
|
|
1280
|
+
this.hoveredAnimatedId = id;
|
|
1281
|
+
if (id !== null) {
|
|
1282
|
+
const st = this.playbackState.get(id);
|
|
1283
|
+
const now = Editor.nowMs();
|
|
1284
|
+
if (st && !st.playing) {
|
|
1285
|
+
st.originMs = now - st.frozenMs;
|
|
1286
|
+
st.playStartMs = now;
|
|
1287
|
+
st.playing = true;
|
|
1288
|
+
this.maybeAnimate();
|
|
1289
|
+
this.scheduleRender();
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
/** True when the shape's GIF is paused (drives the overlay badge). */
|
|
1294
|
+
isPlaybackPaused(id) {
|
|
1295
|
+
return this.playbackState.get(id)?.playing === false;
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Record the idle cursor position so the overlay can grow the SINGLE
|
|
1299
|
+
* selected element's link-start dot nearest the cursor. Only the selected
|
|
1300
|
+
* element shows start dots (connecting from an unselected element on hover
|
|
1301
|
+
* was a cancelled product decision). Pass `null` to clear.
|
|
1302
|
+
*/
|
|
1303
|
+
setHoverCursorWorld(cursor) {
|
|
1304
|
+
this.hoverCursorWorld = cursor;
|
|
1305
|
+
// Dots only render for a single selection; skip notify otherwise.
|
|
1306
|
+
if (this._selection.size === 1)
|
|
1307
|
+
this.notify();
|
|
1308
|
+
}
|
|
1309
|
+
/** Live link-draw preview polyline (elbow), or null when not drawing. */
|
|
1310
|
+
get linkPreviewPath() {
|
|
1311
|
+
return this.edgePreview?.points ?? null;
|
|
1312
|
+
}
|
|
1313
|
+
/** Current connector attach target + mode (point=fixed / element=floating). */
|
|
1314
|
+
get linkAttachTarget() {
|
|
1315
|
+
const t = this.hoveredLinkTarget;
|
|
1316
|
+
return t ? { elementId: t.elementId, mode: t.mode } : null;
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Freeze heavy GIFs after `GIF_AUTOSTOP_MS` of continuous play.
|
|
1320
|
+
* Light GIFs (small byte payload) loop forever. Called from the tick
|
|
1321
|
+
* before each animation render.
|
|
1322
|
+
*/
|
|
1323
|
+
autoStopHeavyGifs() {
|
|
1324
|
+
const now = Editor.nowMs();
|
|
1325
|
+
for (const shape of this._scene.elements.values()) {
|
|
1326
|
+
if (shape.type !== "image")
|
|
1327
|
+
continue;
|
|
1328
|
+
const img = shape;
|
|
1329
|
+
if (!img.animationKind)
|
|
1330
|
+
continue;
|
|
1331
|
+
const st = this.playbackState.get(img.id);
|
|
1332
|
+
if (!st?.playing)
|
|
1333
|
+
continue;
|
|
1334
|
+
const heavy = img.animationData instanceof ArrayBuffer && img.animationData.byteLength > HEAVY_GIF_BYTES;
|
|
1335
|
+
if (!heavy)
|
|
1336
|
+
continue;
|
|
1337
|
+
// Hovered heavy GIF keeps playing — push its timer forward so it
|
|
1338
|
+
// never auto-stops while the pointer is over it.
|
|
1339
|
+
if (img.id === this.hoveredAnimatedId) {
|
|
1340
|
+
st.playStartMs = now;
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
if (now - st.playStartMs > GIF_AUTOSTOP_MS) {
|
|
1344
|
+
st.frozenMs = now - st.originMs;
|
|
1345
|
+
st.playing = false;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Restore transient `animationData` for animated image shapes after
|
|
1351
|
+
* a scene load. The raw GIF bytes don't survive serialisation
|
|
1352
|
+
* (`serializeScene` strips the ArrayBuffer), but they're persisted
|
|
1353
|
+
* in `Scene.files` via the shape's `fileId`. Here we copy the bytes
|
|
1354
|
+
* back onto `shape.animationData` so the registered animation
|
|
1355
|
+
* adapter (host-side, e.g. the gifuct decoder) can produce frames.
|
|
1356
|
+
*
|
|
1357
|
+
* Applied directly to `_scene` (no history entry — this is an
|
|
1358
|
+
* internal rehydration, not a user edit). No-op for shapes that
|
|
1359
|
+
* already carry live `animationData` or lack a resolvable file.
|
|
1360
|
+
*/
|
|
1361
|
+
rehydrateAnimatedImages() {
|
|
1362
|
+
for (const shape of this._scene.elements.values()) {
|
|
1363
|
+
if (shape.type !== "image")
|
|
1364
|
+
continue;
|
|
1365
|
+
const img = shape;
|
|
1366
|
+
if (!img.animationKind)
|
|
1367
|
+
continue;
|
|
1368
|
+
// Seed playback for every animated shape loaded from the scene
|
|
1369
|
+
// (reduced-motion is honoured at this point too).
|
|
1370
|
+
this.initPlayback(img.id);
|
|
1371
|
+
if (!img.fileId)
|
|
1372
|
+
continue;
|
|
1373
|
+
if (img.animationData instanceof ArrayBuffer)
|
|
1374
|
+
continue; // already live
|
|
1375
|
+
const file = getBinaryFile(this._scene, img.fileId);
|
|
1376
|
+
if (!file)
|
|
1377
|
+
continue;
|
|
1378
|
+
this._scene = apply(this._scene, {
|
|
1379
|
+
kind: "element",
|
|
1380
|
+
id: img.id,
|
|
1381
|
+
before: img,
|
|
1382
|
+
after: { ...img, animationData: file.data },
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Drag-to-place flow for palette templates. Adds the shape to the
|
|
1388
|
+
* scene immediately so the user sees it dragging under the cursor,
|
|
1389
|
+
* but defers the history entry until `commit()` is called. `update`
|
|
1390
|
+
* re-positions without writing per-move patches; `cancel` removes
|
|
1391
|
+
* the shape entirely and leaves history untouched (no undo entry).
|
|
1392
|
+
*
|
|
1393
|
+
* Typical wiring: HTML5 dragenter starts the placement, dragover
|
|
1394
|
+
* updates, drop commits, dragleave / window keydown(Escape) cancel.
|
|
1395
|
+
*/
|
|
1396
|
+
// Placement helpers live in `./editor/public/placement.ts`.
|
|
1397
|
+
// Editor owns the transaction lifecycle and selection mutate;
|
|
1398
|
+
// the closure threads scene mutations through the pure helpers.
|
|
1399
|
+
beginPlacement(shape) {
|
|
1400
|
+
const tx = this._history.transaction();
|
|
1401
|
+
const { scene: add, state: initialState } = beginPlacementState(shape);
|
|
1402
|
+
const initial = add(this._scene);
|
|
1403
|
+
this._scene = initial.scene;
|
|
1404
|
+
this._selection = Selection.single(shape.id);
|
|
1405
|
+
this.notify();
|
|
1406
|
+
const state = { ...initialState };
|
|
1407
|
+
return {
|
|
1408
|
+
update: (worldCenter) => {
|
|
1409
|
+
const r = computePlacementUpdate(this._scene, state, worldCenter);
|
|
1410
|
+
this._scene = r.scene;
|
|
1411
|
+
state.current = r.next;
|
|
1412
|
+
this.notify();
|
|
1413
|
+
},
|
|
1414
|
+
commit: () => {
|
|
1415
|
+
const drop = computePlacementContainerDrop(this._scene, state);
|
|
1416
|
+
if (drop) {
|
|
1417
|
+
this._scene = drop.scene;
|
|
1418
|
+
state.current = drop.next;
|
|
1419
|
+
}
|
|
1420
|
+
tx.add({ kind: "element", id: shape.id, before: null, after: state.current });
|
|
1421
|
+
tx.commit();
|
|
1422
|
+
// Notify is mandatory here. The dragover snapshots carried the
|
|
1423
|
+
// placement preview WITHOUT `parentId`, so the
|
|
1424
|
+
// AutoLayoutScheduler's `signatureFor(parent)` did not include the
|
|
1425
|
+
// new child and no `runAutoLayout` was scheduled. The reparent above
|
|
1426
|
+
// set `parentId`; this final `notify()` lets the scheduler see the
|
|
1427
|
+
// change so the child is laid out immediately instead of on the next
|
|
1428
|
+
// unrelated notification.
|
|
1429
|
+
this.notify();
|
|
1430
|
+
},
|
|
1431
|
+
cancel: () => {
|
|
1432
|
+
const { scene } = computePlacementCancel(this._scene, shape.id);
|
|
1433
|
+
this._scene = scene;
|
|
1434
|
+
tx.cancel();
|
|
1435
|
+
this._selection = Selection.EMPTY;
|
|
1436
|
+
this.notify();
|
|
1437
|
+
},
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
// Pure body in `./editor/public/selection-ops.ts`.
|
|
1441
|
+
deleteSelected() {
|
|
1442
|
+
const result = computeDeleteSelection(this._scene, this._selection, this._selectedLinks);
|
|
1443
|
+
if (!result)
|
|
1444
|
+
return;
|
|
1445
|
+
const tx = this._history.transaction();
|
|
1446
|
+
this._scene = result.scene;
|
|
1447
|
+
for (const patch of result.patches)
|
|
1448
|
+
tx.add(patch);
|
|
1449
|
+
tx.commit();
|
|
1450
|
+
this._selection = Selection.EMPTY;
|
|
1451
|
+
this._selectedLinks = LinkSelection.EMPTY;
|
|
1452
|
+
this.notify();
|
|
1453
|
+
}
|
|
1454
|
+
// --- Inline text editing ---
|
|
1455
|
+
/**
|
|
1456
|
+
* Currently edited text shape (or null). Set by `beginTextEdit`;
|
|
1457
|
+
* cleared by `commitTextEdit` / `cancelTextEdit`. The host overlay
|
|
1458
|
+
* (`<TextEditorOverlay>` in `@react-ui`) subscribes via `editor`
|
|
1459
|
+
* and renders a `<textarea>` positioned over the shape.
|
|
1460
|
+
*/
|
|
1461
|
+
_editingTextElement = null;
|
|
1462
|
+
get editingTextElement() {
|
|
1463
|
+
return this._editingTextElement;
|
|
1464
|
+
}
|
|
1465
|
+
/** Link whose caption is being edited inline (double-click), or null. */
|
|
1466
|
+
_editingLinkCaption = null;
|
|
1467
|
+
get editingLinkCaption() {
|
|
1468
|
+
return this._editingLinkCaption;
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Frame whose NAME (header label) is being edited inline (double-click
|
|
1472
|
+
* the header), or null. The host overlay (`<FrameNameEditorOverlay>` in
|
|
1473
|
+
* `@react-ui`) renders an input over the header and commits the name.
|
|
1474
|
+
*/
|
|
1475
|
+
_editingFrameName = null;
|
|
1476
|
+
get editingFrameName() {
|
|
1477
|
+
return this._editingFrameName;
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* When the `draw-text` tool just placed a shape and opened its
|
|
1481
|
+
* editor, this holds that shape's id until the first commit. A
|
|
1482
|
+
* pending creation isn't in history yet: committing non-empty text
|
|
1483
|
+
* records a single add patch (whole shape = one undo); committing
|
|
1484
|
+
* empty / cancelling removes it with no history entry at all.
|
|
1485
|
+
*/
|
|
1486
|
+
_pendingTextCreate = null;
|
|
1487
|
+
/**
|
|
1488
|
+
* Snapshot of the shape at edit start. Used to revert on cancel and
|
|
1489
|
+
* as the `before` of the single commit patch. `null` for a pending
|
|
1490
|
+
* creation (the shape didn't exist yet).
|
|
1491
|
+
*/
|
|
1492
|
+
_textEditOrigin = null;
|
|
1493
|
+
/**
|
|
1494
|
+
* Live selection inside the edited text, mirrored from the hidden
|
|
1495
|
+
* `<textarea>` (`start`/`end` are source offsets, `dir` is the
|
|
1496
|
+
* anchored end). The caret is `dir === "backward" ? start : end`.
|
|
1497
|
+
*/
|
|
1498
|
+
_textSel = null;
|
|
1499
|
+
/** Anchor offset for a canvas drag-select inside the edited text. */
|
|
1500
|
+
_textDragAnchor = null;
|
|
1501
|
+
_caretBlinkOn = true;
|
|
1502
|
+
_caretBlinkTimer = null;
|
|
1503
|
+
get editingTextSelection() {
|
|
1504
|
+
return this._textSel;
|
|
1505
|
+
}
|
|
1506
|
+
/** Caret offset = the moving end of the selection. */
|
|
1507
|
+
get editingTextCaret() {
|
|
1508
|
+
if (!this._textSel)
|
|
1509
|
+
return null;
|
|
1510
|
+
return this._textSel.dir === "backward" ? this._textSel.start : this._textSel.end;
|
|
1511
|
+
}
|
|
1512
|
+
get caretBlinkOn() {
|
|
1513
|
+
return this._caretBlinkOn;
|
|
1514
|
+
}
|
|
1515
|
+
/** `true` while a canvas drag-select inside the edited text is active. */
|
|
1516
|
+
get isTextDragging() {
|
|
1517
|
+
return this._textDragAnchor !== null;
|
|
1518
|
+
}
|
|
1519
|
+
startCaretBlink() {
|
|
1520
|
+
this._caretBlinkOn = true;
|
|
1521
|
+
this.stopCaretBlink();
|
|
1522
|
+
// Only run the blink when a DOM clock exists (browser host). Node
|
|
1523
|
+
// test envs construct the editor without a window — skip so a
|
|
1524
|
+
// dangling interval can't keep the process alive.
|
|
1525
|
+
if (typeof window === "undefined")
|
|
1526
|
+
return;
|
|
1527
|
+
this._caretBlinkTimer = setInterval(() => {
|
|
1528
|
+
this._caretBlinkOn = !this._caretBlinkOn;
|
|
1529
|
+
this.notify();
|
|
1530
|
+
}, CARET_BLINK_INTERVAL_MS);
|
|
1531
|
+
}
|
|
1532
|
+
stopCaretBlink() {
|
|
1533
|
+
if (this._caretBlinkTimer !== null) {
|
|
1534
|
+
clearInterval(this._caretBlinkTimer);
|
|
1535
|
+
this._caretBlinkTimer = null;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
/** Reset the caret to solid (called on type / move so it never blinks off mid-action). */
|
|
1539
|
+
wakeCaret() {
|
|
1540
|
+
this._caretBlinkOn = true;
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Begin editing a text shape's body. No-op when the shape doesn't
|
|
1544
|
+
* exist or isn't a text shape. Concurrent edits commit themselves
|
|
1545
|
+
* (only one shape at a time). Caret defaults to the end of the text.
|
|
1546
|
+
*/
|
|
1547
|
+
// Pure bodies in `./editor/public/text-edit.ts`.
|
|
1548
|
+
/** Open inline caption editing for a link (double-click). */
|
|
1549
|
+
beginLinkCaptionEdit(id) {
|
|
1550
|
+
if (!getLink(this._scene, id))
|
|
1551
|
+
return;
|
|
1552
|
+
if (this._editingTextElement !== null)
|
|
1553
|
+
this.commitTextEdit();
|
|
1554
|
+
this._editingLinkCaption = id;
|
|
1555
|
+
this.notify();
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Commit the link caption. Empty / whitespace text removes the label;
|
|
1559
|
+
* otherwise the label text is set, preserving any existing position /
|
|
1560
|
+
* styling. One undo step. Clears caption-edit mode.
|
|
1561
|
+
*/
|
|
1562
|
+
commitLinkCaptionEdit(text) {
|
|
1563
|
+
const id = this._editingLinkCaption;
|
|
1564
|
+
this._editingLinkCaption = null;
|
|
1565
|
+
if (id === null) {
|
|
1566
|
+
this.notify();
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
const edge = getLink(this._scene, id);
|
|
1570
|
+
if (edge) {
|
|
1571
|
+
const trimmed = text.trim();
|
|
1572
|
+
const nextLabel = trimmed === "" ? undefined : { ...(edge.label ?? {}), text: trimmed };
|
|
1573
|
+
const same = (edge.label?.text ?? "") === (nextLabel?.text ?? "");
|
|
1574
|
+
if (!same) {
|
|
1575
|
+
const r = updateLink(this._scene, id, (e) => {
|
|
1576
|
+
const next = { ...e };
|
|
1577
|
+
if (nextLabel)
|
|
1578
|
+
next.label = nextLabel;
|
|
1579
|
+
else
|
|
1580
|
+
delete next.label;
|
|
1581
|
+
return next;
|
|
1582
|
+
});
|
|
1583
|
+
this._scene = r.scene;
|
|
1584
|
+
this._history.push(r.patch);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
this.notify();
|
|
1588
|
+
}
|
|
1589
|
+
/** Cancel link caption editing without changing the label. */
|
|
1590
|
+
cancelLinkCaptionEdit() {
|
|
1591
|
+
if (this._editingLinkCaption === null)
|
|
1592
|
+
return;
|
|
1593
|
+
this._editingLinkCaption = null;
|
|
1594
|
+
this.notify();
|
|
1595
|
+
}
|
|
1596
|
+
/** World-space anchor point for a link's caption (midpoint of its path). */
|
|
1597
|
+
linkLabelWorld(id) {
|
|
1598
|
+
const edge = getLink(this._scene, id);
|
|
1599
|
+
if (!edge)
|
|
1600
|
+
return null;
|
|
1601
|
+
const path = getLinkPath(this._scene, edge);
|
|
1602
|
+
if (!path || path.length < 2)
|
|
1603
|
+
return null;
|
|
1604
|
+
const t = edge.label?.position ?? 0.5;
|
|
1605
|
+
let total = 0;
|
|
1606
|
+
for (let i = 1; i < path.length; i++)
|
|
1607
|
+
total += distanceTo(req(path[i - 1]), req(path[i]));
|
|
1608
|
+
let remaining = total * t;
|
|
1609
|
+
for (let i = 1; i < path.length; i++) {
|
|
1610
|
+
const a = req(path[i - 1]);
|
|
1611
|
+
const b = req(path[i]);
|
|
1612
|
+
const seg = distanceTo(a, b);
|
|
1613
|
+
if (remaining <= seg) {
|
|
1614
|
+
const r = seg === 0 ? 0 : remaining / seg;
|
|
1615
|
+
return { x: a.x + (b.x - a.x) * r, y: a.y + (b.y - a.y) * r };
|
|
1616
|
+
}
|
|
1617
|
+
remaining -= seg;
|
|
1618
|
+
}
|
|
1619
|
+
return req(path[path.length - 1]);
|
|
1620
|
+
}
|
|
1621
|
+
beginTextEdit(id) {
|
|
1622
|
+
if (!canBeginTextEdit(this._scene, id, (lid) => this.isLayerLocked(lid)))
|
|
1623
|
+
return;
|
|
1624
|
+
// Commit any in-flight edit on a different shape first.
|
|
1625
|
+
if (this._editingTextElement !== null && this._editingTextElement !== id)
|
|
1626
|
+
this.commitTextEdit();
|
|
1627
|
+
this._editingTextElement = id;
|
|
1628
|
+
this._textEditOrigin =
|
|
1629
|
+
this._pendingTextCreate === id ? null : (getElement(this._scene, id) ?? null);
|
|
1630
|
+
const shape = getElement(this._scene, id);
|
|
1631
|
+
const len = shape?.text.length ?? 0;
|
|
1632
|
+
this._textSel = { start: len, end: len, dir: "forward" };
|
|
1633
|
+
this.startCaretBlink();
|
|
1634
|
+
this.notify();
|
|
1635
|
+
}
|
|
1636
|
+
// --- Frame name inline editing (double-click the header) ---
|
|
1637
|
+
/**
|
|
1638
|
+
* Start editing a frame's header name. No-op unless `id` is a frame on
|
|
1639
|
+
* an unlocked layer. Commits any in-flight text edit first.
|
|
1640
|
+
*/
|
|
1641
|
+
beginFrameNameEdit(id) {
|
|
1642
|
+
const shape = getElement(this._scene, id);
|
|
1643
|
+
if (shape?.type !== "frame")
|
|
1644
|
+
return;
|
|
1645
|
+
if (this.isLayerLocked(shape.layerId))
|
|
1646
|
+
return;
|
|
1647
|
+
if (this._editingTextElement !== null)
|
|
1648
|
+
this.commitTextEdit();
|
|
1649
|
+
this._editingFrameName = id;
|
|
1650
|
+
this.notify();
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Commit the edited frame name. Empty / whitespace-only clears the
|
|
1654
|
+
* stored name (the renderer falls back to "Frame"). One history step;
|
|
1655
|
+
* no-op when the name is unchanged. Always clears the editing state.
|
|
1656
|
+
*/
|
|
1657
|
+
commitFrameNameEdit(name) {
|
|
1658
|
+
const id = this._editingFrameName;
|
|
1659
|
+
if (id === null)
|
|
1660
|
+
return;
|
|
1661
|
+
this._editingFrameName = null;
|
|
1662
|
+
const shape = getElement(this._scene, id);
|
|
1663
|
+
if (shape?.type === "frame") {
|
|
1664
|
+
const trimmed = name.trim();
|
|
1665
|
+
const current = shape.name ?? "";
|
|
1666
|
+
if (trimmed !== current) {
|
|
1667
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
1668
|
+
const copy = { ...s };
|
|
1669
|
+
// `exactOptionalPropertyTypes`: drop the key when cleared.
|
|
1670
|
+
if (trimmed === "")
|
|
1671
|
+
delete copy.name;
|
|
1672
|
+
else
|
|
1673
|
+
copy.name = trimmed;
|
|
1674
|
+
return copy;
|
|
1675
|
+
});
|
|
1676
|
+
this._scene = r.scene;
|
|
1677
|
+
this._history.push(r.patch);
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
this.notify();
|
|
1681
|
+
}
|
|
1682
|
+
/** Abandon the frame-name edit without changing the name. */
|
|
1683
|
+
cancelFrameNameEdit() {
|
|
1684
|
+
if (this._editingFrameName === null)
|
|
1685
|
+
return;
|
|
1686
|
+
this._editingFrameName = null;
|
|
1687
|
+
this.notify();
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Frame whose header strip (the label bar ABOVE the body) contains the
|
|
1691
|
+
* world point — top-most by z-order. Used to route a double-click on the
|
|
1692
|
+
* header to a name edit, since the header sits outside the frame's
|
|
1693
|
+
* hit-test bounds. Assumes unrotated frames (the common case).
|
|
1694
|
+
*/
|
|
1695
|
+
frameHeaderAt(p) {
|
|
1696
|
+
let bestId = null;
|
|
1697
|
+
let bestOrder = "";
|
|
1698
|
+
for (const s of this._scene.elements.values()) {
|
|
1699
|
+
if (!isFrame(s))
|
|
1700
|
+
continue;
|
|
1701
|
+
const hx = s.position.x;
|
|
1702
|
+
// The header strip can extend up to the frame's full width (it hugs
|
|
1703
|
+
// the label but is capped there), so the rename hit zone spans it.
|
|
1704
|
+
const hw = s.width * s.scale.x;
|
|
1705
|
+
const hh = FRAME_HEADER_HEIGHT * s.scale.y;
|
|
1706
|
+
const hyTop = s.position.y - hh;
|
|
1707
|
+
if (p.x >= hx && p.x <= hx + hw && p.y >= hyTop && p.y <= hyTop + hh) {
|
|
1708
|
+
if (bestId === null || s.order > bestOrder) {
|
|
1709
|
+
bestId = s.id;
|
|
1710
|
+
bestOrder = s.order;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
return bestId;
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Live edit transport from the hidden `<textarea>`: replace the
|
|
1718
|
+
* shape's text + selection as the user types / pastes / composes.
|
|
1719
|
+
* Mutates the scene WITHOUT a history entry — history is recorded
|
|
1720
|
+
* once on commit. No-op when not editing.
|
|
1721
|
+
*/
|
|
1722
|
+
setEditingText(value, selStart, selEnd, dir = "forward") {
|
|
1723
|
+
const id = this._editingTextElement;
|
|
1724
|
+
if (!id)
|
|
1725
|
+
return;
|
|
1726
|
+
const r = updateElement(this._scene, id, (s) => ({ ...s, text: value }));
|
|
1727
|
+
this._scene = r.scene;
|
|
1728
|
+
this._textSel = { start: selStart, end: selEnd, dir };
|
|
1729
|
+
this.wakeCaret();
|
|
1730
|
+
this.notify();
|
|
1731
|
+
}
|
|
1732
|
+
/** Selection-only update (arrows / shift-select / click) — no text change. */
|
|
1733
|
+
setEditingSelection(selStart, selEnd, dir = "forward") {
|
|
1734
|
+
if (!this._editingTextElement)
|
|
1735
|
+
return;
|
|
1736
|
+
this._textSel = { start: selStart, end: selEnd, dir };
|
|
1737
|
+
this.wakeCaret();
|
|
1738
|
+
this.notify();
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* Map a world-space point to a caret offset in the edited text. Used
|
|
1742
|
+
* to place / extend the caret from canvas clicks. Returns `null` when
|
|
1743
|
+
* not editing or the shape is gone.
|
|
1744
|
+
*/
|
|
1745
|
+
caretIndexAtWorldPoint(worldPoint) {
|
|
1746
|
+
const id = this._editingTextElement;
|
|
1747
|
+
if (!id)
|
|
1748
|
+
return null;
|
|
1749
|
+
const shape = getElement(this._scene, id);
|
|
1750
|
+
if (shape?.type !== "text")
|
|
1751
|
+
return null;
|
|
1752
|
+
const layout = this.editingTextLayout(shape);
|
|
1753
|
+
if (!layout)
|
|
1754
|
+
return null;
|
|
1755
|
+
// World → shape-local (translate by position; rotation/scale on text
|
|
1756
|
+
// edit is uncommon — ignore for hit purposes).
|
|
1757
|
+
const local = { x: worldPoint.x - shape.position.x, y: worldPoint.y - shape.position.y };
|
|
1758
|
+
const align = shape.style.textAlign ?? "left";
|
|
1759
|
+
return pointToCaretIndex(layout, local, this.measureFor(shape), align);
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* `true` when a point is inside the currently-edited text shape's
|
|
1763
|
+
* world bounds. Used by the pointer binding to decide between
|
|
1764
|
+
* repositioning the caret (inside) and committing (outside).
|
|
1765
|
+
*/
|
|
1766
|
+
editedElementContainsPoint(worldPoint) {
|
|
1767
|
+
const id = this._editingTextElement;
|
|
1768
|
+
if (!id)
|
|
1769
|
+
return false;
|
|
1770
|
+
const shape = getElement(this._scene, id);
|
|
1771
|
+
if (!shape)
|
|
1772
|
+
return false;
|
|
1773
|
+
const b = getElementWorldBounds(shape);
|
|
1774
|
+
return (worldPoint.x >= b.x &&
|
|
1775
|
+
worldPoint.x <= b.x + b.width &&
|
|
1776
|
+
worldPoint.y >= b.y &&
|
|
1777
|
+
worldPoint.y <= b.y + b.height);
|
|
1778
|
+
}
|
|
1779
|
+
/** Place a collapsed caret at the clicked point and start a drag-select. */
|
|
1780
|
+
setTextCaretFromPoint(worldPoint) {
|
|
1781
|
+
const idx = this.caretIndexAtWorldPoint(worldPoint);
|
|
1782
|
+
if (idx === null)
|
|
1783
|
+
return;
|
|
1784
|
+
this._textDragAnchor = idx;
|
|
1785
|
+
this.setEditingSelection(idx, idx, "forward");
|
|
1786
|
+
}
|
|
1787
|
+
/** Extend the selection from the drag anchor to the current point. */
|
|
1788
|
+
extendTextSelectionToPoint(worldPoint) {
|
|
1789
|
+
if (this._textDragAnchor === null)
|
|
1790
|
+
return;
|
|
1791
|
+
const idx = this.caretIndexAtWorldPoint(worldPoint);
|
|
1792
|
+
if (idx === null)
|
|
1793
|
+
return;
|
|
1794
|
+
const anchor = this._textDragAnchor;
|
|
1795
|
+
if (idx >= anchor)
|
|
1796
|
+
this.setEditingSelection(anchor, idx, "forward");
|
|
1797
|
+
else
|
|
1798
|
+
this.setEditingSelection(idx, anchor, "backward");
|
|
1799
|
+
}
|
|
1800
|
+
/** End a canvas drag-select (clears the drag anchor). */
|
|
1801
|
+
endTextDragSelect() {
|
|
1802
|
+
this._textDragAnchor = null;
|
|
1803
|
+
}
|
|
1804
|
+
/** Build the editable layout for a text shape using the main target's metrics. */
|
|
1805
|
+
editingTextLayout(shape) {
|
|
1806
|
+
return layoutText(shape.text, this.measureFor(shape), {
|
|
1807
|
+
fontSize: shape.fontSize,
|
|
1808
|
+
...(shape.maxWidth !== undefined ? { maxWidth: shape.maxWidth } : {}),
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
/**
|
|
1812
|
+
* A measure callback bound to a shape's font, using the main target's
|
|
1813
|
+
* `measureText` — the SAME source the renderer draws with (WebGL2
|
|
1814
|
+
* reports MSDF advances) and the bounder measures with. Caret /
|
|
1815
|
+
* selection geometry therefore lines up exactly with the glyphs.
|
|
1816
|
+
*/
|
|
1817
|
+
measureFor(shape) {
|
|
1818
|
+
const target = this.mainTarget;
|
|
1819
|
+
// Match the rendered weight/style so caret / selection geometry lines
|
|
1820
|
+
// up with bold / italic glyphs (which have different advances).
|
|
1821
|
+
target.setFont(shape.fontFamily, shape.fontSize, {
|
|
1822
|
+
...(shape.style.fontWeight === "bold" ? { weight: "bold" } : {}),
|
|
1823
|
+
...(shape.style.fontStyle === "italic" ? { style: "italic" } : {}),
|
|
1824
|
+
});
|
|
1825
|
+
return (s) => target.measureText(s).width;
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* World-space caret + selection geometry for the overlay pass.
|
|
1829
|
+
* Returns `null` when not editing. The caret is `null` while blinked
|
|
1830
|
+
* off so the overlay can simply skip drawing it.
|
|
1831
|
+
*/
|
|
1832
|
+
editingTextOverlay() {
|
|
1833
|
+
const id = this._editingTextElement;
|
|
1834
|
+
if (!id || !this._textSel)
|
|
1835
|
+
return null;
|
|
1836
|
+
const shape = getElement(this._scene, id);
|
|
1837
|
+
if (shape?.type !== "text")
|
|
1838
|
+
return null;
|
|
1839
|
+
const layout = this.editingTextLayout(shape);
|
|
1840
|
+
if (!layout)
|
|
1841
|
+
return null;
|
|
1842
|
+
const align = shape.style.textAlign ?? "left";
|
|
1843
|
+
const measure = this.measureFor(shape);
|
|
1844
|
+
const { x: px, y: py } = shape.position;
|
|
1845
|
+
const local = textSelectionRects(layout, this._textSel.start, this._textSel.end, measure, align);
|
|
1846
|
+
const selectionRects = local.map((r) => ({
|
|
1847
|
+
x: px + r.x,
|
|
1848
|
+
y: py + r.y,
|
|
1849
|
+
width: r.width,
|
|
1850
|
+
height: r.height,
|
|
1851
|
+
}));
|
|
1852
|
+
let caret = null;
|
|
1853
|
+
if (this._caretBlinkOn) {
|
|
1854
|
+
const cIdx = this._textSel.dir === "backward" ? this._textSel.start : this._textSel.end;
|
|
1855
|
+
const g = caretGeometry(layout, cIdx, measure, shape.fontSize, align);
|
|
1856
|
+
caret = { x: px + g.x, y: py + g.y, height: g.height };
|
|
1857
|
+
}
|
|
1858
|
+
return { caret, caretColor: shape.style.fill ?? "#1a1a1a", selectionRects };
|
|
1859
|
+
}
|
|
1860
|
+
commitTextEdit(next) {
|
|
1861
|
+
const id = this._editingTextElement;
|
|
1862
|
+
if (!id)
|
|
1863
|
+
return;
|
|
1864
|
+
const pending = this._pendingTextCreate === id;
|
|
1865
|
+
const origin = this._textEditOrigin;
|
|
1866
|
+
// Optional explicit text (keyboard / test callers); the live path
|
|
1867
|
+
// passes nothing because the scene already holds the typed text.
|
|
1868
|
+
if (next !== undefined) {
|
|
1869
|
+
this._scene = updateElement(this._scene, id, (s) => ({ ...s, text: next })).scene;
|
|
1870
|
+
}
|
|
1871
|
+
this._editingTextElement = null;
|
|
1872
|
+
this._pendingTextCreate = null;
|
|
1873
|
+
this._textEditOrigin = null;
|
|
1874
|
+
this._textSel = null;
|
|
1875
|
+
this.stopCaretBlink();
|
|
1876
|
+
const finalElement = getElement(this._scene, id);
|
|
1877
|
+
const text = finalElement?.text ?? "";
|
|
1878
|
+
// Empty (whitespace-only) text removes the shape. Pending = silent
|
|
1879
|
+
// (never recorded); existing = recorded so undo restores the origin.
|
|
1880
|
+
if (text.trim() === "") {
|
|
1881
|
+
if (finalElement) {
|
|
1882
|
+
this._scene = removeElement(this._scene, id).scene;
|
|
1883
|
+
if (!pending && origin) {
|
|
1884
|
+
this._history.push({ kind: "element", id, before: origin, after: null });
|
|
1885
|
+
}
|
|
1886
|
+
if (this._selection.has(id))
|
|
1887
|
+
this._selection = Selection.EMPTY;
|
|
1888
|
+
}
|
|
1889
|
+
this.notify();
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
if (pending) {
|
|
1893
|
+
// Record the whole creation as one add patch.
|
|
1894
|
+
if (finalElement)
|
|
1895
|
+
this._history.push({ kind: "element", id, before: null, after: finalElement });
|
|
1896
|
+
}
|
|
1897
|
+
else if (origin && finalElement) {
|
|
1898
|
+
// Existing edit: record ONLY the text delta. Other fields (font
|
|
1899
|
+
// size etc.) changed via the panel push their own history during
|
|
1900
|
+
// the edit, so the commit's `before` keeps the final non-text
|
|
1901
|
+
// state and rewinds just the text.
|
|
1902
|
+
const originText = origin.text;
|
|
1903
|
+
if (originText !== finalElement.text) {
|
|
1904
|
+
const before = { ...finalElement, text: originText };
|
|
1905
|
+
this._history.push({ kind: "element", id, before, after: finalElement });
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
this.notify();
|
|
1909
|
+
}
|
|
1910
|
+
cancelTextEdit() {
|
|
1911
|
+
const id = this._editingTextElement;
|
|
1912
|
+
if (id === null)
|
|
1913
|
+
return;
|
|
1914
|
+
const pending = this._pendingTextCreate === id;
|
|
1915
|
+
const origin = this._textEditOrigin;
|
|
1916
|
+
this._editingTextElement = null;
|
|
1917
|
+
this._pendingTextCreate = null;
|
|
1918
|
+
this._textEditOrigin = null;
|
|
1919
|
+
this._textSel = null;
|
|
1920
|
+
this.stopCaretBlink();
|
|
1921
|
+
// Revert live edits with no history entry. Pending creations are
|
|
1922
|
+
// removed entirely; existing shapes have only their TEXT restored
|
|
1923
|
+
// (panel-driven field changes during the edit keep their own
|
|
1924
|
+
// committed history and must survive the cancel).
|
|
1925
|
+
if (pending) {
|
|
1926
|
+
if (getElement(this._scene, id)) {
|
|
1927
|
+
this._scene = removeElement(this._scene, id).scene;
|
|
1928
|
+
if (this._selection.has(id))
|
|
1929
|
+
this._selection = Selection.EMPTY;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
else if (origin) {
|
|
1933
|
+
const originText = origin.text;
|
|
1934
|
+
this._scene = updateElement(this._scene, id, (s) => ({ ...s, text: originText })).scene;
|
|
1935
|
+
}
|
|
1936
|
+
this.notify();
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Translate every selected shape by the given world-space delta.
|
|
1940
|
+
* Single undo step. No-op when selection is empty. Used by arrow-key
|
|
1941
|
+
* keyboard navigation; hosts pass `{ x: 1, y: 0 }` for fine nudge
|
|
1942
|
+
* and `{ x: 10, y: 0 }` for shift-arrow.
|
|
1943
|
+
*/
|
|
1944
|
+
// Pure body in `./editor/public/selection-ops.ts`.
|
|
1945
|
+
moveSelectionBy(delta) {
|
|
1946
|
+
if (this._selection.size === 0 && this._selectedLinks.size === 0)
|
|
1947
|
+
return;
|
|
1948
|
+
// Locked / layer-locked elements don't move (they're still selectable).
|
|
1949
|
+
const targets = new Set([...this.expandSelectionWithDescendants()].filter((id) => {
|
|
1950
|
+
const s = getElement(this._scene, id);
|
|
1951
|
+
return s ? this.isElementManipulable(s) : false;
|
|
1952
|
+
}));
|
|
1953
|
+
const result = this._selection.size > 0
|
|
1954
|
+
? computeMoveSelectionBy(this._scene, targets, delta, (lid) => this.isLayerLocked(lid))
|
|
1955
|
+
: null;
|
|
1956
|
+
// Selected links (translated whole, incl. free endpoints) + connectors
|
|
1957
|
+
// bound on both ends to nudged elements move by the same delta.
|
|
1958
|
+
const sceneAfterElements = result ? result.scene : this._scene;
|
|
1959
|
+
const linkResult = computeMovingLinkForNudge(sceneAfterElements, targets, this._selectedLinks, delta);
|
|
1960
|
+
if (!result && linkResult.patches.length === 0)
|
|
1961
|
+
return;
|
|
1962
|
+
const tx = this._history.transaction();
|
|
1963
|
+
this._scene = linkResult.scene;
|
|
1964
|
+
if (result)
|
|
1965
|
+
for (const patch of result.patches)
|
|
1966
|
+
tx.add(patch);
|
|
1967
|
+
for (const patch of linkResult.patches)
|
|
1968
|
+
tx.add(patch);
|
|
1969
|
+
tx.commit();
|
|
1970
|
+
this.notify();
|
|
1971
|
+
this.announce(describeNudgePure(delta, result?.moved ?? 0));
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* Keyboard-friendly creation flow. Picks the shape type from the
|
|
1975
|
+
* current `mode` ("draw-rect" / "draw-ellipse" / fallback to
|
|
1976
|
+
* rectangle) and inserts a sensible default-sized shape at the
|
|
1977
|
+
* viewport center. Returns the new shape's id, or `null` when the
|
|
1978
|
+
* scene has no active layer.
|
|
1979
|
+
*
|
|
1980
|
+
* Hosts can bind this to "Enter" while in a draw mode, providing a
|
|
1981
|
+
* mouse-free alternative to drag-out creation.
|
|
1982
|
+
*/
|
|
1983
|
+
// Pure body in `./editor/public/placement.ts`.
|
|
1984
|
+
createElementAtCursor() {
|
|
1985
|
+
const vp = this._scene.viewport;
|
|
1986
|
+
const world = this.screenToWorld({
|
|
1987
|
+
x: (vp.size.width || 200) / 2,
|
|
1988
|
+
y: (vp.size.height || 200) / 2,
|
|
1989
|
+
});
|
|
1990
|
+
const id = newElementIdAtCursor(++this.nextId);
|
|
1991
|
+
const shape = buildElementAtCursor(this._scene, this.mode, world, this._activeLayerId, id);
|
|
1992
|
+
const r = addElement(this._scene, shape);
|
|
1993
|
+
this._scene = r.scene;
|
|
1994
|
+
this._history.push(r.patch);
|
|
1995
|
+
this._selection = Selection.single(id);
|
|
1996
|
+
this.notify();
|
|
1997
|
+
this.announce(`Created ${shape.type} ${id}`);
|
|
1998
|
+
return id;
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
2001
|
+
* `draw-text` tool: drop an empty text shape at `worldPoint`, select
|
|
2002
|
+
* it and open its inline editor immediately. The add is a single undo
|
|
2003
|
+
* step; the subsequent text typed in is committed (or the empty shape
|
|
2004
|
+
* removed) by `commitTextEdit`. Reverts to `select` afterwards unless
|
|
2005
|
+
* the tool is locked.
|
|
2006
|
+
*/
|
|
2007
|
+
createTextAt(worldPoint) {
|
|
2008
|
+
const id = newElementIdAtCursor(++this.nextId);
|
|
2009
|
+
const shape = buildTextElementAt(this._scene, worldPoint, this._activeLayerId, id);
|
|
2010
|
+
// No history push here — the placeholder is "pending" until the
|
|
2011
|
+
// first commit (see `_pendingTextCreate`). This way an abandoned
|
|
2012
|
+
// text never pollutes the undo stack.
|
|
2013
|
+
const r = addElement(this._scene, shape);
|
|
2014
|
+
this._scene = r.scene;
|
|
2015
|
+
this._pendingTextCreate = id;
|
|
2016
|
+
this._selection = Selection.single(id);
|
|
2017
|
+
this.maybeRevertModeAfterCreate();
|
|
2018
|
+
this.notify();
|
|
2019
|
+
this.announce(`Created text ${id}`);
|
|
2020
|
+
this.beginTextEdit(id);
|
|
2021
|
+
return id;
|
|
2022
|
+
}
|
|
2023
|
+
// Pure bodies in `./editor/public/brush.ts`.
|
|
2024
|
+
beginBrushStroke(world, pressure = 0.5) {
|
|
2025
|
+
this.brushStroke = beginBrushStrokePure(world, pressure);
|
|
2026
|
+
this.notify();
|
|
2027
|
+
}
|
|
2028
|
+
extendBrushStroke(world, pressure = 0.5) {
|
|
2029
|
+
if (!this.brushStroke)
|
|
2030
|
+
return;
|
|
2031
|
+
extendBrushStrokePure(this.brushStroke, world, pressure);
|
|
2032
|
+
this.notify();
|
|
2033
|
+
}
|
|
2034
|
+
commitBrushStroke() {
|
|
2035
|
+
const result = commitBrushStrokePure(this._scene, this.brushStroke, this._activeLayerId, newBrushId(++this.nextId));
|
|
2036
|
+
if (!result) {
|
|
2037
|
+
this.brushStroke = null;
|
|
2038
|
+
this.notify();
|
|
2039
|
+
return null;
|
|
2040
|
+
}
|
|
2041
|
+
this._scene = result.scene;
|
|
2042
|
+
this._history.push(result.patch);
|
|
2043
|
+
this.brushStroke = null;
|
|
2044
|
+
this.notify();
|
|
2045
|
+
return result.elementId;
|
|
2046
|
+
}
|
|
2047
|
+
cancelBrushStroke() {
|
|
2048
|
+
if (!this.brushStroke)
|
|
2049
|
+
return;
|
|
2050
|
+
this.brushStroke = null;
|
|
2051
|
+
this.notify();
|
|
2052
|
+
}
|
|
2053
|
+
/** Current in-progress brush stroke, exposed for the overlay preview. */
|
|
2054
|
+
get pendingBrushStroke() {
|
|
2055
|
+
return this.brushStroke;
|
|
2056
|
+
}
|
|
2057
|
+
// Pure bodies in `./editor/public/arrange-group.ts`.
|
|
2058
|
+
arrangeAsGrid(opts = {}) {
|
|
2059
|
+
const origin = this.combinedSelectionBounds() ?? { x: 0, y: 0 };
|
|
2060
|
+
const result = computeArrangeAsGrid(this._scene, this._selection, opts, origin);
|
|
2061
|
+
if (!result)
|
|
2062
|
+
return;
|
|
2063
|
+
this._scene = result.scene;
|
|
2064
|
+
this._history.push(result.patch);
|
|
2065
|
+
this.notify();
|
|
2066
|
+
this.announce(`Arranged ${result.count} shapes on a ${result.cols}-column grid`);
|
|
2067
|
+
}
|
|
2068
|
+
arrangeAsStack(opts = {}) {
|
|
2069
|
+
const origin = this.combinedSelectionBounds() ?? { x: 0, y: 0 };
|
|
2070
|
+
const result = computeArrangeAsStack(this._scene, this._selection, opts, origin);
|
|
2071
|
+
if (!result)
|
|
2072
|
+
return;
|
|
2073
|
+
this._scene = result.scene;
|
|
2074
|
+
this._history.push(result.patch);
|
|
2075
|
+
this.notify();
|
|
2076
|
+
this.announce(`Stacked ${result.count} shapes ${result.direction}`);
|
|
2077
|
+
}
|
|
2078
|
+
groupSelected() {
|
|
2079
|
+
const result = computeGroupSelected(this._scene, this._selection, newGroupElementId(++this.nextId));
|
|
2080
|
+
if (!result)
|
|
2081
|
+
return { kind: "noop" };
|
|
2082
|
+
const tx = this._history.transaction();
|
|
2083
|
+
this._scene = result.scene;
|
|
2084
|
+
for (const patch of result.patches)
|
|
2085
|
+
tx.add(patch);
|
|
2086
|
+
tx.commit();
|
|
2087
|
+
this._selection = Selection.single(result.groupId);
|
|
2088
|
+
this.notify();
|
|
2089
|
+
return { kind: "grouped", groupId: result.groupId };
|
|
2090
|
+
}
|
|
2091
|
+
ungroup() {
|
|
2092
|
+
const result = computeUngroup(this._scene, this._selection);
|
|
2093
|
+
if (!result)
|
|
2094
|
+
return;
|
|
2095
|
+
const tx = this._history.transaction();
|
|
2096
|
+
this._scene = result.scene;
|
|
2097
|
+
for (const patch of result.patches)
|
|
2098
|
+
tx.add(patch);
|
|
2099
|
+
tx.commit();
|
|
2100
|
+
this._selection = new Set(result.nextSelection);
|
|
2101
|
+
this.notify();
|
|
2102
|
+
}
|
|
2103
|
+
selectionRoots() {
|
|
2104
|
+
return selectionRoots(this._scene, this._selection);
|
|
2105
|
+
}
|
|
2106
|
+
expandSelectionWithDescendants() {
|
|
2107
|
+
return expandSelectionWithDescendants(this._scene, this._selection);
|
|
2108
|
+
}
|
|
2109
|
+
focusCycle(direction) {
|
|
2110
|
+
const current = [...this._selection][0];
|
|
2111
|
+
const result = pickFocusCycle(this._scene, current, direction);
|
|
2112
|
+
if (!result)
|
|
2113
|
+
return;
|
|
2114
|
+
this._selection = Selection.single(result.id);
|
|
2115
|
+
this.notify();
|
|
2116
|
+
this.announce(`Selected ${result.name}`);
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Clear selection + cancel any in-progress drag / draw gesture.
|
|
2120
|
+
* Bound to Escape in default keyboard nav.
|
|
2121
|
+
*/
|
|
2122
|
+
cancelInteraction() {
|
|
2123
|
+
// Abort the in-flight gesture AND roll `_scene` back to the pre-gesture
|
|
2124
|
+
// snapshot — Esc during any drag/move/resize/endpoint-rebind restores the
|
|
2125
|
+
// scene to exactly where it was (cancelling the history tx alone wouldn't).
|
|
2126
|
+
this.cancelGesture();
|
|
2127
|
+
this.actor.send({ type: "POINTER_CANCEL" });
|
|
2128
|
+
this.drawingPreview = null;
|
|
2129
|
+
this.edgePreview = null;
|
|
2130
|
+
this.lassoPreview = null;
|
|
2131
|
+
// Abort a host-managed link-from-anchor gesture too — it lives outside
|
|
2132
|
+
// the machine, so POINTER_CANCEL above doesn't touch it. Without this a
|
|
2133
|
+
// gesture left mid-flight would keep its preview after Escape.
|
|
2134
|
+
this.linkDragFromAnchor = null;
|
|
2135
|
+
this.hoveredLinkTarget = null;
|
|
2136
|
+
this.hoverCursorWorld = null;
|
|
2137
|
+
this._editingLinkCaption = null;
|
|
2138
|
+
this.pendingLinkDropMenu = null;
|
|
2139
|
+
this.linkWaypointDrag = null;
|
|
2140
|
+
this.linkSegmentDrag = null;
|
|
2141
|
+
// Endpoint-rebind drag: gestureTx.cancel above already reverted the live
|
|
2142
|
+
// re-point; just drop the handle-preview state so the dot stops tracking.
|
|
2143
|
+
this.linkEndpointDrag = null;
|
|
2144
|
+
// Esc exits group-isolation if active. The selection that was
|
|
2145
|
+
// active inside the group is dropped (Esc reads as a full
|
|
2146
|
+
// "back out" — selecting the group is a separate gesture).
|
|
2147
|
+
if (this._enteredGroup !== null) {
|
|
2148
|
+
this._enteredGroup = null;
|
|
2149
|
+
}
|
|
2150
|
+
this._selection = Selection.EMPTY;
|
|
2151
|
+
this._selectedLinks = LinkSelection.EMPTY;
|
|
2152
|
+
this.notify();
|
|
2153
|
+
this.announce("Selection cleared");
|
|
2154
|
+
}
|
|
2155
|
+
/**
|
|
2156
|
+
* Duplicate the selected shapes 10 px down-right of the originals.
|
|
2157
|
+
* Links between selected shapes are NOT cloned. Single undo step.
|
|
2158
|
+
*/
|
|
2159
|
+
// Pure body in `./editor/public/selection-ops.ts`.
|
|
2160
|
+
duplicateSelected() {
|
|
2161
|
+
const result = computeDuplicateSelection(this._scene, this._selection, () => ++this.nextId);
|
|
2162
|
+
if (!result)
|
|
2163
|
+
return;
|
|
2164
|
+
const tx = this._history.transaction();
|
|
2165
|
+
this._scene = result.scene;
|
|
2166
|
+
for (const patch of result.patches)
|
|
2167
|
+
tx.add(patch);
|
|
2168
|
+
tx.commit();
|
|
2169
|
+
if (result.newIds.length > 0) {
|
|
2170
|
+
this._selection = selectionFromNewIds(result.newIds);
|
|
2171
|
+
}
|
|
2172
|
+
this.notify();
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* Clone the selection IN PLACE (zero offset), select the clones, and return
|
|
2176
|
+
* the clone of `anchorId` (or null). Unlike `duplicateSelected` this also
|
|
2177
|
+
* clones group descendants and frame members, remapping `parentId`/`frameId`
|
|
2178
|
+
* among the clones so a duplicated frame keeps its contents. Used by
|
|
2179
|
+
* `⌥`-drag duplicate — the caller then drags the clones, leaving the
|
|
2180
|
+
* originals. One undo step.
|
|
2181
|
+
*/
|
|
2182
|
+
duplicateSelectedInPlace(anchorId = null) {
|
|
2183
|
+
if (this._selection.size === 0)
|
|
2184
|
+
return null;
|
|
2185
|
+
// Expand: selection + group descendants (parentId) + frame members (frameId).
|
|
2186
|
+
const ids = new Set();
|
|
2187
|
+
const addWithDescendants = (id) => {
|
|
2188
|
+
if (ids.has(id))
|
|
2189
|
+
return;
|
|
2190
|
+
ids.add(id);
|
|
2191
|
+
for (const s of this._scene.elements.values()) {
|
|
2192
|
+
if (s.parentId === id || s.frameId === id)
|
|
2193
|
+
addWithDescendants(s.id);
|
|
2194
|
+
}
|
|
2195
|
+
};
|
|
2196
|
+
for (const id of this._selection)
|
|
2197
|
+
addWithDescendants(id);
|
|
2198
|
+
// Pre-allocate new ids so cross-references (parentId/frameId) can be remapped.
|
|
2199
|
+
const idMap = new Map();
|
|
2200
|
+
for (const id of ids)
|
|
2201
|
+
idMap.set(id, castElementId(this.uniqueId("shape")));
|
|
2202
|
+
const tx = this._history.transaction();
|
|
2203
|
+
for (const id of ids) {
|
|
2204
|
+
const shape = getElement(this._scene, id);
|
|
2205
|
+
if (!shape)
|
|
2206
|
+
continue;
|
|
2207
|
+
const newId = idMap.get(id);
|
|
2208
|
+
if (newId === undefined)
|
|
2209
|
+
continue;
|
|
2210
|
+
const order = orderForTop([...this._scene.elements.values()]
|
|
2211
|
+
.filter((sh) => sh.layerId === shape.layerId)
|
|
2212
|
+
.map((sh) => sh.order));
|
|
2213
|
+
const copy = { ...shape, id: newId, order };
|
|
2214
|
+
if (copy.parentId !== undefined) {
|
|
2215
|
+
const mapped = idMap.get(copy.parentId);
|
|
2216
|
+
if (mapped !== undefined)
|
|
2217
|
+
copy.parentId = mapped;
|
|
2218
|
+
}
|
|
2219
|
+
if (copy.frameId !== undefined) {
|
|
2220
|
+
const mapped = idMap.get(copy.frameId);
|
|
2221
|
+
if (mapped !== undefined)
|
|
2222
|
+
copy.frameId = mapped;
|
|
2223
|
+
}
|
|
2224
|
+
const r = addElement(this._scene, copy);
|
|
2225
|
+
this._scene = r.scene;
|
|
2226
|
+
tx.add(r.patch);
|
|
2227
|
+
}
|
|
2228
|
+
tx.commit();
|
|
2229
|
+
// Select the clones of the originally-selected ids.
|
|
2230
|
+
const selectedClones = [];
|
|
2231
|
+
for (const id of this._selection) {
|
|
2232
|
+
const c = idMap.get(id);
|
|
2233
|
+
if (c !== undefined)
|
|
2234
|
+
selectedClones.push(c);
|
|
2235
|
+
}
|
|
2236
|
+
if (selectedClones.length > 0)
|
|
2237
|
+
this._selection = selectionFromNewIds(selectedClones);
|
|
2238
|
+
this.notify();
|
|
2239
|
+
return anchorId !== null ? (idMap.get(anchorId) ?? null) : null;
|
|
2240
|
+
}
|
|
2241
|
+
setSelection(ids) {
|
|
2242
|
+
const next = computeSetSelection(this._scene, ids, this._selection);
|
|
2243
|
+
if (!next)
|
|
2244
|
+
return;
|
|
2245
|
+
this._selection = next;
|
|
2246
|
+
if (this._selectedLinks.size > 0)
|
|
2247
|
+
this._selectedLinks = LinkSelection.EMPTY;
|
|
2248
|
+
this.notify();
|
|
2249
|
+
}
|
|
2250
|
+
selectAll() {
|
|
2251
|
+
const next = computeSelectAll(this._scene, this._selection);
|
|
2252
|
+
const nextLinks = computeSelectAllLinks(this._scene);
|
|
2253
|
+
const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
|
|
2254
|
+
// `computeSelectAll` returns null when the element set is unchanged;
|
|
2255
|
+
// still proceed if the link set changed (e.g. only links left to add).
|
|
2256
|
+
if (!next && !linksChanged)
|
|
2257
|
+
return;
|
|
2258
|
+
if (next)
|
|
2259
|
+
this._selection = next;
|
|
2260
|
+
this._selectedLinks = nextLinks;
|
|
2261
|
+
this.notify();
|
|
2262
|
+
const count = this._selection.size + this._selectedLinks.size;
|
|
2263
|
+
this.announce(`Selected ${count} objects`);
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Internal clipboard. Stored as deep-cloned snapshots so subsequent
|
|
2267
|
+
* mutations don't affect the buffer. Survives across editor calls
|
|
2268
|
+
* within the same session; cross-tab paste uses host-level
|
|
2269
|
+
* `navigator.clipboard` (out of scope for the editor).
|
|
2270
|
+
*/
|
|
2271
|
+
clipboard = [];
|
|
2272
|
+
// Pure body in `./editor/public/clipboard.ts`.
|
|
2273
|
+
copySelected() {
|
|
2274
|
+
const out = copySelectedPure(this._scene, this._selection);
|
|
2275
|
+
if (out.length === 0)
|
|
2276
|
+
return;
|
|
2277
|
+
this.clipboard = [...out];
|
|
2278
|
+
this.announce(`Copied ${out.length} shapes`);
|
|
2279
|
+
}
|
|
2280
|
+
cutSelected() {
|
|
2281
|
+
this.copySelected();
|
|
2282
|
+
this.deleteSelected();
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* Paste clipboard contents into the scene. The cluster lands so that
|
|
2286
|
+
* its centroid sits at `targetWorld` (defaults to the last tracked
|
|
2287
|
+
* cursor position; when even that is unavailable, falls back to a
|
|
2288
|
+
* +10 px nudge so duplicates stay visible). Relative offsets
|
|
2289
|
+
* between clipboard items are preserved.
|
|
2290
|
+
*
|
|
2291
|
+
* New shapes get fresh ids and end up selected. Single undo step.
|
|
2292
|
+
*/
|
|
2293
|
+
// Pure body in `./editor/public/clipboard.ts`.
|
|
2294
|
+
paste(targetWorld) {
|
|
2295
|
+
if (this.clipboard.length === 0)
|
|
2296
|
+
return;
|
|
2297
|
+
// Defensive: if a gesture is mid-flight (drag / resize) the
|
|
2298
|
+
// gestureTx is still open and a fresh `transaction()` inside
|
|
2299
|
+
// pasteElements would throw. Reasonable behaviour for a user
|
|
2300
|
+
// pressing Cmd+V mid-gesture is "commit what you have and
|
|
2301
|
+
// paste on top", so close the gesture first.
|
|
2302
|
+
this.finalizeOpenGestureTx();
|
|
2303
|
+
const target = targetWorld ?? this.lastPointerWorld;
|
|
2304
|
+
const result = pasteFromClipboard(this._scene, this._history, this.clipboard, target ?? null, () => ++this.nextId);
|
|
2305
|
+
this._scene = result.scene;
|
|
2306
|
+
this._selection = selectionFromPasted(result.newIds);
|
|
2307
|
+
this.notify();
|
|
2308
|
+
this.announce(`Pasted ${result.newIds.length} shapes`);
|
|
2309
|
+
}
|
|
2310
|
+
/**
|
|
2311
|
+
* Merge `partial` into the `style` of every shape in `ids`. Useful
|
|
2312
|
+
* for the inspector / PropertyPanel: flipping `roundness`, swapping
|
|
2313
|
+
* `lineJoin`, changing `stroke` colour across a multi-selection,
|
|
2314
|
+
* etc. All changes go through one history record (single undo).
|
|
2315
|
+
*
|
|
2316
|
+
* No-op when `ids` is empty or none of the targeted shapes exist.
|
|
2317
|
+
*/
|
|
2318
|
+
// Pure body in `./editor/public/selection-ops.ts`.
|
|
2319
|
+
updateStyle(ids, partial) {
|
|
2320
|
+
const result = computeUpdateStyle(this._scene, ids, partial);
|
|
2321
|
+
if (!result)
|
|
2322
|
+
return;
|
|
2323
|
+
this._scene = result.scene;
|
|
2324
|
+
this._history.push(result.patch);
|
|
2325
|
+
this.notify();
|
|
2326
|
+
}
|
|
2327
|
+
/**
|
|
2328
|
+
* Update non-style text properties (`fontSize`, `fontFamily`,
|
|
2329
|
+
* `maxWidth`) on every selected text shape. Non-text shapes are
|
|
2330
|
+
* skipped. Single undo step. Used by the text contextual panel.
|
|
2331
|
+
*/
|
|
2332
|
+
updateTextProps(ids, partial) {
|
|
2333
|
+
const result = computeUpdateTextProps(this._scene, ids, partial);
|
|
2334
|
+
if (!result)
|
|
2335
|
+
return;
|
|
2336
|
+
this._scene = result.scene;
|
|
2337
|
+
this._history.push(result.patch);
|
|
2338
|
+
this.notify();
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Set (or clear, with `null`) the element-level hyperlink (`href`) on
|
|
2342
|
+
* every shape in `ids`. Single undo step. Pass a raw user string — it
|
|
2343
|
+
* is normalised here (`normalizeHref`: adds `https://`, `mailto:`,
|
|
2344
|
+
* rejects `javascript:`/`data:`); a string that normalises to nothing
|
|
2345
|
+
* clears the link. The host opens it on Cmd/Ctrl-click or the
|
|
2346
|
+
* hover link-popup.
|
|
2347
|
+
*/
|
|
2348
|
+
setLink(ids, href) {
|
|
2349
|
+
const normalized = href === null ? null : normalizeHref(href);
|
|
2350
|
+
const result = computeSetLink(this._scene, ids, normalized);
|
|
2351
|
+
if (!result)
|
|
2352
|
+
return;
|
|
2353
|
+
this._scene = result.scene;
|
|
2354
|
+
this._history.push(result.patch);
|
|
2355
|
+
this.notify();
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Open an element hyperlink in a new tab. Re-validates the scheme
|
|
2359
|
+
* (`safeHref`) before navigating — only `http`/`https`/`mailto`, never
|
|
2360
|
+
* `javascript:` / `data:` — and uses `noopener,noreferrer`. No-op for
|
|
2361
|
+
* an unsafe / empty href or outside a browser.
|
|
2362
|
+
*/
|
|
2363
|
+
openLink(href) {
|
|
2364
|
+
const url = safeHref(href);
|
|
2365
|
+
if (!url || typeof window === "undefined")
|
|
2366
|
+
return;
|
|
2367
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
2368
|
+
}
|
|
2369
|
+
/** The element-level href of a shape, if safe to open; else `null`. */
|
|
2370
|
+
elementLink(id) {
|
|
2371
|
+
return safeHref(getElement(this._scene, id)?.href);
|
|
2372
|
+
}
|
|
2373
|
+
/**
|
|
2374
|
+
* Topmost interactable shape at `worldPoint` that carries a safe link,
|
|
2375
|
+
* with its world bounds — for the hover link-popup. `null` when none.
|
|
2376
|
+
*/
|
|
2377
|
+
linkAt(worldPoint) {
|
|
2378
|
+
const shape = this.acceleratedElementAt(worldPoint);
|
|
2379
|
+
if (!shape || !this.isElementInteractable(shape))
|
|
2380
|
+
return null;
|
|
2381
|
+
const href = safeHref(shape.href);
|
|
2382
|
+
if (!href)
|
|
2383
|
+
return null;
|
|
2384
|
+
return { id: shape.id, href, bounds: getElementWorldBounds(shape) };
|
|
2385
|
+
}
|
|
2386
|
+
// Pure bodies in `./editor/public/z-order.ts`.
|
|
2387
|
+
bringToFront(id) {
|
|
2388
|
+
const result = computeBringToFront(this._scene, id, this._selection);
|
|
2389
|
+
if (!result)
|
|
2390
|
+
return;
|
|
2391
|
+
this._scene = result.scene;
|
|
2392
|
+
this._history.push(result.patch);
|
|
2393
|
+
this.notify();
|
|
2394
|
+
}
|
|
2395
|
+
sendToBack(id) {
|
|
2396
|
+
const result = computeSendToBack(this._scene, id, this._selection);
|
|
2397
|
+
if (!result)
|
|
2398
|
+
return;
|
|
2399
|
+
this._scene = result.scene;
|
|
2400
|
+
this._history.push(result.patch);
|
|
2401
|
+
this.notify();
|
|
2402
|
+
}
|
|
2403
|
+
/** Move the target shape one step toward the top of its layer. */
|
|
2404
|
+
bringForward(id) {
|
|
2405
|
+
const result = computeBringForward(this._scene, id, this._selection);
|
|
2406
|
+
if (!result)
|
|
2407
|
+
return;
|
|
2408
|
+
this._scene = result.scene;
|
|
2409
|
+
this._history.push(result.patch);
|
|
2410
|
+
this.notify();
|
|
2411
|
+
}
|
|
2412
|
+
/** Move the target shape one step toward the bottom of its layer. */
|
|
2413
|
+
sendBackward(id) {
|
|
2414
|
+
const result = computeSendBackward(this._scene, id, this._selection);
|
|
2415
|
+
if (!result)
|
|
2416
|
+
return;
|
|
2417
|
+
this._scene = result.scene;
|
|
2418
|
+
this._history.push(result.patch);
|
|
2419
|
+
this.notify();
|
|
2420
|
+
}
|
|
2421
|
+
compactLayerZOrder(layerId, options = {}) {
|
|
2422
|
+
const recordHistory = options.recordHistory ?? true;
|
|
2423
|
+
const layerIds = layerId ? [layerId] : [...this._scene.layers.keys()];
|
|
2424
|
+
const tx = recordHistory ? this._history.transaction() : null;
|
|
2425
|
+
const touched = compactLayerZOrderPatches(this._scene, layerIds, (nextScene, patch) => {
|
|
2426
|
+
this._scene = nextScene;
|
|
2427
|
+
tx?.add(patch);
|
|
2428
|
+
});
|
|
2429
|
+
if (touched === 0) {
|
|
2430
|
+
tx?.cancel();
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
tx?.commit();
|
|
2434
|
+
this.notify();
|
|
2435
|
+
if (recordHistory) {
|
|
2436
|
+
this.announce(`Compacted z-order across ${layerIds.length} layer(s)`);
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* Wipe every shape + edge from the scene. Layers and viewport survive.
|
|
2441
|
+
* Clears history — restoring an empty scene through undo would be
|
|
2442
|
+
* surprising and the operation is rarely chained with other edits.
|
|
2443
|
+
*/
|
|
2444
|
+
clear() {
|
|
2445
|
+
if (this._scene.elements.size === 0 && this._scene.links.size === 0)
|
|
2446
|
+
return;
|
|
2447
|
+
this._scene = {
|
|
2448
|
+
...this._scene,
|
|
2449
|
+
elements: new Map(),
|
|
2450
|
+
links: new Map(),
|
|
2451
|
+
};
|
|
2452
|
+
this._selection = Selection.EMPTY;
|
|
2453
|
+
this._selectedLinks = LinkSelection.EMPTY;
|
|
2454
|
+
this._history.clear();
|
|
2455
|
+
this.notify();
|
|
2456
|
+
}
|
|
2457
|
+
// --- Layer commands ---
|
|
2458
|
+
/** Currently active layer — new shapes default into it. */
|
|
2459
|
+
get activeLayerId() {
|
|
2460
|
+
return this._activeLayerId;
|
|
2461
|
+
}
|
|
2462
|
+
/** Switch the active layer. Hosts call this from a layer panel click. */
|
|
2463
|
+
setActiveLayer(id) {
|
|
2464
|
+
if (!this._scene.layers.has(id))
|
|
2465
|
+
return;
|
|
2466
|
+
if (this._activeLayerId === id)
|
|
2467
|
+
return;
|
|
2468
|
+
this._activeLayerId = id;
|
|
2469
|
+
this.notify();
|
|
2470
|
+
}
|
|
2471
|
+
// Pure bodies in `./editor/public/layers.ts`.
|
|
2472
|
+
createLayer(name) {
|
|
2473
|
+
const result = computeCreateLayer(this._scene, name, newLayerId(++this.nextId));
|
|
2474
|
+
this._scene = result.scene;
|
|
2475
|
+
this._history.push(result.patch);
|
|
2476
|
+
this._activeLayerId = result.layerId;
|
|
2477
|
+
this.notify();
|
|
2478
|
+
return result.layerId;
|
|
2479
|
+
}
|
|
2480
|
+
removeLayer(id) {
|
|
2481
|
+
const result = computeRemoveLayer(this._scene, id, this._activeLayerId);
|
|
2482
|
+
if (!result)
|
|
2483
|
+
return;
|
|
2484
|
+
const tx = this._history.transaction();
|
|
2485
|
+
this._scene = result.scene;
|
|
2486
|
+
for (const patch of result.patches)
|
|
2487
|
+
tx.add(patch);
|
|
2488
|
+
tx.commit();
|
|
2489
|
+
this._activeLayerId = result.nextActiveLayerId;
|
|
2490
|
+
this._selection = Selection.EMPTY;
|
|
2491
|
+
this.notify();
|
|
2492
|
+
}
|
|
2493
|
+
renameLayer(id, name) {
|
|
2494
|
+
const result = computeRenameLayer(this._scene, id, name);
|
|
2495
|
+
if (!result)
|
|
2496
|
+
return;
|
|
2497
|
+
this._scene = result.scene;
|
|
2498
|
+
this._history.push(result.patch);
|
|
2499
|
+
this.notify();
|
|
2500
|
+
}
|
|
2501
|
+
toggleLayerVisibility(id) {
|
|
2502
|
+
const result = computeToggleLayerVisibility(this._scene, id);
|
|
2503
|
+
if (!result)
|
|
2504
|
+
return;
|
|
2505
|
+
this._scene = result.scene;
|
|
2506
|
+
this._history.push(result.patch);
|
|
2507
|
+
this.notify();
|
|
2508
|
+
}
|
|
2509
|
+
toggleLayerLock(id) {
|
|
2510
|
+
const result = computeToggleLayerLock(this._scene, id);
|
|
2511
|
+
if (!result)
|
|
2512
|
+
return;
|
|
2513
|
+
this._scene = result.scene;
|
|
2514
|
+
this._history.push(result.patch);
|
|
2515
|
+
this.notify();
|
|
2516
|
+
}
|
|
2517
|
+
moveSelectionToLayer(targetLayer) {
|
|
2518
|
+
const result = computeMoveSelectionToLayer(this._scene, this._selection, targetLayer);
|
|
2519
|
+
if (!result)
|
|
2520
|
+
return;
|
|
2521
|
+
const tx = this._history.transaction();
|
|
2522
|
+
this._scene = result.scene;
|
|
2523
|
+
for (const patch of result.patches)
|
|
2524
|
+
tx.add(patch);
|
|
2525
|
+
tx.commit();
|
|
2526
|
+
this.notify();
|
|
2527
|
+
}
|
|
2528
|
+
// --- Viewport commands ---
|
|
2529
|
+
/**
|
|
2530
|
+
* Pan the camera by a screen-space delta. Drives both wheel-pan and
|
|
2531
|
+
* the two-finger pan gesture; deltas are in screen pixels (positive
|
|
2532
|
+
* x → shapes move right relative to the user). Not recorded in
|
|
2533
|
+
* history — viewport state is editor-local.
|
|
2534
|
+
*/
|
|
2535
|
+
// Pure bodies in `./editor/public/zoom-pan.ts`.
|
|
2536
|
+
panBy(deltaScreen) {
|
|
2537
|
+
const next = computePan(this._scene, deltaScreen);
|
|
2538
|
+
if (!next)
|
|
2539
|
+
return;
|
|
2540
|
+
this._scene = next;
|
|
2541
|
+
this.notify();
|
|
2542
|
+
}
|
|
2543
|
+
zoomIn() {
|
|
2544
|
+
this.zoomStep(WHEEL_ZOOM_STEP);
|
|
2545
|
+
}
|
|
2546
|
+
zoomOut() {
|
|
2547
|
+
this.zoomStep(1 / WHEEL_ZOOM_STEP);
|
|
2548
|
+
}
|
|
2549
|
+
zoomStep(factor) {
|
|
2550
|
+
const vp = this._scene.viewport;
|
|
2551
|
+
if (vp.size.width <= 0 || vp.size.height <= 0)
|
|
2552
|
+
return;
|
|
2553
|
+
const center = this.screenToWorld({ x: vp.size.width / 2, y: vp.size.height / 2 });
|
|
2554
|
+
this.zoomAt(factor, center);
|
|
2555
|
+
}
|
|
2556
|
+
resetZoom() {
|
|
2557
|
+
const next = computeResetZoom(this._scene);
|
|
2558
|
+
if (!next)
|
|
2559
|
+
return;
|
|
2560
|
+
this._scene = next;
|
|
2561
|
+
this.notify();
|
|
2562
|
+
}
|
|
2563
|
+
zoomToFit(padding = 40) {
|
|
2564
|
+
const next = computeZoomToFit(this._scene, padding);
|
|
2565
|
+
if (!next)
|
|
2566
|
+
return;
|
|
2567
|
+
this._scene = next;
|
|
2568
|
+
this.notify();
|
|
2569
|
+
}
|
|
2570
|
+
/** Fit the camera to the current selection (standard `⌥2`). No-op when empty. */
|
|
2571
|
+
zoomToSelection(padding = 80) {
|
|
2572
|
+
const bounds = this.combinedSelectionBounds();
|
|
2573
|
+
if (!bounds)
|
|
2574
|
+
return;
|
|
2575
|
+
const next = computeZoomToBounds(this._scene, bounds, padding);
|
|
2576
|
+
if (!next)
|
|
2577
|
+
return;
|
|
2578
|
+
this._scene = next;
|
|
2579
|
+
this.notify();
|
|
2580
|
+
}
|
|
2581
|
+
/**
|
|
2582
|
+
* Select the nearest interactable top-level element in `direction` from the
|
|
2583
|
+
* current selection's centre (or the viewport centre when nothing is
|
|
2584
|
+
* selected). standard `⌘`+arrows. Candidates must lie within a 45° cone of the
|
|
2585
|
+
* direction; the closest by along+lateral distance wins. No-op when nothing
|
|
2586
|
+
* qualifies.
|
|
2587
|
+
*/
|
|
2588
|
+
selectClosest(direction) {
|
|
2589
|
+
const ref = this.combinedSelectionBounds();
|
|
2590
|
+
const vp = this._scene.viewport;
|
|
2591
|
+
const refC = ref
|
|
2592
|
+
? { x: ref.x + ref.width / 2, y: ref.y + ref.height / 2 }
|
|
2593
|
+
: {
|
|
2594
|
+
x: vp.pan.x + vp.size.width / 2 / vp.zoom,
|
|
2595
|
+
y: vp.pan.y + vp.size.height / 2 / vp.zoom,
|
|
2596
|
+
};
|
|
2597
|
+
const dv = direction === "left"
|
|
2598
|
+
? { x: -1, y: 0 }
|
|
2599
|
+
: direction === "right"
|
|
2600
|
+
? { x: 1, y: 0 }
|
|
2601
|
+
: direction === "up"
|
|
2602
|
+
? { x: 0, y: -1 }
|
|
2603
|
+
: { x: 0, y: 1 };
|
|
2604
|
+
let best = null;
|
|
2605
|
+
let bestScore = Infinity;
|
|
2606
|
+
for (const s of this._scene.elements.values()) {
|
|
2607
|
+
if (s.parentId !== undefined)
|
|
2608
|
+
continue; // top-level shapes only
|
|
2609
|
+
if (this._selection.has(s.id))
|
|
2610
|
+
continue;
|
|
2611
|
+
if (!this.isElementInteractable(s))
|
|
2612
|
+
continue;
|
|
2613
|
+
const b = getElementWorldBounds(s);
|
|
2614
|
+
const cx = b.x + b.width / 2 - refC.x;
|
|
2615
|
+
const cy = b.y + b.height / 2 - refC.y;
|
|
2616
|
+
const along = cx * dv.x + cy * dv.y;
|
|
2617
|
+
if (along <= 0)
|
|
2618
|
+
continue; // not in the direction's half-plane
|
|
2619
|
+
const perp = Math.abs(cx * dv.y - cy * dv.x);
|
|
2620
|
+
if (perp > along)
|
|
2621
|
+
continue; // outside the 45° cone
|
|
2622
|
+
const score = along + perp;
|
|
2623
|
+
if (score < bestScore) {
|
|
2624
|
+
bestScore = score;
|
|
2625
|
+
best = s.id;
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
if (best === null)
|
|
2629
|
+
return;
|
|
2630
|
+
this.setSelection([best]);
|
|
2631
|
+
}
|
|
2632
|
+
zoomAt(factor, anchorWorld) {
|
|
2633
|
+
const next = computeZoomAt(this._scene, factor, anchorWorld);
|
|
2634
|
+
if (!next)
|
|
2635
|
+
return;
|
|
2636
|
+
this._scene = next;
|
|
2637
|
+
this.notify();
|
|
2638
|
+
}
|
|
2639
|
+
setViewportSize(width, height) {
|
|
2640
|
+
const next = computeViewportResize(this._scene, width, height);
|
|
2641
|
+
if (!next)
|
|
2642
|
+
return;
|
|
2643
|
+
this._scene = next;
|
|
2644
|
+
this.notify();
|
|
2645
|
+
}
|
|
2646
|
+
setGrid(patch) {
|
|
2647
|
+
const next = computeSetGrid(this._scene, patch);
|
|
2648
|
+
if (!next)
|
|
2649
|
+
return;
|
|
2650
|
+
this._scene = next;
|
|
2651
|
+
this.notify();
|
|
2652
|
+
}
|
|
2653
|
+
/** Whether snap-to-grid is currently enabled (default on). */
|
|
2654
|
+
get snapToGridEnabled() {
|
|
2655
|
+
return isSnapToGridEnabled(this._scene.viewport);
|
|
2656
|
+
}
|
|
2657
|
+
/** Toggle snap-to-grid on/off. Persists in the viewport. */
|
|
2658
|
+
setSnapToGrid(enabled) {
|
|
2659
|
+
this.setGrid({ snap: enabled });
|
|
2660
|
+
}
|
|
2661
|
+
/**
|
|
2662
|
+
* Host hook: while held, the next move/resize/create gesture ignores
|
|
2663
|
+
* snap-to-grid (Cmd/Ctrl modifier). The app wires keydown/keyup
|
|
2664
|
+
* of the modifier to this. Idempotent; never touches history.
|
|
2665
|
+
*/
|
|
2666
|
+
setSnapSuppressed(suppressed) {
|
|
2667
|
+
this.snapSuppressed = suppressed;
|
|
2668
|
+
}
|
|
2669
|
+
/**
|
|
2670
|
+
* True when a gesture should snap: feature on, grid visible, AND the
|
|
2671
|
+
* suppress modifier not held. Hiding the grid (`toggleGrid` / `g`) also
|
|
2672
|
+
* disables snap-to-grid — snapping to an invisible grid is confusing.
|
|
2673
|
+
* Re-showing the grid restores the stored snap preference.
|
|
2674
|
+
*/
|
|
2675
|
+
snapActive() {
|
|
2676
|
+
return !this.snapSuppressed && this.gridVisible && isSnapToGridEnabled(this._scene.viewport);
|
|
2677
|
+
}
|
|
2678
|
+
/** World-unit spacing the current gesture snaps to. */
|
|
2679
|
+
snapSpacing() {
|
|
2680
|
+
return resolveSnapSpacing(this._scene.viewport);
|
|
2681
|
+
}
|
|
2682
|
+
/**
|
|
2683
|
+
* Replace the entire scene (e.g. after `parseScene`). Clears history,
|
|
2684
|
+
* selection and any open gesture. Use to load a saved document.
|
|
2685
|
+
*/
|
|
2686
|
+
loadScene(scene, options = {}) {
|
|
2687
|
+
if (this.gestureTx) {
|
|
2688
|
+
this.gestureTx.cancel();
|
|
2689
|
+
this.gestureTx = null;
|
|
2690
|
+
}
|
|
2691
|
+
this._scene = scene;
|
|
2692
|
+
// Snap active layer back into the loaded scene's layer set.
|
|
2693
|
+
if (!scene.layers.has(this._activeLayerId)) {
|
|
2694
|
+
const first = scene.layers.keys().next().value;
|
|
2695
|
+
this._activeLayerId = first ?? castLayerId(DEFAULT_LAYER_ID);
|
|
2696
|
+
}
|
|
2697
|
+
if (options.preserveHistory) {
|
|
2698
|
+
// Used by collab when a peer update arrives — the local user's
|
|
2699
|
+
// undo stack must survive remote edits. Drop selection entries
|
|
2700
|
+
// that no longer point to existing shapes; the rest of the stack
|
|
2701
|
+
// stays untouched (patches that reference removed shapes will
|
|
2702
|
+
// throw on `apply` and need user-visible recovery later).
|
|
2703
|
+
this.pruneSelection();
|
|
2704
|
+
}
|
|
2705
|
+
else {
|
|
2706
|
+
this._selection = Selection.EMPTY;
|
|
2707
|
+
this._history.clear();
|
|
2708
|
+
}
|
|
2709
|
+
// Restore transient animationData (GIF bytes) from Scene.files
|
|
2710
|
+
// before the tick so the animation adapter can decode frames.
|
|
2711
|
+
this.rehydrateAnimatedImages();
|
|
2712
|
+
this.notify();
|
|
2713
|
+
// Loaded scene may carry animated shapes (e.g. GIF re-imported
|
|
2714
|
+
// from saved JSON). Re-arm the tick — `metadata.animated` survives
|
|
2715
|
+
// serialisation and `rehydrateAnimatedImages` re-attached the
|
|
2716
|
+
// bytes, so the registered adapter can produce frames again.
|
|
2717
|
+
// `maybeAnimate` honours the G1 viewport cull.
|
|
2718
|
+
this.maybeAnimate();
|
|
2719
|
+
}
|
|
2720
|
+
/** Detach all DOM listeners and stop the actor. */
|
|
2721
|
+
dispose() {
|
|
2722
|
+
this.cancelLongPress();
|
|
2723
|
+
this.unbind();
|
|
2724
|
+
this.actor.stop();
|
|
2725
|
+
this.listeners.clear();
|
|
2726
|
+
this.cursorListeners.clear();
|
|
2727
|
+
this.longPressListeners.clear();
|
|
2728
|
+
this.announceListeners.clear();
|
|
2729
|
+
this.animationTick.stop();
|
|
2730
|
+
this.animationContentOff?.();
|
|
2731
|
+
if (typeof document !== "undefined") {
|
|
2732
|
+
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
2733
|
+
}
|
|
2734
|
+
if (this.renderRafId !== null && typeof cancelAnimationFrame !== "undefined") {
|
|
2735
|
+
cancelAnimationFrame(this.renderRafId);
|
|
2736
|
+
this.renderRafId = null;
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
// --- Internal ---
|
|
2740
|
+
// Body moved to `./editor/pointer-binding.ts` (~700 lines of
|
|
2741
|
+
// pointer / wheel / keyboard dispatch). The thin wrapper here
|
|
2742
|
+
// preserves the original constructor call site.
|
|
2743
|
+
bindPointerEvents() {
|
|
2744
|
+
return bindPointerEventsExternal(this);
|
|
2745
|
+
}
|
|
2746
|
+
/**
|
|
2747
|
+
* Open a pan gesture: capture the pointer so subsequent move / up
|
|
2748
|
+
* events arrive even outside the host bounds, cancel anything the
|
|
2749
|
+
* machine might have started this tick, and switch the cursor.
|
|
2750
|
+
*/
|
|
2751
|
+
beginPanGesture(pointerId, button, point) {
|
|
2752
|
+
this.actor.send({ type: "POINTER_CANCEL" });
|
|
2753
|
+
this.cancelGesture();
|
|
2754
|
+
this.cancelLongPress();
|
|
2755
|
+
this.host.setPointerCapture(pointerId);
|
|
2756
|
+
this.panGesture = {
|
|
2757
|
+
pointerId,
|
|
2758
|
+
button,
|
|
2759
|
+
startPoint: point,
|
|
2760
|
+
lastPoint: point,
|
|
2761
|
+
moved: false,
|
|
2762
|
+
};
|
|
2763
|
+
this.refreshCursor(); // → "grabbing" while panGesture is set
|
|
2764
|
+
}
|
|
2765
|
+
/**
|
|
2766
|
+
* End an in-progress pan gesture. Restores the cursor unless Space
|
|
2767
|
+
* is still held (then we drop back to "grab" so the user knows
|
|
2768
|
+
* another drag is armed). For right-click that didn't move past
|
|
2769
|
+
* the slop threshold, fires the long-press callback so the context
|
|
2770
|
+
* menu opens at the click position — that's the "right-click =
|
|
2771
|
+
* menu, right-drag = pan" decision rule.
|
|
2772
|
+
*/
|
|
2773
|
+
endPanGesture() {
|
|
2774
|
+
const gesture = this.panGesture;
|
|
2775
|
+
this.panGesture = null;
|
|
2776
|
+
if (gesture && (gesture.button === 2 || gesture.button === 1) && !gesture.moved) {
|
|
2777
|
+
// Right-click without a drag → trigger the context-menu listeners.
|
|
2778
|
+
// Same payload as touch long-press so existing UI (e.g.
|
|
2779
|
+
// `@react-ui/ContextMenu`) works without changes.
|
|
2780
|
+
const worldPoint = this.screenToWorld(gesture.startPoint);
|
|
2781
|
+
for (const fn of this.longPressListeners) {
|
|
2782
|
+
fn({ screenPoint: gesture.startPoint, worldPoint });
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
else {
|
|
2786
|
+
// Either it was a real drag, or Space + left drag. In both cases the
|
|
2787
|
+
// native context menu stays suppressed until the upcoming
|
|
2788
|
+
// `contextmenu` event lands (Chrome fires it after pointerup on the
|
|
2789
|
+
// right button).
|
|
2790
|
+
}
|
|
2791
|
+
// Pan over — recompute (→ "grab" if Space/hand still armed, else the
|
|
2792
|
+
// idle hover cursor).
|
|
2793
|
+
this.refreshCursor();
|
|
2794
|
+
}
|
|
2795
|
+
isDrawingPhase(ctx) {
|
|
2796
|
+
return ctx.mode === "draw-rect" || ctx.mode === "draw-ellipse" || ctx.mode === "draw-edge";
|
|
2797
|
+
}
|
|
2798
|
+
// --- Long-press --- (controller in `./editor/long-press.ts`)
|
|
2799
|
+
startLongPress(screenPoint) {
|
|
2800
|
+
this.longPress.start(screenPoint);
|
|
2801
|
+
}
|
|
2802
|
+
cancelLongPress() {
|
|
2803
|
+
this.longPress.cancel();
|
|
2804
|
+
}
|
|
2805
|
+
// --- Pinch gesture --- (controller in `./editor/pinch.ts`)
|
|
2806
|
+
beginPinch() {
|
|
2807
|
+
this.pinch.begin([...this.activePointers.values()]);
|
|
2808
|
+
}
|
|
2809
|
+
applyPinch() {
|
|
2810
|
+
this.pinch.apply([...this.activePointers.values()]);
|
|
2811
|
+
}
|
|
2812
|
+
/**
|
|
2813
|
+
* Convert a point in the host element's CSS-pixel coordinate space into
|
|
2814
|
+
* world coordinates. Public so drop handlers (drag-from-palette, paste)
|
|
2815
|
+
* can map pointer positions back to scene space.
|
|
2816
|
+
*/
|
|
2817
|
+
screenToWorld(point) {
|
|
2818
|
+
return matrix.applyToPoint(getScreenToWorld(this._scene.viewport), point);
|
|
2819
|
+
}
|
|
2820
|
+
// Pure body in `./editor/hit-test.ts`. Editor passes a narrow
|
|
2821
|
+
// context bundle that closes over its private state + accel
|
|
2822
|
+
// helpers (acceleratedElementAt, isElementInteractable, …).
|
|
2823
|
+
/**
|
|
2824
|
+
* Attach target under `worldPoint` for an endpoint-rebind drop: the topmost
|
|
2825
|
+
* interactable ELEMENT (group-promoted), ignoring link bodies and the dragged
|
|
2826
|
+
* link's own endpoint handle. Used instead of {@link hitTest} when finishing
|
|
2827
|
+
* an endpoint drag — `hitTest` would return the (now live, cursor-tracking)
|
|
2828
|
+
* endpoint handle and shadow the element beneath it, blocking re-binding.
|
|
2829
|
+
* `undefined` → dropped on empty space (the end stays a free point).
|
|
2830
|
+
*/
|
|
2831
|
+
linkAttachTargetAt(worldPoint) {
|
|
2832
|
+
const shape = this.acceleratedElementAt(worldPoint);
|
|
2833
|
+
if (shape && this.isElementInteractable(shape)) {
|
|
2834
|
+
const target = this.promoteToGroupRoot(shape);
|
|
2835
|
+
return { kind: "element", id: target.id, bounds: getElementWorldBounds(target) };
|
|
2836
|
+
}
|
|
2837
|
+
return undefined;
|
|
2838
|
+
}
|
|
2839
|
+
hitTest(worldPoint) {
|
|
2840
|
+
return pickPressTarget(worldPoint, {
|
|
2841
|
+
scene: this._scene,
|
|
2842
|
+
selection: this._selection,
|
|
2843
|
+
selectedLink: this.selectedLink,
|
|
2844
|
+
selectedLinkCount: this._selectedLinks.size,
|
|
2845
|
+
enteredGroup: this._enteredGroup,
|
|
2846
|
+
handleHitSlop: this.handleHitSlop,
|
|
2847
|
+
edgeHandleHitSlop: this.edgeHandleHitSlop,
|
|
2848
|
+
edgeHitThreshold: this.edgeHitThreshold,
|
|
2849
|
+
hitAnnotation: (p) => this.hitAnnotation(p),
|
|
2850
|
+
selectionIsAspectLocked: () => this.selectionIsAspectLocked(),
|
|
2851
|
+
combinedSelectionBounds: () => this.combinedSelectionBounds(),
|
|
2852
|
+
acceleratedElementAt: (p) => this.acceleratedElementAt(p),
|
|
2853
|
+
isElementInteractable: (s) => this.isElementInteractable(s),
|
|
2854
|
+
isLayerLocked: (id) => this.isLayerLocked(id),
|
|
2855
|
+
promoteToGroupRoot: (s) => this.promoteToGroupRoot(s),
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
/**
|
|
2859
|
+
* Recompute the canvas cursor from the current interaction state and apply
|
|
2860
|
+
* it to the host element. Single chokepoint — called from pointer-move
|
|
2861
|
+
* (hover), gesture begin/end, and mode changes so the cursor never drifts
|
|
2862
|
+
* out of sync. `worldPoint` defaults to the last known pointer position.
|
|
2863
|
+
*/
|
|
2864
|
+
refreshCursor(worldPoint) {
|
|
2865
|
+
const next = this.computeCursor(worldPoint ?? this.lastPointerWorld);
|
|
2866
|
+
if (this.host.style.cursor !== next)
|
|
2867
|
+
this.host.style.cursor = next;
|
|
2868
|
+
}
|
|
2869
|
+
/**
|
|
2870
|
+
* The CSS cursor for the current state. Priority: active gesture → text edit → pan affordance →
|
|
2871
|
+
* draw tool → idle hover hit-test. Pure read of editor state; no side effects.
|
|
2872
|
+
*/
|
|
2873
|
+
computeCursor(p) {
|
|
2874
|
+
// Each outcome is a (role, fallback-keyword) pair; `resolveCursor` returns
|
|
2875
|
+
// a host-registered custom image for that role if one exists, else the
|
|
2876
|
+
// keyword. Roles are the stable override keys (see `setCursorOverride`).
|
|
2877
|
+
const r = (role, keyword) => this.resolveCursor(role, keyword);
|
|
2878
|
+
const resizeRole = (h) => r(RESIZE_ROLE[h], cursorForHandle(h));
|
|
2879
|
+
// 1. Active gestures (highest priority — what the pointer is doing now).
|
|
2880
|
+
if (this.panGesture)
|
|
2881
|
+
return r("pan-active", "grabbing");
|
|
2882
|
+
if (this.linkDragFromAnchor?.moved === true)
|
|
2883
|
+
return r("draw", "crosshair");
|
|
2884
|
+
if (this.isDraggingWaypoint || this.isDraggingSegment)
|
|
2885
|
+
return r("move", "grabbing");
|
|
2886
|
+
if (this.annotationDrag?.moved === true)
|
|
2887
|
+
return r("move", "grabbing");
|
|
2888
|
+
if (this.brushStroke)
|
|
2889
|
+
return r("draw", "crosshair");
|
|
2890
|
+
// Machine-driven drag past the threshold (`gestureTx` opens then): resize
|
|
2891
|
+
// shows the handle's arrow; element / link move shows grabbing.
|
|
2892
|
+
if (this.gestureTx) {
|
|
2893
|
+
const t = this.actor.getSnapshot().context.pressTarget;
|
|
2894
|
+
if (t && (t.kind === "handle" || t.kind === "group-handle"))
|
|
2895
|
+
return resizeRole(t.handle);
|
|
2896
|
+
if (t && (t.kind === "element" || t.kind === "link" || t.kind === "edge-endpoint")) {
|
|
2897
|
+
return r("move", "grabbing");
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
// 2. In-canvas text editing → I-beam.
|
|
2901
|
+
if (this.editingTextElement !== null)
|
|
2902
|
+
return r("text", "text");
|
|
2903
|
+
// 3. Pan affordance (idle): Space held or hand tool.
|
|
2904
|
+
if (this.spaceHeld || this.mode === "hand")
|
|
2905
|
+
return r("pan-ready", "grab");
|
|
2906
|
+
// 4. Draw tools (idle, before a gesture starts).
|
|
2907
|
+
switch (this.mode) {
|
|
2908
|
+
case "draw-rect":
|
|
2909
|
+
case "draw-ellipse":
|
|
2910
|
+
case "draw-frame":
|
|
2911
|
+
case "draw-edge":
|
|
2912
|
+
case "brush":
|
|
2913
|
+
return r("draw", "crosshair");
|
|
2914
|
+
case "draw-text":
|
|
2915
|
+
return r("text", "text");
|
|
2916
|
+
default:
|
|
2917
|
+
break;
|
|
2918
|
+
}
|
|
2919
|
+
// 5. Idle hover in select mode — key off the hit-test target.
|
|
2920
|
+
if (p) {
|
|
2921
|
+
if (this.isOverLinkStartDot(p))
|
|
2922
|
+
return r("link-start", "crosshair");
|
|
2923
|
+
const t = this.hitTest(p);
|
|
2924
|
+
switch (t.kind) {
|
|
2925
|
+
case "handle":
|
|
2926
|
+
case "group-handle":
|
|
2927
|
+
return resizeRole(t.handle);
|
|
2928
|
+
case "edge-endpoint":
|
|
2929
|
+
return r("link-handle", "grab");
|
|
2930
|
+
case "annotation":
|
|
2931
|
+
return r("annotation", "pointer");
|
|
2932
|
+
default:
|
|
2933
|
+
return r("default", "default");
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
return r("default", "default");
|
|
2937
|
+
}
|
|
2938
|
+
/**
|
|
2939
|
+
* Resolve a cursor role to a CSS `cursor` value: a host-registered custom
|
|
2940
|
+
* image (via {@link setCursorOverride}) if present, else `fallbackKeyword`.
|
|
2941
|
+
*/
|
|
2942
|
+
resolveCursor(role, fallbackKeyword) {
|
|
2943
|
+
const spec = this.cursorOverrides.get(role);
|
|
2944
|
+
return spec === undefined ? fallbackKeyword : cssCursor(spec, fallbackKeyword);
|
|
2945
|
+
}
|
|
2946
|
+
/**
|
|
2947
|
+
* Register (or clear, with `null`) a custom cursor image for a state role.
|
|
2948
|
+
* The image is shown wherever `computeCursor` resolves that role; pass a
|
|
2949
|
+
* `CursorSpec` object for a DPR-aware image (`image-set(1x, 2x)`) with a
|
|
2950
|
+
* hotspot + keyword fallback, or a raw CSS cursor string. Host-only view
|
|
2951
|
+
* state — not persisted.
|
|
2952
|
+
*/
|
|
2953
|
+
setCursorOverride(role, spec) {
|
|
2954
|
+
if (spec === null)
|
|
2955
|
+
this.cursorOverrides.delete(role);
|
|
2956
|
+
else
|
|
2957
|
+
this.cursorOverrides.set(role, spec);
|
|
2958
|
+
this.refreshCursor();
|
|
2959
|
+
}
|
|
2960
|
+
/**
|
|
2961
|
+
* True when `p` is within the grab radius of one of the single selected
|
|
2962
|
+
* element's link-start dots — used to show a `crosshair` (start a link).
|
|
2963
|
+
* Mirrors the anchor-drag hit-test in pointer-binding so the cursor matches
|
|
2964
|
+
* exactly where a press would begin a link.
|
|
2965
|
+
*/
|
|
2966
|
+
isOverLinkStartDot(p) {
|
|
2967
|
+
if (this.mode !== "select" || this._selection.size !== 1)
|
|
2968
|
+
return false;
|
|
2969
|
+
const id = [...this._selection][0];
|
|
2970
|
+
if (id === undefined)
|
|
2971
|
+
return false;
|
|
2972
|
+
const shape = getElement(this._scene, id);
|
|
2973
|
+
if (!shape)
|
|
2974
|
+
return false;
|
|
2975
|
+
const zoom = this._scene.viewport.zoom || 1;
|
|
2976
|
+
const { worldPoints } = anchorOverlayPoints(shape, LINK_START_ANCHOR_OUTSET / zoom);
|
|
2977
|
+
const grab = (ANCHOR_DOT_ACTIVE_RADIUS + this.anchorStartHitSlop) / zoom;
|
|
2978
|
+
const grab2 = grab * grab;
|
|
2979
|
+
for (const wp of worldPoints) {
|
|
2980
|
+
const dx = wp.x - p.x;
|
|
2981
|
+
const dy = wp.y - p.y;
|
|
2982
|
+
if (dx * dx + dy * dy <= grab2)
|
|
2983
|
+
return true;
|
|
2984
|
+
}
|
|
2985
|
+
return false;
|
|
2986
|
+
}
|
|
2987
|
+
/** True when the given layer exists and is marked `locked`. */
|
|
2988
|
+
isLayerLocked(layerId) {
|
|
2989
|
+
const layer = this._scene.layers.get(layerId);
|
|
2990
|
+
return layer?.locked === true;
|
|
2991
|
+
}
|
|
2992
|
+
/**
|
|
2993
|
+
* Combined interactivity check: false when the shape's layer is
|
|
2994
|
+
* locked, or when the shape itself or any ancestor via `parentId`
|
|
2995
|
+
* carries `locked: true` (group lock propagation). Hit-test treats
|
|
2996
|
+
* non-interactable hits as misses; render still draws them so the
|
|
2997
|
+
* user can see what's locked.
|
|
2998
|
+
*/
|
|
2999
|
+
isElementInteractable(shape) {
|
|
3000
|
+
if (this.isLayerLocked(shape.layerId))
|
|
3001
|
+
return false;
|
|
3002
|
+
if (isElementHidden(this._scene, shape))
|
|
3003
|
+
return false;
|
|
3004
|
+
// NOTE: a `locked` element IS interactable for SELECTION (so the user can
|
|
3005
|
+
// click it to unlock) — movement / resize are blocked separately via
|
|
3006
|
+
// `isElementManipulable`. Click-through past a locked shape is therefore
|
|
3007
|
+
// disabled, matching standard.
|
|
3008
|
+
return true;
|
|
3009
|
+
}
|
|
3010
|
+
/**
|
|
3011
|
+
* Can this shape be moved / resized? False when the shape (or an ancestor)
|
|
3012
|
+
* is `locked`, its layer is locked, or it's hidden. Distinct from
|
|
3013
|
+
* `isElementInteractable`, which still allows selecting a locked shape so it
|
|
3014
|
+
* can be unlocked.
|
|
3015
|
+
*/
|
|
3016
|
+
isElementManipulable(shape) {
|
|
3017
|
+
if (this.isLayerLocked(shape.layerId))
|
|
3018
|
+
return false;
|
|
3019
|
+
if (isElementLocked(this._scene, shape))
|
|
3020
|
+
return false;
|
|
3021
|
+
if (isElementHidden(this._scene, shape))
|
|
3022
|
+
return false;
|
|
3023
|
+
return true;
|
|
3024
|
+
}
|
|
3025
|
+
/**
|
|
3026
|
+
* Toggle the `locked` flag on the selection (standard `⌘⇧L`). If any selected
|
|
3027
|
+
* element is currently unlocked, lock all; otherwise unlock all. One undo
|
|
3028
|
+
* step. A locked element stays selectable (click → select → unlock) but
|
|
3029
|
+
* can't be moved or resized.
|
|
3030
|
+
*/
|
|
3031
|
+
toggleLockSelection() {
|
|
3032
|
+
if (this._selection.size === 0)
|
|
3033
|
+
return;
|
|
3034
|
+
const ids = [...this._selection];
|
|
3035
|
+
const anyUnlocked = ids.some((id) => getElement(this._scene, id)?.locked !== true);
|
|
3036
|
+
const tx = this._history.transaction();
|
|
3037
|
+
for (const id of ids) {
|
|
3038
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
3039
|
+
const copy = { ...s };
|
|
3040
|
+
if (anyUnlocked)
|
|
3041
|
+
copy.locked = true;
|
|
3042
|
+
else
|
|
3043
|
+
delete copy.locked;
|
|
3044
|
+
return copy;
|
|
3045
|
+
});
|
|
3046
|
+
this._scene = r.scene;
|
|
3047
|
+
tx.add(r.patch);
|
|
3048
|
+
}
|
|
3049
|
+
tx.commit();
|
|
3050
|
+
this.notify();
|
|
3051
|
+
}
|
|
3052
|
+
/**
|
|
3053
|
+
* Promote a hit shape to the topmost ancestor whose group we have NOT
|
|
3054
|
+
* "entered" yet. Only **`group`**-typed parents promote — containers
|
|
3055
|
+
* (swim-lane, frame) intentionally let click hits land on their
|
|
3056
|
+
* children. Group is an abstract wrapper that has no visual identity,
|
|
3057
|
+
* so promoting up to it is the only way to select it; a container has
|
|
3058
|
+
* its own body, header, etc. and clicking inside it should let users
|
|
3059
|
+
* pick the actual child shape (rectangle, sticky, …) — same affordance
|
|
3060
|
+
* as standard.
|
|
3061
|
+
*
|
|
3062
|
+
* Stops at the first non-`group` parent. With `_enteredGroup` set,
|
|
3063
|
+
* the walk also stops just below that group so children can be edited
|
|
3064
|
+
* directly.
|
|
3065
|
+
*/
|
|
3066
|
+
promoteToGroupRoot(shape) {
|
|
3067
|
+
return promoteToGroupRootHelper(this._scene, shape, this._enteredGroup);
|
|
3068
|
+
}
|
|
3069
|
+
/**
|
|
3070
|
+
* Topmost group ancestor of `shape` (walks parentId chain, returns
|
|
3071
|
+
* the highest `type === "group"` parent). `null` if `shape` has no
|
|
3072
|
+
* group ancestor. Used by drill-down: a double-click on a shape
|
|
3073
|
+
* with a group ancestor enters that group. Body extracted to
|
|
3074
|
+
* `./group-helpers.ts`.
|
|
3075
|
+
*/
|
|
3076
|
+
topGroupAncestor(shape) {
|
|
3077
|
+
return topGroupAncestorHelper(this._scene, shape);
|
|
3078
|
+
}
|
|
3079
|
+
/**
|
|
3080
|
+
* True when `elementId`'s parent chain contains `groupId`. Used by the
|
|
3081
|
+
* isolation exit path: a click on a shape whose parent chain *does
|
|
3082
|
+
* not* lead through the entered group is a click "outside" the
|
|
3083
|
+
* group, which exits isolation.
|
|
3084
|
+
*/
|
|
3085
|
+
isDescendantOfGroup(elementId, groupId) {
|
|
3086
|
+
return isDescendantOfGroupHelper(this._scene, elementId, groupId);
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* Compute the dim set for isolation rendering: every shape whose
|
|
3090
|
+
* parent chain does NOT pass through `enteredGroupId`. The entered
|
|
3091
|
+
* group itself is treated as "inside" (returns true from
|
|
3092
|
+
* isDescendantOfGroup) so it stays at full alpha — but groups have
|
|
3093
|
+
* no intrinsic geometry, so this only matters for the
|
|
3094
|
+
* group-bounds-outline overlay path, not the shape render.
|
|
3095
|
+
*
|
|
3096
|
+
* Defensive: shapes in the current selection are never dimmed. The
|
|
3097
|
+
* focus shape (drilled-into child) is always a group descendant in
|
|
3098
|
+
* practice, but the guard keeps the contract simple — "what you've
|
|
3099
|
+
* selected, you can see".
|
|
3100
|
+
*/
|
|
3101
|
+
// Body moved to `./editor/shape-filters.ts`.
|
|
3102
|
+
computeHiddenElements() {
|
|
3103
|
+
return computeHiddenElementsPure(this._scene);
|
|
3104
|
+
}
|
|
3105
|
+
computeDimElements(enteredGroupId) {
|
|
3106
|
+
return computeDimElementsHelper(this._scene, this._selection, enteredGroupId);
|
|
3107
|
+
}
|
|
3108
|
+
/**
|
|
3109
|
+
* Enter a group — subsequent hits inside this group return children
|
|
3110
|
+
* directly instead of the group root. `null` exits group-edit mode.
|
|
3111
|
+
* Bound to double-click on a group in the default handler.
|
|
3112
|
+
*/
|
|
3113
|
+
enterGroup(groupId) {
|
|
3114
|
+
this._enteredGroup = groupId;
|
|
3115
|
+
this.notify();
|
|
3116
|
+
}
|
|
3117
|
+
/**
|
|
3118
|
+
* Enter the single selected container — select its contents (standard `⌘⇧↓`).
|
|
3119
|
+
* Members are children via `parentId` (group / template container) or via
|
|
3120
|
+
* `frameId` (frame). For a group we also set `enteredGroup` so subsequent
|
|
3121
|
+
* clicks land on children. No-op unless exactly one container with members
|
|
3122
|
+
* is selected.
|
|
3123
|
+
*/
|
|
3124
|
+
enterContainer() {
|
|
3125
|
+
if (this._selection.size !== 1)
|
|
3126
|
+
return;
|
|
3127
|
+
const id = req([...this._selection][0]);
|
|
3128
|
+
const el = getElement(this._scene, id);
|
|
3129
|
+
if (!el)
|
|
3130
|
+
return;
|
|
3131
|
+
const isFrameEl = el.type === "frame";
|
|
3132
|
+
const members = [];
|
|
3133
|
+
for (const s of this._scene.elements.values()) {
|
|
3134
|
+
if (s.parentId === id || (isFrameEl && s.frameId === id))
|
|
3135
|
+
members.push(s.id);
|
|
3136
|
+
}
|
|
3137
|
+
if (members.length === 0)
|
|
3138
|
+
return;
|
|
3139
|
+
if (el.type === "group")
|
|
3140
|
+
this._enteredGroup = id;
|
|
3141
|
+
this.setSelection(members);
|
|
3142
|
+
}
|
|
3143
|
+
/**
|
|
3144
|
+
* Exit to the container of the current selection — select the parent group /
|
|
3145
|
+
* template container (`parentId`) or frame (`frameId`) when every selected
|
|
3146
|
+
* element shares one (standard `⌘⇧↑`). Clears `enteredGroup`. No-op when there
|
|
3147
|
+
* is no single common container.
|
|
3148
|
+
*/
|
|
3149
|
+
exitContainer() {
|
|
3150
|
+
if (this._selection.size === 0)
|
|
3151
|
+
return;
|
|
3152
|
+
let parent;
|
|
3153
|
+
let common = true;
|
|
3154
|
+
for (const sid of this._selection) {
|
|
3155
|
+
const s = getElement(this._scene, sid);
|
|
3156
|
+
const p = s?.parentId ?? s?.frameId;
|
|
3157
|
+
if (p === undefined) {
|
|
3158
|
+
common = false;
|
|
3159
|
+
break;
|
|
3160
|
+
}
|
|
3161
|
+
if (parent === undefined)
|
|
3162
|
+
parent = p;
|
|
3163
|
+
else if (parent !== p) {
|
|
3164
|
+
common = false;
|
|
3165
|
+
break;
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
this._enteredGroup = null;
|
|
3169
|
+
if (common && parent !== undefined)
|
|
3170
|
+
this.setSelection([parent]);
|
|
3171
|
+
else
|
|
3172
|
+
this.notify();
|
|
3173
|
+
}
|
|
3174
|
+
/** Currently "entered" group, if any. */
|
|
3175
|
+
get enteredGroup() {
|
|
3176
|
+
return this._enteredGroup;
|
|
3177
|
+
}
|
|
3178
|
+
/**
|
|
3179
|
+
* SpatialGrid-accelerated topmost-shape lookup. Linear scan for small
|
|
3180
|
+
* scenes; for larger scenes builds a grid lazily, keyed by current
|
|
3181
|
+
* scene-identity. Scene operations replace `_scene` (immutable patches),
|
|
3182
|
+
* so reference-equality is a sufficient invalidation signal.
|
|
3183
|
+
*/
|
|
3184
|
+
acceleratedElementAt(worldPoint) {
|
|
3185
|
+
if (this._scene.elements.size < LARGE_SCENE_HIT_THRESHOLD) {
|
|
3186
|
+
return getElementAt(this._scene, worldPoint);
|
|
3187
|
+
}
|
|
3188
|
+
return getElementAtIndexed(this._scene, this.ensureSpatialIndex(), worldPoint);
|
|
3189
|
+
}
|
|
3190
|
+
/**
|
|
3191
|
+
* Build (or return the cached) `SpatialGrid` for the current scene.
|
|
3192
|
+
* Re-built only when `_scene` reference changes — scene operations
|
|
3193
|
+
* always produce a fresh object, so reference equality is a
|
|
3194
|
+
* sufficient invalidation signal.
|
|
3195
|
+
*
|
|
3196
|
+
* Shared between the hit-test path (`acceleratedElementAt`) and the
|
|
3197
|
+
* renderer pass (passed to `renderScene` as `spatialIndex`), so
|
|
3198
|
+
* the grid build cost is amortised across both consumers.
|
|
3199
|
+
*/
|
|
3200
|
+
ensureSpatialIndex() {
|
|
3201
|
+
const cached = this.spatialIndexCache;
|
|
3202
|
+
if (cached?.scene === this._scene)
|
|
3203
|
+
return cached.index;
|
|
3204
|
+
const index = buildSpatialIndex(this._scene);
|
|
3205
|
+
this.spatialIndexCache = { scene: this._scene, index };
|
|
3206
|
+
return index;
|
|
3207
|
+
}
|
|
3208
|
+
/**
|
|
3209
|
+
* Group-isolation click routing. Returns `true` if the click was
|
|
3210
|
+
* handled (caller should skip the default applyEmit), `false` if the
|
|
3211
|
+
* normal selection emit should still run.
|
|
3212
|
+
*
|
|
3213
|
+
* Three paths fire here:
|
|
3214
|
+
* 1. **Double-click on a grouped shape (not yet in isolation):**
|
|
3215
|
+
* enter that group; select the raw inner shape (bypassing the
|
|
3216
|
+
* group-root promotion that ran in hitTest).
|
|
3217
|
+
* 2. **Inside isolation, click on a non-descendant shape OR empty
|
|
3218
|
+
* space:** exit isolation. Let the normal click then run so the
|
|
3219
|
+
* newly clicked element / empty selection takes hold.
|
|
3220
|
+
* 3. **Inside isolation, double-click on the entered group's own
|
|
3221
|
+
* child group:** drill another level deeper. (Implicit: same as
|
|
3222
|
+
* case 1 but topGroupAncestor here returns the inner child
|
|
3223
|
+
* group because the outer group is already entered.)
|
|
3224
|
+
*
|
|
3225
|
+
* Side-effect: updates `lastClickAt` / `lastClickWorldPoint`
|
|
3226
|
+
* regardless of result, so subsequent calls can detect a double-
|
|
3227
|
+
* click against this event.
|
|
3228
|
+
*/
|
|
3229
|
+
routeIsolationClick(clickEffect, worldPoint) {
|
|
3230
|
+
const now = performance.now();
|
|
3231
|
+
const isDouble = now - this.lastClickAt < DOUBLE_CLICK_MS &&
|
|
3232
|
+
this.lastClickWorldPoint !== null &&
|
|
3233
|
+
distanceTo(this.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
|
|
3234
|
+
this.lastClickAt = now;
|
|
3235
|
+
this.lastClickWorldPoint = worldPoint;
|
|
3236
|
+
// Double-click the frame HEADER (label strip above the body) → rename.
|
|
3237
|
+
// Checked before the clickEffect gate because the header sits outside
|
|
3238
|
+
// the frame's hit-test bounds, so the click produces SELECT_CLEAR (or
|
|
3239
|
+
// no effect), not a frame select.
|
|
3240
|
+
if (isDouble) {
|
|
3241
|
+
const headerFrame = this.frameHeaderAt(worldPoint);
|
|
3242
|
+
if (headerFrame !== null) {
|
|
3243
|
+
this.beginFrameNameEdit(headerFrame);
|
|
3244
|
+
return true;
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
if (!clickEffect)
|
|
3248
|
+
return false;
|
|
3249
|
+
// Click outside the entered group while in isolation → exit; let
|
|
3250
|
+
// the normal click effect run after.
|
|
3251
|
+
if (this._enteredGroup !== null) {
|
|
3252
|
+
const targetId = clickEffect.type === "SELECT_REPLACE" || clickEffect.type === "SELECT_TOGGLE"
|
|
3253
|
+
? clickEffect.id
|
|
3254
|
+
: null;
|
|
3255
|
+
const stillInside = targetId !== null && this.isDescendantOfGroup(targetId, this._enteredGroup);
|
|
3256
|
+
if (!stillInside) {
|
|
3257
|
+
this._enteredGroup = null;
|
|
3258
|
+
this.notify();
|
|
3259
|
+
// Fall through — apply the normal click effect (caller).
|
|
3260
|
+
return false;
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
// Double-click handling for SELECT_REPLACE / SELECT_TOGGLE
|
|
3264
|
+
// effects. Two outcomes, in priority order:
|
|
3265
|
+
// 1) text shape → open inline text editor (highest priority —
|
|
3266
|
+
// double-clicking text in any editor means "edit the body");
|
|
3267
|
+
// 2) shape with a group ancestor → drill into that group.
|
|
3268
|
+
// Lasso / edge ops are not double-click candidates and fall
|
|
3269
|
+
// through to the normal single-click handler.
|
|
3270
|
+
// Double-click on a link → edit its caption inline (standard).
|
|
3271
|
+
if (isDouble && clickEffect.type === "SELECT_EDGE_REPLACE") {
|
|
3272
|
+
this._selectedLinks = LinkSelection.single(clickEffect.id);
|
|
3273
|
+
this._selection = Selection.EMPTY;
|
|
3274
|
+
this.beginLinkCaptionEdit(clickEffect.id);
|
|
3275
|
+
return true;
|
|
3276
|
+
}
|
|
3277
|
+
if (isDouble &&
|
|
3278
|
+
(clickEffect.type === "SELECT_REPLACE" || clickEffect.type === "SELECT_TOGGLE")) {
|
|
3279
|
+
const raw = this.acceleratedElementAt(worldPoint);
|
|
3280
|
+
if (raw?.type === "text") {
|
|
3281
|
+
this.beginTextEdit(raw.id);
|
|
3282
|
+
return true;
|
|
3283
|
+
}
|
|
3284
|
+
if (raw?.type === "frame") {
|
|
3285
|
+
this.beginFrameNameEdit(raw.id);
|
|
3286
|
+
return true;
|
|
3287
|
+
}
|
|
3288
|
+
if (raw) {
|
|
3289
|
+
const top = this.topGroupAncestor(raw);
|
|
3290
|
+
// If the topmost group is the one we've already entered, drill
|
|
3291
|
+
// one level deeper — pick the next-down group on the chain.
|
|
3292
|
+
const target = this.pickDrillTarget(raw, top);
|
|
3293
|
+
if (target) {
|
|
3294
|
+
this._enteredGroup = target.id;
|
|
3295
|
+
this._selection = Selection.single(raw.id);
|
|
3296
|
+
if (this._selectedLinks.size > 0)
|
|
3297
|
+
this._selectedLinks = LinkSelection.EMPTY;
|
|
3298
|
+
this.notify();
|
|
3299
|
+
return true;
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
return false;
|
|
3304
|
+
}
|
|
3305
|
+
/**
|
|
3306
|
+
* Given the raw shape under the cursor and its topmost group
|
|
3307
|
+
* ancestor, pick which group to "enter" on a drill-down.
|
|
3308
|
+
*
|
|
3309
|
+
* - No group ancestor → null (drill-down doesn't apply).
|
|
3310
|
+
* - Top group not yet entered → enter top.
|
|
3311
|
+
* - Top group already entered → walk down the chain to find the
|
|
3312
|
+
* next group inward (one level deeper).
|
|
3313
|
+
*/
|
|
3314
|
+
pickDrillTarget(raw, top) {
|
|
3315
|
+
return pickDrillTargetHelper(this._scene, raw, top, this._enteredGroup);
|
|
3316
|
+
}
|
|
3317
|
+
applyEmit(emit) {
|
|
3318
|
+
switch (emit.type) {
|
|
3319
|
+
case "SELECT_REPLACE":
|
|
3320
|
+
// Plain element click replaces the whole selection (elements + links).
|
|
3321
|
+
this._selection = Selection.single(emit.id);
|
|
3322
|
+
this._selectedLinks = LinkSelection.EMPTY;
|
|
3323
|
+
this.notify();
|
|
3324
|
+
return;
|
|
3325
|
+
case "SELECT_TOGGLE":
|
|
3326
|
+
// Shift/meta element click toggles the element; selected links stay.
|
|
3327
|
+
this._selection = Selection.toggle(this._selection, emit.id);
|
|
3328
|
+
this.notify();
|
|
3329
|
+
return;
|
|
3330
|
+
case "SELECT_CLEAR":
|
|
3331
|
+
this._selection = Selection.EMPTY;
|
|
3332
|
+
this._selectedLinks = LinkSelection.EMPTY;
|
|
3333
|
+
this.notify();
|
|
3334
|
+
return;
|
|
3335
|
+
case "SELECT_EDGE_REPLACE":
|
|
3336
|
+
// Plain link click replaces the whole selection with this one link.
|
|
3337
|
+
this._selectedLinks = LinkSelection.single(emit.id);
|
|
3338
|
+
this._selection = Selection.EMPTY;
|
|
3339
|
+
this.notify();
|
|
3340
|
+
return;
|
|
3341
|
+
case "SELECT_EDGE_TOGGLE":
|
|
3342
|
+
// Shift/meta link click toggles the link; selected elements stay.
|
|
3343
|
+
this._selectedLinks = LinkSelection.toggle(this._selectedLinks, emit.id);
|
|
3344
|
+
this.notify();
|
|
3345
|
+
return;
|
|
3346
|
+
case "SELECT_EDGE_CLEAR":
|
|
3347
|
+
if (this._selectedLinks.size > 0) {
|
|
3348
|
+
this._selectedLinks = LinkSelection.EMPTY;
|
|
3349
|
+
this.notify();
|
|
3350
|
+
}
|
|
3351
|
+
return;
|
|
3352
|
+
case "UPDATE_EDGE_ENDPOINT_PREVIEW":
|
|
3353
|
+
this.applyLinkEndpointMove(emit.linkId, emit.side, emit.toPoint);
|
|
3354
|
+
return;
|
|
3355
|
+
case "UPDATE_EDGE_ENDPOINT":
|
|
3356
|
+
this.applyLinkEndpointUpdate(emit);
|
|
3357
|
+
return;
|
|
3358
|
+
case "LASSO_PROGRESS":
|
|
3359
|
+
// Capture the pre-lasso selection on the first progress emit
|
|
3360
|
+
// of a gesture; subsequent emits use it as the additive base.
|
|
3361
|
+
this.lassoBaseSelection ??= this._selection;
|
|
3362
|
+
this.lassoBaseLinks ??= this._selectedLinks;
|
|
3363
|
+
this.lassoPreview = emit.bounds;
|
|
3364
|
+
this.applyLassoLiveSelection(emit.bounds, emit.mode);
|
|
3365
|
+
this.notify();
|
|
3366
|
+
return;
|
|
3367
|
+
case "LASSO_CLEAR":
|
|
3368
|
+
if (this.lassoPreview !== null ||
|
|
3369
|
+
this.lassoBaseSelection !== null ||
|
|
3370
|
+
this.lassoBaseLinks !== null) {
|
|
3371
|
+
this.lassoPreview = null;
|
|
3372
|
+
this.lassoBaseSelection = null;
|
|
3373
|
+
this.lassoBaseLinks = null;
|
|
3374
|
+
this.notify();
|
|
3375
|
+
}
|
|
3376
|
+
return;
|
|
3377
|
+
case "SELECT_BY_BOUNDS":
|
|
3378
|
+
// Final commit — uses the same logic as the live preview so
|
|
3379
|
+
// the visible selection matches what lands. Reset the base
|
|
3380
|
+
// snapshot so the next gesture re-captures it.
|
|
3381
|
+
this.lassoBaseSelection = null;
|
|
3382
|
+
this.lassoBaseLinks = null;
|
|
3383
|
+
this.applySelectByBounds(emit.bounds, emit.mode);
|
|
3384
|
+
return;
|
|
3385
|
+
case "MOVE_SHAPE":
|
|
3386
|
+
if (this.groupMoveOrigin) {
|
|
3387
|
+
this.applyGroupMove(emit.delta);
|
|
3388
|
+
}
|
|
3389
|
+
else {
|
|
3390
|
+
this.applyMove(emit.id, emit.delta, emit.originalBounds);
|
|
3391
|
+
}
|
|
3392
|
+
return;
|
|
3393
|
+
case "RESIZE_GROUP":
|
|
3394
|
+
this.applyGroupResize(emit.handle, emit.delta, emit.originalBounds);
|
|
3395
|
+
return;
|
|
3396
|
+
case "RESIZE_SHAPE":
|
|
3397
|
+
this.applyResize(emit.id, emit.handle, emit.delta, emit.originalBounds);
|
|
3398
|
+
return;
|
|
3399
|
+
case "CREATE_SHAPE":
|
|
3400
|
+
this.applyCreate(emit.shapeType, emit.bounds);
|
|
3401
|
+
return;
|
|
3402
|
+
case "CREATE_EDGE":
|
|
3403
|
+
this.applyCreateLink(emit);
|
|
3404
|
+
return;
|
|
3405
|
+
case "DRAW_EDGE_PREVIEW":
|
|
3406
|
+
this.applyLinkPreview(emit.fromElement, emit.fromPoint, emit.toPoint);
|
|
3407
|
+
return;
|
|
3408
|
+
case "DRAW_EDGE_PREVIEW_CLEAR":
|
|
3409
|
+
if (this.edgePreview) {
|
|
3410
|
+
this.edgePreview = null;
|
|
3411
|
+
this.notify();
|
|
3412
|
+
}
|
|
3413
|
+
return;
|
|
3414
|
+
case "TEMPLATE_TAP":
|
|
3415
|
+
// Forward to subscribers via a custom listener path.
|
|
3416
|
+
for (const fn of this.templateTapListeners)
|
|
3417
|
+
fn(emit);
|
|
3418
|
+
return;
|
|
3419
|
+
case "TEMPLATE_DROP":
|
|
3420
|
+
for (const fn of this.templateDropListeners)
|
|
3421
|
+
fn(emit);
|
|
3422
|
+
return;
|
|
3423
|
+
case "MOVE_ANNOTATION":
|
|
3424
|
+
this.applyAnnotationMove(emit.id, emit.delta, emit.originalPosition);
|
|
3425
|
+
return;
|
|
3426
|
+
case "COMMIT_ANNOTATION_DRAG":
|
|
3427
|
+
this.finalizeOpenGestureTx();
|
|
3428
|
+
return;
|
|
3429
|
+
}
|
|
3430
|
+
}
|
|
3431
|
+
/**
|
|
3432
|
+
* Drag handler for annotation pins. Moves the pin to
|
|
3433
|
+
* `origin + delta`. Anchor semantics: for shape-anchored
|
|
3434
|
+
* annotations the `position` field is shape-local, so dragging
|
|
3435
|
+
* still updates the same field — the editor doesn't try to
|
|
3436
|
+
* reparent the anchor mid-drag; user wants the pin under the
|
|
3437
|
+
* cursor and that's exactly what `position + (delta in world)`
|
|
3438
|
+
* gives, regardless of which space the position is interpreted
|
|
3439
|
+
* in (`getAnnotationWorldPosition` already adds the shape's
|
|
3440
|
+
* world position when anchored).
|
|
3441
|
+
*
|
|
3442
|
+
* Wrapped in a single gestureTx so per-move updates collapse
|
|
3443
|
+
* into one undo step.
|
|
3444
|
+
*/
|
|
3445
|
+
// Pure body in `./editor/applies/move.ts`.
|
|
3446
|
+
applyAnnotationMove(id, delta, origin) {
|
|
3447
|
+
const result = computeAnnotationMovePatch(this._scene, id, delta, origin);
|
|
3448
|
+
if (!result)
|
|
3449
|
+
return;
|
|
3450
|
+
this._scene = result.scene;
|
|
3451
|
+
this.recordGesturePatch(result.patch);
|
|
3452
|
+
this.notify();
|
|
3453
|
+
}
|
|
3454
|
+
templateTapListeners = new Set();
|
|
3455
|
+
templateDropListeners = new Set();
|
|
3456
|
+
/**
|
|
3457
|
+
* Subscribe to template button taps. Returns an unsubscribe function.
|
|
3458
|
+
* Hosts use this to route template button clicks to their own actions.
|
|
3459
|
+
*/
|
|
3460
|
+
onTemplateTap(fn) {
|
|
3461
|
+
this.templateTapListeners.add(fn);
|
|
3462
|
+
return () => this.templateTapListeners.delete(fn);
|
|
3463
|
+
}
|
|
3464
|
+
/**
|
|
3465
|
+
* Subscribe to drops onto template drop-zones. Returns an unsubscribe fn.
|
|
3466
|
+
* Hosts decide what to do with the drop (e.g. add a child shape, link
|
|
3467
|
+
* templates together).
|
|
3468
|
+
*/
|
|
3469
|
+
onTemplateDrop(fn) {
|
|
3470
|
+
this.templateDropListeners.add(fn);
|
|
3471
|
+
return () => this.templateDropListeners.delete(fn);
|
|
3472
|
+
}
|
|
3473
|
+
/**
|
|
3474
|
+
* Dispatch a TEMPLATE_DROP emit programmatically. Hosts call this from their
|
|
3475
|
+
* own DOM `drop` listener after looking up which drop-zone (if any) is
|
|
3476
|
+
* under the pointer via `findDropZoneAt`.
|
|
3477
|
+
*/
|
|
3478
|
+
dispatchTemplateDrop(emit) {
|
|
3479
|
+
this.applyEmit(emit);
|
|
3480
|
+
}
|
|
3481
|
+
// Pure body in `./editor/applies/move.ts`.
|
|
3482
|
+
applyMove(id, delta, originalBounds) {
|
|
3483
|
+
// Locked / layer-locked elements are selectable but don't move.
|
|
3484
|
+
const el = getElement(this._scene, id);
|
|
3485
|
+
if (el && !this.isElementManipulable(el))
|
|
3486
|
+
return;
|
|
3487
|
+
const d = this.snapActive() ? snapMoveDelta(originalBounds, delta, this.snapSpacing()) : delta;
|
|
3488
|
+
const patch = computeElementMovePatch(this._scene, id, d, originalBounds);
|
|
3489
|
+
if (!patch)
|
|
3490
|
+
return;
|
|
3491
|
+
this._scene = apply(this._scene, patch);
|
|
3492
|
+
this.recordGesturePatch(patch);
|
|
3493
|
+
this.notify();
|
|
3494
|
+
}
|
|
3495
|
+
applyGroupMove(delta) {
|
|
3496
|
+
if (!this.groupMoveOrigin)
|
|
3497
|
+
return;
|
|
3498
|
+
const d = this.snapActive()
|
|
3499
|
+
? snapGroupDelta(this.groupMoveOrigin, delta, this.snapSpacing())
|
|
3500
|
+
: delta;
|
|
3501
|
+
const patches = computeGroupMovePatches(this._scene, this.groupMoveOrigin, d);
|
|
3502
|
+
for (const patch of patches) {
|
|
3503
|
+
this._scene = apply(this._scene, patch);
|
|
3504
|
+
this.recordGesturePatch(patch);
|
|
3505
|
+
}
|
|
3506
|
+
// Selected links + connectors bound on both ends to moved elements
|
|
3507
|
+
// translate with the drag (standard parity) — shifted by the
|
|
3508
|
+
// same delta from the press-time snapshot.
|
|
3509
|
+
if (this.groupLinkMoveOrigin) {
|
|
3510
|
+
const linkPatches = computeMovingLinkPatches(this._scene, this.groupLinkMoveOrigin, d);
|
|
3511
|
+
for (const patch of linkPatches) {
|
|
3512
|
+
this._scene = apply(this._scene, patch);
|
|
3513
|
+
this.recordGesturePatch(patch);
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
this.notify();
|
|
3517
|
+
}
|
|
3518
|
+
// Body moved to `./editor/viewport-helpers.ts`.
|
|
3519
|
+
computeViewportWorld() {
|
|
3520
|
+
return computeViewportWorldPure(this._scene);
|
|
3521
|
+
}
|
|
3522
|
+
/**
|
|
3523
|
+
* Identity-diff the current scene against the last rendered one and
|
|
3524
|
+
* return the union AABB of every shape/edge that changed reference
|
|
3525
|
+
* (added, removed, or replaced). Returns `null` to force a full
|
|
3526
|
+
* clear when something that affects the entire surface changes —
|
|
3527
|
+
* viewport pan/zoom/resize, layer visibility, or the first frame.
|
|
3528
|
+
*
|
|
3529
|
+
* Empty union (returned as a zero-area bbox far off-screen) means
|
|
3530
|
+
* nothing changed; renderScene will cull every shape via its
|
|
3531
|
+
* `dirtyWorld` filter — effectively a no-op main pass.
|
|
3532
|
+
*/
|
|
3533
|
+
computeDirtyWorld() {
|
|
3534
|
+
const prev = this.lastRenderedScene;
|
|
3535
|
+
const next = this._scene;
|
|
3536
|
+
if (!prev)
|
|
3537
|
+
return null;
|
|
3538
|
+
// Skip dirty-rect optimization until the canvas has been sized at
|
|
3539
|
+
// least once — the first paint to a 0×0 viewport doesn't actually
|
|
3540
|
+
// hit pixels, so we have to force a full repaint as soon as the
|
|
3541
|
+
// host's ResizeObserver fires (even when the diff finds zero
|
|
3542
|
+
// changed shapes, e.g. when only viewport.size changed but the
|
|
3543
|
+
// viewport ref happened to equal — which can't happen, but defence
|
|
3544
|
+
// in depth).
|
|
3545
|
+
if (prev.viewport.size.width <= 0 ||
|
|
3546
|
+
prev.viewport.size.height <= 0 ||
|
|
3547
|
+
next.viewport.size.width <= 0 ||
|
|
3548
|
+
next.viewport.size.height <= 0) {
|
|
3549
|
+
return null;
|
|
3550
|
+
}
|
|
3551
|
+
// Active gesture (drag / resize / brush / placement) → full
|
|
3552
|
+
// repaint. Dirty-rect optimisation skipped on purpose:
|
|
3553
|
+
//
|
|
3554
|
+
// • transitive-overlap expansion catches the common case but
|
|
3555
|
+
// misses corner cases (edges attached to moving shapes,
|
|
3556
|
+
// group descendants that aren't all siblings, hovered
|
|
3557
|
+
// ports that decorate a different layer);
|
|
3558
|
+
// • during a drag we already repaint the largest dirty area
|
|
3559
|
+
// in the scene (the moving shape's swept bbox), so the
|
|
3560
|
+
// dirty optimisation buys almost no perf — the only thing
|
|
3561
|
+
// it does is occasionally drop a frame for a sibling that
|
|
3562
|
+
// should have been redrawn underneath / above the mover;
|
|
3563
|
+
// • once the gesture commits, the next render falls back to
|
|
3564
|
+
// normal dirty-rect behaviour again.
|
|
3565
|
+
//
|
|
3566
|
+
// Net: trade ~1 frame's worth of work during the drag for
|
|
3567
|
+
// guaranteed correct z-order.
|
|
3568
|
+
if (this.gestureTx !== null ||
|
|
3569
|
+
this.dragElementId !== null ||
|
|
3570
|
+
this.drawingPreview !== null ||
|
|
3571
|
+
this.edgePreview !== null ||
|
|
3572
|
+
this.brushStroke !== null ||
|
|
3573
|
+
this.lassoPreview !== null) {
|
|
3574
|
+
return null;
|
|
3575
|
+
}
|
|
3576
|
+
// Anything that affects the global render — viewport (pan / zoom /
|
|
3577
|
+
// size) or layer ordering / visibility — forces a full clear.
|
|
3578
|
+
if (prev.viewport !== next.viewport)
|
|
3579
|
+
return null;
|
|
3580
|
+
if (prev.layers !== next.layers)
|
|
3581
|
+
return null;
|
|
3582
|
+
// Isolation transition (enter / exit a group) re-dims a wide swath
|
|
3583
|
+
// of shapes without touching the scene reference, so force a full
|
|
3584
|
+
// repaint when the entered-group identity changes between frames.
|
|
3585
|
+
if (this.lastRenderedEnteredGroup !== this._enteredGroup)
|
|
3586
|
+
return null;
|
|
3587
|
+
// Scene ref unchanged → nothing changed on main canvas → skip the
|
|
3588
|
+
// whole pass via an empty off-screen rect that the dirty filter
|
|
3589
|
+
// culls every shape against.
|
|
3590
|
+
if (prev === next) {
|
|
3591
|
+
return { x: -1e9, y: -1e9, width: 0, height: 0 };
|
|
3592
|
+
}
|
|
3593
|
+
let acc = null;
|
|
3594
|
+
const add = (b) => {
|
|
3595
|
+
acc = acc ? B.union(acc, b) : b;
|
|
3596
|
+
};
|
|
3597
|
+
// Track shapes that changed (added / removed / mutated). Links
|
|
3598
|
+
// attached to any of these have stale rendered paths even when
|
|
3599
|
+
// the edge object itself is reference-equal — the path resolves
|
|
3600
|
+
// through the shape's new position, but the old path stays on
|
|
3601
|
+
// screen as a "ghost" trail unless we explicitly invalidate it.
|
|
3602
|
+
const changedElementIds = new Set();
|
|
3603
|
+
for (const [id, shape] of next.elements) {
|
|
3604
|
+
const old = prev.elements.get(id);
|
|
3605
|
+
if (old === shape)
|
|
3606
|
+
continue;
|
|
3607
|
+
changedElementIds.add(id);
|
|
3608
|
+
// Render bounds (not geometric) so overpaint — a frame's header
|
|
3609
|
+
// strip, confetti particles — is cleared too, no ghost trail.
|
|
3610
|
+
const afterBounds = getElementRenderBounds(shape);
|
|
3611
|
+
const beforeBounds = old ? getElementRenderBounds(old) : null;
|
|
3612
|
+
add(afterBounds);
|
|
3613
|
+
if (beforeBounds)
|
|
3614
|
+
add(beforeBounds);
|
|
3615
|
+
// Stash for the tile-cache path — covers add + move via
|
|
3616
|
+
// before/after pair; pure mutation re-uses the single
|
|
3617
|
+
// afterBounds rect.
|
|
3618
|
+
if (this.tileComposeFn !== null) {
|
|
3619
|
+
this.tileDirtyElements.set(id, { before: beforeBounds, after: afterBounds });
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
for (const [id, shape] of prev.elements) {
|
|
3623
|
+
if (!next.elements.has(id)) {
|
|
3624
|
+
changedElementIds.add(id);
|
|
3625
|
+
// Render bounds so a removed frame/confetti clears its overpaint.
|
|
3626
|
+
const beforeBounds = getElementRenderBounds(shape);
|
|
3627
|
+
add(beforeBounds);
|
|
3628
|
+
if (this.tileComposeFn !== null) {
|
|
3629
|
+
this.tileDirtyElements.set(id, { before: beforeBounds, after: null });
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
const linkTouchesChangedElement = (edge) => {
|
|
3634
|
+
for (const ep of [edge.from, edge.to]) {
|
|
3635
|
+
if (ep.kind !== "point") {
|
|
3636
|
+
if (changedElementIds.has(ep.elementId))
|
|
3637
|
+
return true;
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
return false;
|
|
3641
|
+
};
|
|
3642
|
+
for (const [id, edge] of next.links) {
|
|
3643
|
+
const old = prev.links.get(id);
|
|
3644
|
+
// Refresh edge dirty-rect when: edge object changed, OR an
|
|
3645
|
+
// endpoint references a shape that moved this frame (path is
|
|
3646
|
+
// re-resolved every render but the old screen pixels persist).
|
|
3647
|
+
if (old === edge && !linkTouchesChangedElement(edge))
|
|
3648
|
+
continue;
|
|
3649
|
+
const b = computeLinkWorldBounds(next, edge);
|
|
3650
|
+
if (b)
|
|
3651
|
+
add(b);
|
|
3652
|
+
const oldLink = old ?? edge; // prev scene resolves with prev shapes for ghost-clear
|
|
3653
|
+
const ob = computeLinkWorldBounds(prev, oldLink);
|
|
3654
|
+
if (ob)
|
|
3655
|
+
add(ob);
|
|
3656
|
+
}
|
|
3657
|
+
for (const [id, edge] of prev.links) {
|
|
3658
|
+
if (!next.links.has(id)) {
|
|
3659
|
+
const b = computeLinkWorldBounds(prev, edge);
|
|
3660
|
+
if (b)
|
|
3661
|
+
add(b);
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- `acc` is mutated via the `add` closure; TS flow analysis can't see it and narrows to null
|
|
3665
|
+
if (acc === null)
|
|
3666
|
+
return { x: -1e9, y: -1e9, width: 0, height: 0 };
|
|
3667
|
+
// Transitive expansion: any shape whose bounds intersect the
|
|
3668
|
+
// current dirty rect must be repainted, AND its bounds added
|
|
3669
|
+
// to the dirty rect so any shape ABOVE it that overlaps gets
|
|
3670
|
+
// included too. Repeat until the set stabilises.
|
|
3671
|
+
//
|
|
3672
|
+
// Without this, dragging A through a B/C stack produces
|
|
3673
|
+
// visual jitter: B intersects the dirty rect and gets
|
|
3674
|
+
// repainted, but C — sitting above B and partially overlapping
|
|
3675
|
+
// it — doesn't intersect the original dirty, so B re-emerges
|
|
3676
|
+
// on top of where C should still be drawn. Z-order is correct
|
|
3677
|
+
// in `getElementsInLayer`; the issue is missed shapes, not
|
|
3678
|
+
// wrong order.
|
|
3679
|
+
const visited = new Set();
|
|
3680
|
+
let expanded = acc;
|
|
3681
|
+
let grew = true;
|
|
3682
|
+
while (grew) {
|
|
3683
|
+
grew = false;
|
|
3684
|
+
for (const shape of next.elements.values()) {
|
|
3685
|
+
if (visited.has(shape.id))
|
|
3686
|
+
continue;
|
|
3687
|
+
const bb = getElementWorldBounds(shape);
|
|
3688
|
+
if (!B.intersects(bb, expanded))
|
|
3689
|
+
continue;
|
|
3690
|
+
visited.add(shape.id);
|
|
3691
|
+
const merged = B.union(expanded, bb);
|
|
3692
|
+
if (merged.x !== expanded.x ||
|
|
3693
|
+
merged.y !== expanded.y ||
|
|
3694
|
+
merged.width !== expanded.width ||
|
|
3695
|
+
merged.height !== expanded.height) {
|
|
3696
|
+
expanded = merged;
|
|
3697
|
+
grew = true;
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
// Inflate by a couple pixels to cover anti-aliased stroke fuzz
|
|
3702
|
+
// around the geometry edges.
|
|
3703
|
+
return B.expand(expanded, 4);
|
|
3704
|
+
}
|
|
3705
|
+
// Bodies moved to `./editor/viewport-helpers.ts`.
|
|
3706
|
+
combinedSelectionBounds() {
|
|
3707
|
+
let acc = combinedSelectionBoundsPure(this._scene, this._selection);
|
|
3708
|
+
// Selected links join the selection box (standard parity) — union in
|
|
3709
|
+
// each link's drawn-path AABB so the rectangle frames connectors too.
|
|
3710
|
+
for (const id of this._selectedLinks) {
|
|
3711
|
+
const edge = getLink(this._scene, id);
|
|
3712
|
+
if (!edge)
|
|
3713
|
+
continue;
|
|
3714
|
+
const path = getLinkPath(this._scene, edge);
|
|
3715
|
+
if (!path || path.length === 0)
|
|
3716
|
+
continue;
|
|
3717
|
+
let minX = Infinity;
|
|
3718
|
+
let minY = Infinity;
|
|
3719
|
+
let maxX = -Infinity;
|
|
3720
|
+
let maxY = -Infinity;
|
|
3721
|
+
for (const p of path) {
|
|
3722
|
+
if (p.x < minX)
|
|
3723
|
+
minX = p.x;
|
|
3724
|
+
if (p.y < minY)
|
|
3725
|
+
minY = p.y;
|
|
3726
|
+
if (p.x > maxX)
|
|
3727
|
+
maxX = p.x;
|
|
3728
|
+
if (p.y > maxY)
|
|
3729
|
+
maxY = p.y;
|
|
3730
|
+
}
|
|
3731
|
+
const b = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
3732
|
+
acc = acc ? B.union(acc, b) : b;
|
|
3733
|
+
}
|
|
3734
|
+
return acc;
|
|
3735
|
+
}
|
|
3736
|
+
groupChildrenUnion(groupId) {
|
|
3737
|
+
return groupChildrenUnionPure(this._scene, groupId);
|
|
3738
|
+
}
|
|
3739
|
+
/**
|
|
3740
|
+
* True when the current selection should be treated as aspect-
|
|
3741
|
+
* locked for group-handle resize. Currently: a single `group`-typed
|
|
3742
|
+
* shape selected. Multi-selection of free shapes keeps the default
|
|
3743
|
+
* 8-handle / free-aspect behaviour (matches user expectation:
|
|
3744
|
+
* grouping is the explicit "lock the ratio" gesture).
|
|
3745
|
+
*/
|
|
3746
|
+
selectionIsAspectLocked() {
|
|
3747
|
+
if (this._selection.size === 0)
|
|
3748
|
+
return false;
|
|
3749
|
+
if (this._selection.size === 1) {
|
|
3750
|
+
const [only] = [...this._selection];
|
|
3751
|
+
if (!only)
|
|
3752
|
+
return false;
|
|
3753
|
+
// A single group (grouping IS the explicit "lock ratio" gesture)
|
|
3754
|
+
// or a single image (images may only be scaled, never distorted).
|
|
3755
|
+
const type = getElement(this._scene, only)?.type;
|
|
3756
|
+
return type === "group" || type === "image";
|
|
3757
|
+
}
|
|
3758
|
+
// Multi-selection: lock when every selected shape is an image — they
|
|
3759
|
+
// must never be stretched out of ratio, only scaled together.
|
|
3760
|
+
for (const id of this._selection) {
|
|
3761
|
+
if (getElement(this._scene, id)?.type !== "image")
|
|
3762
|
+
return false;
|
|
3763
|
+
}
|
|
3764
|
+
return true;
|
|
3765
|
+
}
|
|
3766
|
+
// Pure body in `./editor/applies/resize.ts`.
|
|
3767
|
+
applyGroupResize(handle, delta, originalBounds) {
|
|
3768
|
+
if (!this.groupResizeOrigin)
|
|
3769
|
+
return;
|
|
3770
|
+
const d = this.snapActive()
|
|
3771
|
+
? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
|
|
3772
|
+
: delta;
|
|
3773
|
+
const result = computeGroupResizePatches(this._scene, this.groupResizeOrigin, handle, d, originalBounds, this.selectionIsAspectLocked());
|
|
3774
|
+
this._scene = result.scene;
|
|
3775
|
+
for (const patch of result.patches)
|
|
3776
|
+
this.recordGesturePatch(patch);
|
|
3777
|
+
this.notify();
|
|
3778
|
+
}
|
|
3779
|
+
applyResize(id, handle, delta, originalBounds) {
|
|
3780
|
+
const shape = getElement(this._scene, id);
|
|
3781
|
+
const d = this.snapActive()
|
|
3782
|
+
? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
|
|
3783
|
+
: delta;
|
|
3784
|
+
// Text: aspect-locked font scaling. Snapshot the pristine shape on
|
|
3785
|
+
// the gesture's first tick so the scale base never compounds.
|
|
3786
|
+
if (shape?.type === "text") {
|
|
3787
|
+
if (this._resizeOriginElement?.id !== id) {
|
|
3788
|
+
this._resizeOriginElement = shape;
|
|
3789
|
+
}
|
|
3790
|
+
const result = computeTextResize(this._scene, this._resizeOriginElement, handle, d, originalBounds);
|
|
3791
|
+
if (!result)
|
|
3792
|
+
return;
|
|
3793
|
+
this._scene = result.scene;
|
|
3794
|
+
this.recordGesturePatch(result.patch);
|
|
3795
|
+
this.notify();
|
|
3796
|
+
return;
|
|
3797
|
+
}
|
|
3798
|
+
const result = computeElementResize(this._scene, id, handle, d, originalBounds, (s, raw, h) => this.clampContainerToChildren(s, raw, h));
|
|
3799
|
+
if (!result)
|
|
3800
|
+
return;
|
|
3801
|
+
this._scene = result.scene;
|
|
3802
|
+
this.recordGesturePatch(result.patch);
|
|
3803
|
+
this.notify();
|
|
3804
|
+
}
|
|
3805
|
+
// Pure body in `./editor/applies/create.ts`.
|
|
3806
|
+
applyCreate(kind, bounds) {
|
|
3807
|
+
const id = newElementId(++this.nextId);
|
|
3808
|
+
const b = this.snapActive() ? snapCreateBounds(bounds, this.snapSpacing()) : bounds;
|
|
3809
|
+
const result = computeCreateElement(this._scene, kind, b, id, this._activeLayerId, () => this.nextFrameName());
|
|
3810
|
+
this._scene = result.scene;
|
|
3811
|
+
this._selection = Selection.single(id);
|
|
3812
|
+
// CREATE is a single-shot operation, not part of a multi-tick gesture.
|
|
3813
|
+
this._history.push(result.patch);
|
|
3814
|
+
// Frame-specific: scoop up every shape whose centre lies inside
|
|
3815
|
+
// the new frame's bounds and tag them with `frameId`.
|
|
3816
|
+
if (kind === "frame") {
|
|
3817
|
+
this.assignFrameMembers(id, b);
|
|
3818
|
+
}
|
|
3819
|
+
this.maybeRevertModeAfterCreate();
|
|
3820
|
+
this.notify();
|
|
3821
|
+
}
|
|
3822
|
+
/** Generate the next "Frame N" name based on existing frames. */
|
|
3823
|
+
nextFrameName() {
|
|
3824
|
+
return nextFrameNameHelper(this._scene);
|
|
3825
|
+
}
|
|
3826
|
+
/**
|
|
3827
|
+
* Assign frameId to every shape (except the frame itself) whose
|
|
3828
|
+
* centre falls inside the frame's world bounds. Runs as a single
|
|
3829
|
+
* undo step in the same gesture transaction as the create.
|
|
3830
|
+
*/
|
|
3831
|
+
assignFrameMembers(frameId, frameBounds) {
|
|
3832
|
+
this._scene = assignFrameMembersHelper(this._scene, this._history, frameId, frameBounds);
|
|
3833
|
+
}
|
|
3834
|
+
/**
|
|
3835
|
+
* Re-evaluate frame membership at the end of a move / resize gesture —
|
|
3836
|
+
* elements dropped inside a frame join it, those dragged out are
|
|
3837
|
+
* released (standard "membership on drop"). Runs inside the gesture
|
|
3838
|
+
* transaction (called from pointer-up before `commitGesture`) so the
|
|
3839
|
+
* frameId changes undo together with the drag. No-op when nothing
|
|
3840
|
+
* changed.
|
|
3841
|
+
*/
|
|
3842
|
+
reconcileFrameMembership() {
|
|
3843
|
+
this._scene = reconcileFrameMembershipHelper(this._scene, this._history);
|
|
3844
|
+
}
|
|
3845
|
+
// Pure body in `./editor/applies/create.ts`. Endpoint snapping
|
|
3846
|
+
// stays here because it needs the snap engine.
|
|
3847
|
+
applyCreateLink(emit) {
|
|
3848
|
+
const from = this.snapLinkEndpoint(emit.fromElement, emit.fromPoint);
|
|
3849
|
+
const to = this.snapLinkEndpoint(emit.toElement, emit.toPoint);
|
|
3850
|
+
const id = newLinkId(++this.nextId);
|
|
3851
|
+
const result = computeCreateLink(this._scene, from, to, id, this._activeLayerId);
|
|
3852
|
+
this._scene = result.scene;
|
|
3853
|
+
this._history.push(result.patch);
|
|
3854
|
+
this.edgePreview = null;
|
|
3855
|
+
this.maybeRevertModeAfterCreate();
|
|
3856
|
+
// Dropped on empty canvas (free `point` end) → offer a shape-picker at
|
|
3857
|
+
// the drop point (standard). The free-ended link stays; picking re-points
|
|
3858
|
+
// it, dismissing keeps it. Only the `to` end is user-dragged here.
|
|
3859
|
+
if (to.kind === "point") {
|
|
3860
|
+
this.pendingLinkDropMenu = { linkId: id, side: "to", world: to.position };
|
|
3861
|
+
}
|
|
3862
|
+
this.notify();
|
|
3863
|
+
}
|
|
3864
|
+
/** Pending shape-picker after a link was dropped on empty canvas. */
|
|
3865
|
+
get linkDropMenu() {
|
|
3866
|
+
return this.pendingLinkDropMenu;
|
|
3867
|
+
}
|
|
3868
|
+
/**
|
|
3869
|
+
* Resolve a pending link-drop shape-picker by creating an element from
|
|
3870
|
+
* `factory` centred at the drop point and re-pointing the dropped link
|
|
3871
|
+
* end to float against it. Element + re-point land in one undo step; the
|
|
3872
|
+
* new element becomes the selection. No-op when no menu is pending.
|
|
3873
|
+
*/
|
|
3874
|
+
placeShapeAtLinkDrop(factory) {
|
|
3875
|
+
const pending = this.pendingLinkDropMenu;
|
|
3876
|
+
if (!pending)
|
|
3877
|
+
return;
|
|
3878
|
+
const link = getLink(this._scene, pending.linkId);
|
|
3879
|
+
if (!link) {
|
|
3880
|
+
this.pendingLinkDropMenu = null;
|
|
3881
|
+
this.notify();
|
|
3882
|
+
return;
|
|
3883
|
+
}
|
|
3884
|
+
const newId = newElementId(++this.nextId);
|
|
3885
|
+
const order = orderForTop([...this._scene.elements.values()]
|
|
3886
|
+
.filter((sh) => sh.layerId === this._activeLayerId)
|
|
3887
|
+
.map((sh) => sh.order));
|
|
3888
|
+
const built = factory({
|
|
3889
|
+
id: newId,
|
|
3890
|
+
layerId: this._activeLayerId,
|
|
3891
|
+
position: pending.world,
|
|
3892
|
+
order,
|
|
3893
|
+
});
|
|
3894
|
+
// Centre the element on the drop point regardless of how the factory
|
|
3895
|
+
// anchored it at `position`.
|
|
3896
|
+
const wb = getElementWorldBounds(built);
|
|
3897
|
+
const shape = {
|
|
3898
|
+
...built,
|
|
3899
|
+
position: {
|
|
3900
|
+
x: built.position.x + (pending.world.x - (wb.x + wb.width / 2)),
|
|
3901
|
+
y: built.position.y + (pending.world.y - (wb.y + wb.height / 2)),
|
|
3902
|
+
},
|
|
3903
|
+
};
|
|
3904
|
+
const tx = this._history.transaction();
|
|
3905
|
+
const added = addElement(this._scene, shape);
|
|
3906
|
+
this._scene = added.scene;
|
|
3907
|
+
tx.add(added.patch);
|
|
3908
|
+
const upd = updateLink(this._scene, pending.linkId, (e) => ({
|
|
3909
|
+
...e,
|
|
3910
|
+
[pending.side]: { kind: "floating", elementId: newId },
|
|
3911
|
+
}));
|
|
3912
|
+
this._scene = upd.scene;
|
|
3913
|
+
tx.add(upd.patch);
|
|
3914
|
+
tx.commit();
|
|
3915
|
+
this.pendingLinkDropMenu = null;
|
|
3916
|
+
this._selection = Selection.single(newId);
|
|
3917
|
+
this._selectedLinks = LinkSelection.EMPTY;
|
|
3918
|
+
this.notify();
|
|
3919
|
+
}
|
|
3920
|
+
/** Dismiss the link-drop shape-picker, leaving the free-ended link. */
|
|
3921
|
+
dismissLinkDropMenu() {
|
|
3922
|
+
if (!this.pendingLinkDropMenu)
|
|
3923
|
+
return;
|
|
3924
|
+
this.pendingLinkDropMenu = null;
|
|
3925
|
+
this.notify();
|
|
3926
|
+
}
|
|
3927
|
+
/**
|
|
3928
|
+
* standard "click a link-start dot" gesture: spawn a new element in that
|
|
3929
|
+
* dot's outward direction and link the source to it. The clone copies
|
|
3930
|
+
* the source's type / style / size but NOT its text (a fresh blank of
|
|
3931
|
+
* the same kind). Direction is source → new; the new element becomes the
|
|
3932
|
+
* selection. Element + link land in one undo step.
|
|
3933
|
+
*/
|
|
3934
|
+
createLinkedElementFromAnchor(fromElement, anchorName) {
|
|
3935
|
+
const src = getElement(this._scene, fromElement);
|
|
3936
|
+
if (!src)
|
|
3937
|
+
return;
|
|
3938
|
+
const anchor = { kind: "named", name: anchorName };
|
|
3939
|
+
const normal = getAnchorOutwardNormal(src, anchor);
|
|
3940
|
+
const bounds = getElementWorldBounds(src);
|
|
3941
|
+
const srcCenter = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
|
|
3942
|
+
// Same-size clone → centre-to-centre distance = the source's extent
|
|
3943
|
+
// along the normal + the gap, leaving exactly
|
|
3944
|
+
// ANCHOR_CLICK_NEW_ELEMENT_GAP between the facing edges. `extentAlong`
|
|
3945
|
+
// resolves to width for a horizontal normal, height for a vertical one
|
|
3946
|
+
// (link-start dots are the four edge midpoints).
|
|
3947
|
+
const extentAlong = Math.abs(normal.x) * bounds.width + Math.abs(normal.y) * bounds.height;
|
|
3948
|
+
const dist = extentAlong + ANCHOR_CLICK_NEW_ELEMENT_GAP;
|
|
3949
|
+
const delta = { x: normal.x * dist, y: normal.y * dist };
|
|
3950
|
+
const newId = newElementId(++this.nextId);
|
|
3951
|
+
const order = orderForTop([...this._scene.elements.values()]
|
|
3952
|
+
.filter((sh) => sh.layerId === src.layerId)
|
|
3953
|
+
.map((sh) => sh.order));
|
|
3954
|
+
let clone = {
|
|
3955
|
+
...src,
|
|
3956
|
+
id: newId,
|
|
3957
|
+
position: { x: src.position.x + delta.x, y: src.position.y + delta.y },
|
|
3958
|
+
order,
|
|
3959
|
+
};
|
|
3960
|
+
// Blank user text — the new element is a fresh same-kind shape, not a
|
|
3961
|
+
// content copy (standard). Only `text` (TextElement) and `name`
|
|
3962
|
+
// (FrameElement) carry user-entered text. Cast through `Element` because
|
|
3963
|
+
// `exactOptionalPropertyTypes` rejects the bare object literal against
|
|
3964
|
+
// the union (TS2375), even though the narrowed branch is sound.
|
|
3965
|
+
if (clone.type === "text")
|
|
3966
|
+
clone = { ...clone, text: "" };
|
|
3967
|
+
else if (clone.type === "frame")
|
|
3968
|
+
clone = { ...clone, name: "" };
|
|
3969
|
+
const tx = this._history.transaction();
|
|
3970
|
+
const added = addElement(this._scene, clone);
|
|
3971
|
+
this._scene = added.scene;
|
|
3972
|
+
tx.add(added.patch);
|
|
3973
|
+
const linkId = newLinkId(++this.nextId);
|
|
3974
|
+
const placed = req(getElement(this._scene, newId));
|
|
3975
|
+
const { ref: toRef } = findNearestAnchor(placed, srcCenter, snapExcludedAnchors(placed));
|
|
3976
|
+
const linkResult = computeCreateLink(this._scene, { kind: "anchor", elementId: fromElement, anchor }, { kind: "anchor", elementId: newId, anchor: toRef }, linkId, this._activeLayerId);
|
|
3977
|
+
this._scene = linkResult.scene;
|
|
3978
|
+
tx.add(linkResult.patch);
|
|
3979
|
+
tx.commit();
|
|
3980
|
+
this._selection = Selection.single(newId);
|
|
3981
|
+
if (this._selectedLinks.size > 0)
|
|
3982
|
+
this._selectedLinks = LinkSelection.EMPTY;
|
|
3983
|
+
this.notify();
|
|
3984
|
+
}
|
|
3985
|
+
/**
|
|
3986
|
+
* Ghost geometry for what clicking a start dot would create (standard hover
|
|
3987
|
+
* preview): the would-be new element's world bounds + the connector path
|
|
3988
|
+
* from the dot to it. Pure — no mutation. Mirrors the placement in
|
|
3989
|
+
* `createLinkedElementFromAnchor`.
|
|
3990
|
+
*/
|
|
3991
|
+
previewClickCreate(fromElement, anchorName) {
|
|
3992
|
+
const src = getElement(this._scene, fromElement);
|
|
3993
|
+
if (!src)
|
|
3994
|
+
return null;
|
|
3995
|
+
const anchor = { kind: "named", name: anchorName };
|
|
3996
|
+
const normal = getAnchorOutwardNormal(src, anchor);
|
|
3997
|
+
const b = getElementWorldBounds(src);
|
|
3998
|
+
const extentAlong = Math.abs(normal.x) * b.width + Math.abs(normal.y) * b.height;
|
|
3999
|
+
const dist = extentAlong + ANCHOR_CLICK_NEW_ELEMENT_GAP;
|
|
4000
|
+
const delta = { x: normal.x * dist, y: normal.y * dist };
|
|
4001
|
+
const bounds = { x: b.x + delta.x, y: b.y + delta.y, width: b.width, height: b.height };
|
|
4002
|
+
const fromWorld = getAnchorWorld(src, anchor);
|
|
4003
|
+
// Facing edge of the ghost (toward the source) = its centre pulled back
|
|
4004
|
+
// along the normal by half its extent.
|
|
4005
|
+
const ghostCx = bounds.x + bounds.width / 2;
|
|
4006
|
+
const ghostCy = bounds.y + bounds.height / 2;
|
|
4007
|
+
const nearEdge = {
|
|
4008
|
+
x: ghostCx - normal.x * (extentAlong / 2),
|
|
4009
|
+
y: ghostCy - normal.y * (extentAlong / 2),
|
|
4010
|
+
};
|
|
4011
|
+
// The would-be element itself — a same-kind clone of the source shifted
|
|
4012
|
+
// to the ghost bounds, with blank user text (mirrors
|
|
4013
|
+
// `createLinkedElementFromAnchor`). The overlay renders THIS through the
|
|
4014
|
+
// real renderer so the ghost looks like the actual shape (an ellipse
|
|
4015
|
+
// ghosts as an ellipse), not a bounding rect. Throwaway id — never enters
|
|
4016
|
+
// the real scene.
|
|
4017
|
+
let element = {
|
|
4018
|
+
...src,
|
|
4019
|
+
id: PREVIEW_GHOST_ELEMENT_ID,
|
|
4020
|
+
position: { x: src.position.x + delta.x, y: src.position.y + delta.y },
|
|
4021
|
+
};
|
|
4022
|
+
if (element.type === "text")
|
|
4023
|
+
element = { ...element, text: "" };
|
|
4024
|
+
else if (element.type === "frame")
|
|
4025
|
+
element = { ...element, name: "" };
|
|
4026
|
+
// Build a throwaway scene holding the ghost element + the would-be link so
|
|
4027
|
+
// the connector can be drawn through the REAL link renderer (same routing,
|
|
4028
|
+
// arrowhead and style it'll have once created) — faded — instead of a
|
|
4029
|
+
// dashed preview line. Mirrors the link build in
|
|
4030
|
+
// `createLinkedElementFromAnchor` exactly. The `path` field stays for
|
|
4031
|
+
// callers that just want the straight from→to segment.
|
|
4032
|
+
const srcCenter = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
|
|
4033
|
+
const withGhost = addElement(this._scene, element).scene;
|
|
4034
|
+
const placed = req(getElement(withGhost, PREVIEW_GHOST_ELEMENT_ID));
|
|
4035
|
+
const { ref: toRef } = findNearestAnchor(placed, srcCenter, snapExcludedAnchors(placed));
|
|
4036
|
+
const linkResult = computeCreateLink(withGhost, { kind: "anchor", elementId: fromElement, anchor }, { kind: "anchor", elementId: PREVIEW_GHOST_ELEMENT_ID, anchor: toRef }, PREVIEW_GHOST_LINK_ID, this._activeLayerId);
|
|
4037
|
+
let ghostScene = linkResult.scene;
|
|
4038
|
+
const edge = req(getLink(ghostScene, PREVIEW_GHOST_LINK_ID));
|
|
4039
|
+
if ((edge.routing ?? "straight") === "orthogonal") {
|
|
4040
|
+
const routedPoints = routeElbowLink(ghostScene, edge);
|
|
4041
|
+
ghostScene = updateLink(ghostScene, PREVIEW_GHOST_LINK_ID, (e) => ({
|
|
4042
|
+
...e,
|
|
4043
|
+
routedPoints,
|
|
4044
|
+
})).scene;
|
|
4045
|
+
}
|
|
4046
|
+
// Render only the ghost link (the shapes stay for endpoint resolution).
|
|
4047
|
+
ghostScene = {
|
|
4048
|
+
...ghostScene,
|
|
4049
|
+
links: new Map([[PREVIEW_GHOST_LINK_ID, req(getLink(ghostScene, PREVIEW_GHOST_LINK_ID))]]),
|
|
4050
|
+
};
|
|
4051
|
+
return {
|
|
4052
|
+
bounds,
|
|
4053
|
+
path: [fromWorld, nearEdge],
|
|
4054
|
+
element,
|
|
4055
|
+
ghostScene,
|
|
4056
|
+
ghostLinkId: PREVIEW_GHOST_LINK_ID,
|
|
4057
|
+
};
|
|
4058
|
+
}
|
|
4059
|
+
/**
|
|
4060
|
+
* Build an `LinkEndpoint` for a draw-edge / re-bind gesture. Runs the
|
|
4061
|
+
* scene's snap engine for the probe point, prefers anchor snap when
|
|
4062
|
+
* close enough, falls back to outline snap (so the user can attach
|
|
4063
|
+
* "anywhere on the right edge"), then `point` for the free-floating
|
|
4064
|
+
* case.
|
|
4065
|
+
*
|
|
4066
|
+
* `pressTargetElement` is the shape the gesture originated from or
|
|
4067
|
+
* landed on (used as a strong hint — we don't snap onto unrelated
|
|
4068
|
+
* shapes when the user clearly aimed for this one).
|
|
4069
|
+
*/
|
|
4070
|
+
snapLinkEndpoint(pressTargetElement, worldPoint) {
|
|
4071
|
+
const result = this.snapEngine.snap({
|
|
4072
|
+
scene: this._scene,
|
|
4073
|
+
probe: worldPoint,
|
|
4074
|
+
threshold: this.snapThreshold,
|
|
4075
|
+
gesture: "draw-edge",
|
|
4076
|
+
});
|
|
4077
|
+
// Attach contract: dropping on a port dot → *fixed* anchor; dropping
|
|
4078
|
+
// near an EDGE (not a dot) → *fixed* outline point (a ratio along the
|
|
4079
|
+
// perimeter — survives move/resize); dropping on the body interior (no
|
|
4080
|
+
// snap to a dot or edge) → *floating* against the whole shape, so the
|
|
4081
|
+
// connection re-aims at the partner as either shape moves; dropping on
|
|
4082
|
+
// empty canvas → a free point.
|
|
4083
|
+
//
|
|
4084
|
+
// Pick a candidate ON the pressed shape if there is one, otherwise the
|
|
4085
|
+
// nearest overall. The "nearest overall" branch matters because the
|
|
4086
|
+
// attach DOTS are drawn OUTSIDE the body — a release on a dot finds no
|
|
4087
|
+
// element under it (hit-test = empty), so `pressTargetElement` is null,
|
|
4088
|
+
// yet the snap engine still reports the dot's anchor within threshold.
|
|
4089
|
+
// Binding it makes the endpoint enter PERPENDICULAR to that edge instead
|
|
4090
|
+
// of staying a free point that aims at the partner (jumping between the
|
|
4091
|
+
// four sides).
|
|
4092
|
+
const pick = (kind) => {
|
|
4093
|
+
if (pressTargetElement !== null) {
|
|
4094
|
+
const onTarget = result.all.find((c) => c.kind === kind && c.metadata?.elementId === pressTargetElement);
|
|
4095
|
+
if (onTarget)
|
|
4096
|
+
return onTarget;
|
|
4097
|
+
}
|
|
4098
|
+
return result.all.find((c) => c.kind === kind);
|
|
4099
|
+
};
|
|
4100
|
+
const boundFrom = (cand, want) => {
|
|
4101
|
+
if (!cand)
|
|
4102
|
+
return null;
|
|
4103
|
+
const elId = cand.metadata?.elementId;
|
|
4104
|
+
if (elId === undefined)
|
|
4105
|
+
return null;
|
|
4106
|
+
const shp = getElement(this._scene, elId);
|
|
4107
|
+
if (!shp)
|
|
4108
|
+
return null;
|
|
4109
|
+
const ep = endpointFromSnap(elId, cand, shp);
|
|
4110
|
+
return ep.kind === want ? ep : null;
|
|
4111
|
+
};
|
|
4112
|
+
const anchorEp = boundFrom(pick("anchor"), "anchor");
|
|
4113
|
+
if (anchorEp)
|
|
4114
|
+
return anchorEp;
|
|
4115
|
+
const outlineEp = boundFrom(pick("outline"), "outline");
|
|
4116
|
+
if (outlineEp)
|
|
4117
|
+
return outlineEp;
|
|
4118
|
+
// No dot/edge snap. Over a shape body → floating; else a free point.
|
|
4119
|
+
if (pressTargetElement !== null && getElement(this._scene, pressTargetElement)) {
|
|
4120
|
+
return { kind: "floating", elementId: pressTargetElement };
|
|
4121
|
+
}
|
|
4122
|
+
return { kind: "point", position: worldPoint };
|
|
4123
|
+
}
|
|
4124
|
+
// Pure body in `./editor/applies/selection.ts`. The wrappers
|
|
4125
|
+
// here own the side effects (`_selectedLink` clearing, notify).
|
|
4126
|
+
applySelectByBounds(bounds, mode) {
|
|
4127
|
+
const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode);
|
|
4128
|
+
const nextLinks = selectLinksByBoundsLivePure(this._scene, mode === "add" ? this._selectedLinks : LinkSelection.EMPTY, (id) => this.isLayerLocked(id), bounds, mode);
|
|
4129
|
+
const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
|
|
4130
|
+
this._selectedLinks = nextLinks;
|
|
4131
|
+
if (Selection.equals(next, this._selection) && !linksChanged) {
|
|
4132
|
+
this.notify();
|
|
4133
|
+
return;
|
|
4134
|
+
}
|
|
4135
|
+
this._selection = next;
|
|
4136
|
+
this.notify();
|
|
4137
|
+
}
|
|
4138
|
+
applyLassoLiveSelection(bounds, mode) {
|
|
4139
|
+
const base = this.lassoBaseSelection ?? Selection.EMPTY;
|
|
4140
|
+
const next = selectByBoundsLivePure(this._scene, base, (id) => this.isLayerLocked(id), bounds, mode);
|
|
4141
|
+
const linkBase = this.lassoBaseLinks ?? LinkSelection.EMPTY;
|
|
4142
|
+
const nextLinks = selectLinksByBoundsLivePure(this._scene, linkBase, (id) => this.isLayerLocked(id), bounds, mode);
|
|
4143
|
+
const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
|
|
4144
|
+
if (Selection.equals(next, this._selection) && !linksChanged)
|
|
4145
|
+
return;
|
|
4146
|
+
this._selectedLinks = nextLinks;
|
|
4147
|
+
this._selection = next;
|
|
4148
|
+
}
|
|
4149
|
+
// Pure body in `./editor/applies/edge.ts`. The wrapper here
|
|
4150
|
+
// owns the side effects (history push, drag-state clearing,
|
|
4151
|
+
// notify).
|
|
4152
|
+
/**
|
|
4153
|
+
* Live endpoint-rebind move: re-point the dragged end to the cursor in the
|
|
4154
|
+
* scene (a free `point` endpoint), recorded in the gesture transaction so the
|
|
4155
|
+
* WHOLE link redraws under the cursor with full fidelity — real style,
|
|
4156
|
+
* arrowhead, curved bow, and (via `rerouteElbows` in `render`) a live elbow
|
|
4157
|
+
* re-route. One undo step on commit; Escape cancels the transaction and the
|
|
4158
|
+
* link snaps back to where it was. The handle dot follows via `linkEndpointDrag`.
|
|
4159
|
+
*/
|
|
4160
|
+
applyLinkEndpointMove(linkId, side, toPoint) {
|
|
4161
|
+
const edge = getLink(this._scene, linkId);
|
|
4162
|
+
if (!edge)
|
|
4163
|
+
return;
|
|
4164
|
+
// A real drag breaks the handle double-click chain (mirrors waypoint /
|
|
4165
|
+
// segment drags) so a quick click after dropping isn't read as a delete.
|
|
4166
|
+
this.lastHandleClickAt = 0;
|
|
4167
|
+
// Resolve the attach target under the cursor and snap the endpoint to it
|
|
4168
|
+
// with the SAME logic the drop uses, so the link attaches LIVE exactly as it
|
|
4169
|
+
// will commit — lands on the dot (fixed), floats on the body, or stays a
|
|
4170
|
+
// free point over empty space.
|
|
4171
|
+
const target = this.linkAttachTargetAt(toPoint);
|
|
4172
|
+
const targetId = target?.kind === "element" ? target.id : null;
|
|
4173
|
+
const ep = this.snapLinkEndpoint(targetId, toPoint);
|
|
4174
|
+
const r = updateLink(this._scene, linkId, (e) => side === "from" ? { ...e, from: ep } : { ...e, to: ep });
|
|
4175
|
+
this._scene = r.scene;
|
|
4176
|
+
this.recordGesturePatch(r.patch);
|
|
4177
|
+
this.linkEndpointDrag = { linkId, side, toPoint };
|
|
4178
|
+
// Attach-point highlight — the SAME feedback as drawing a new link
|
|
4179
|
+
// (candidate dots + float-element halo), driven by `hoveredLinkTarget`.
|
|
4180
|
+
this.updateHoveredLinkTarget(toPoint);
|
|
4181
|
+
this.notify();
|
|
4182
|
+
}
|
|
4183
|
+
applyLinkEndpointUpdate(emit) {
|
|
4184
|
+
// A move opened a gesture transaction (live re-point per tick). The final
|
|
4185
|
+
// snapped endpoint goes into the SAME transaction so the net history step is
|
|
4186
|
+
// original → final (one undo). A pure click (no move, no tx) that resolves
|
|
4187
|
+
// to a no-op change must not leave a junk undo entry.
|
|
4188
|
+
const moved = this.gestureTx !== null;
|
|
4189
|
+
const result = computeLinkEndpointUpdate(this._scene, emit, (toElement, toPoint) => this.snapLinkEndpoint(toElement, toPoint));
|
|
4190
|
+
if (result === null) {
|
|
4191
|
+
this.cancelGesture();
|
|
4192
|
+
this.linkEndpointDrag = null;
|
|
4193
|
+
this.hoveredLinkTarget = null;
|
|
4194
|
+
this.notify();
|
|
4195
|
+
return;
|
|
4196
|
+
}
|
|
4197
|
+
if (!moved && isNoop(result.patch)) {
|
|
4198
|
+
this.linkEndpointDrag = null;
|
|
4199
|
+
this.hoveredLinkTarget = null;
|
|
4200
|
+
this.notify();
|
|
4201
|
+
return;
|
|
4202
|
+
}
|
|
4203
|
+
this._scene = result.scene;
|
|
4204
|
+
this.recordGesturePatch(result.patch);
|
|
4205
|
+
this.commitGesture();
|
|
4206
|
+
this.linkEndpointDrag = null;
|
|
4207
|
+
this.hoveredLinkTarget = null;
|
|
4208
|
+
}
|
|
4209
|
+
/** True while a waypoint of the selected link is being dragged. */
|
|
4210
|
+
get isDraggingWaypoint() {
|
|
4211
|
+
return this.linkWaypointDrag !== null;
|
|
4212
|
+
}
|
|
4213
|
+
/**
|
|
4214
|
+
* Begin a host-managed waypoint drag. `insert` splices a new waypoint at
|
|
4215
|
+
* `index` on the first move (segment-midpoint "add" handle); otherwise an
|
|
4216
|
+
* existing waypoint at `index` is moved. Live-mutated through the gesture
|
|
4217
|
+
* transaction so the whole drag is one undo step.
|
|
4218
|
+
*/
|
|
4219
|
+
beginWaypointDrag(linkId, index, insert) {
|
|
4220
|
+
if (!getLink(this._scene, linkId))
|
|
4221
|
+
return;
|
|
4222
|
+
this.linkWaypointDrag = { linkId, index, pendingInsert: insert };
|
|
4223
|
+
}
|
|
4224
|
+
/** Live update of the dragged waypoint to `world`. */
|
|
4225
|
+
updateWaypointDrag(world) {
|
|
4226
|
+
const drag = this.linkWaypointDrag;
|
|
4227
|
+
if (!drag)
|
|
4228
|
+
return;
|
|
4229
|
+
// A real drag breaks the handle double-click chain (see updateSegmentDrag).
|
|
4230
|
+
this.lastHandleClickAt = 0;
|
|
4231
|
+
const edge = getLink(this._scene, drag.linkId);
|
|
4232
|
+
if (!edge)
|
|
4233
|
+
return;
|
|
4234
|
+
const wps = [...(edge.waypoints ?? [])];
|
|
4235
|
+
if (drag.pendingInsert) {
|
|
4236
|
+
wps.splice(drag.index, 0, world);
|
|
4237
|
+
drag.pendingInsert = false;
|
|
4238
|
+
}
|
|
4239
|
+
else {
|
|
4240
|
+
if (drag.index < 0 || drag.index >= wps.length)
|
|
4241
|
+
return;
|
|
4242
|
+
wps[drag.index] = world;
|
|
4243
|
+
}
|
|
4244
|
+
const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, waypoints: wps }));
|
|
4245
|
+
this._scene = r.scene;
|
|
4246
|
+
this.recordGesturePatch(r.patch);
|
|
4247
|
+
this.notify();
|
|
4248
|
+
}
|
|
4249
|
+
/**
|
|
4250
|
+
* Finish the waypoint drag. If the dragged waypoint landed within
|
|
4251
|
+
* `WAYPOINT_COLLAPSE_RADIUS` of an adjacent path point, it is removed
|
|
4252
|
+
* (drag-onto-the-line to delete). A no-move insert adds nothing.
|
|
4253
|
+
*/
|
|
4254
|
+
endWaypointDrag() {
|
|
4255
|
+
const drag = this.linkWaypointDrag;
|
|
4256
|
+
this.linkWaypointDrag = null;
|
|
4257
|
+
if (!drag)
|
|
4258
|
+
return;
|
|
4259
|
+
if (drag.pendingInsert) {
|
|
4260
|
+
// Never moved → it was a click on a midpoint; nothing inserted.
|
|
4261
|
+
this.commitGesture();
|
|
4262
|
+
return;
|
|
4263
|
+
}
|
|
4264
|
+
const edge = getLink(this._scene, drag.linkId);
|
|
4265
|
+
if (edge?.waypoints && drag.index >= 0 && drag.index < edge.waypoints.length) {
|
|
4266
|
+
const path = getLinkPath(this._scene, edge);
|
|
4267
|
+
const wp = req(edge.waypoints[drag.index]);
|
|
4268
|
+
// Neighbours in the [from, ...waypoints, to] chain: path[index] and
|
|
4269
|
+
// path[index + 2] (path[0] = from, so waypoint i sits at path[i + 1]).
|
|
4270
|
+
// Dropping the waypoint back onto the straight segment between its
|
|
4271
|
+
// neighbours removes the bend ("drag onto the line to delete").
|
|
4272
|
+
const collapse = WAYPOINT_COLLAPSE_RADIUS / (this._scene.viewport.zoom || 1);
|
|
4273
|
+
const a = path?.[drag.index];
|
|
4274
|
+
const b = path?.[drag.index + 2];
|
|
4275
|
+
if (a && b && distanceToSegmentPt(wp, a, b) <= collapse) {
|
|
4276
|
+
const wps = edge.waypoints.filter((_, i) => i !== drag.index);
|
|
4277
|
+
const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, waypoints: wps }));
|
|
4278
|
+
this._scene = r.scene;
|
|
4279
|
+
this.recordGesturePatch(r.patch);
|
|
4280
|
+
}
|
|
4281
|
+
}
|
|
4282
|
+
this.commitGesture();
|
|
4283
|
+
}
|
|
4284
|
+
/** True while an elbow segment is being dragged. */
|
|
4285
|
+
get isDraggingSegment() {
|
|
4286
|
+
return this.linkSegmentDrag !== null;
|
|
4287
|
+
}
|
|
4288
|
+
/**
|
|
4289
|
+
* Begin a host-managed elbow segment drag. `axis` is the segment's
|
|
4290
|
+
* orientation; `at` is its centre along its own axis (used to re-identify it
|
|
4291
|
+
* across re-routes).
|
|
4292
|
+
*/
|
|
4293
|
+
beginSegmentDrag(linkId, axis, at) {
|
|
4294
|
+
if (!getLink(this._scene, linkId))
|
|
4295
|
+
return;
|
|
4296
|
+
this.linkSegmentDrag = { linkId, axis, at };
|
|
4297
|
+
}
|
|
4298
|
+
/**
|
|
4299
|
+
* Move the dragged elbow segment perpendicular to its axis: pin its
|
|
4300
|
+
* perpendicular coordinate to the cursor. The reroute pass re-flows the
|
|
4301
|
+
* rest around the pin (one undo step via the gesture transaction).
|
|
4302
|
+
*/
|
|
4303
|
+
updateSegmentDrag(world) {
|
|
4304
|
+
const drag = this.linkSegmentDrag;
|
|
4305
|
+
if (!drag)
|
|
4306
|
+
return;
|
|
4307
|
+
// A real drag breaks the handle double-click chain, so a single click
|
|
4308
|
+
// right after pinning can't be misread as a double-click (= delete).
|
|
4309
|
+
this.lastHandleClickAt = 0;
|
|
4310
|
+
const edge = getLink(this._scene, drag.linkId);
|
|
4311
|
+
if (!edge)
|
|
4312
|
+
return;
|
|
4313
|
+
const pos = drag.axis === "h" ? world.y : world.x;
|
|
4314
|
+
const fixed = [...(edge.fixedSegments ?? [])];
|
|
4315
|
+
const entry = { axis: drag.axis, pos, at: drag.at };
|
|
4316
|
+
const at = fixed.findIndex((f) => f.axis === drag.axis && Math.abs(f.at - drag.at) < 0.5);
|
|
4317
|
+
if (at >= 0)
|
|
4318
|
+
fixed[at] = entry;
|
|
4319
|
+
else
|
|
4320
|
+
fixed.push(entry);
|
|
4321
|
+
const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, fixedSegments: fixed }));
|
|
4322
|
+
this._scene = r.scene;
|
|
4323
|
+
this.recordGesturePatch(r.patch);
|
|
4324
|
+
this.notify();
|
|
4325
|
+
}
|
|
4326
|
+
/** Finish the elbow segment drag (commit the gesture as one undo step). */
|
|
4327
|
+
endSegmentDrag() {
|
|
4328
|
+
if (!this.linkSegmentDrag)
|
|
4329
|
+
return;
|
|
4330
|
+
this.linkSegmentDrag = null;
|
|
4331
|
+
this.commitGesture();
|
|
4332
|
+
}
|
|
4333
|
+
/**
|
|
4334
|
+
* Double-click detector for link edit handles (waypoint / segment).
|
|
4335
|
+
* Returns true when this press follows the previous handle press within
|
|
4336
|
+
* the double-click window + tolerance. Updates state every call. Kept
|
|
4337
|
+
* separate from the up-side double-click path (handles return early in
|
|
4338
|
+
* `onDown`, so that path never sees them).
|
|
4339
|
+
*/
|
|
4340
|
+
isHandleDoubleClick(world) {
|
|
4341
|
+
const now = performance.now();
|
|
4342
|
+
const isDouble = now - this.lastHandleClickAt < DOUBLE_CLICK_MS &&
|
|
4343
|
+
this.lastHandleClickWorld !== null &&
|
|
4344
|
+
distanceTo(this.lastHandleClickWorld, world) <= DOUBLE_CLICK_TOLERANCE_PX;
|
|
4345
|
+
this.lastHandleClickAt = now;
|
|
4346
|
+
this.lastHandleClickWorld = world;
|
|
4347
|
+
return isDouble;
|
|
4348
|
+
}
|
|
4349
|
+
/**
|
|
4350
|
+
* Delete a free bend point (waypoint) from a straight / bezier link by
|
|
4351
|
+
* index — double-click a waypoint handle to remove it. One undo step.
|
|
4352
|
+
*/
|
|
4353
|
+
deleteWaypoint(linkId, index) {
|
|
4354
|
+
const edge = getLink(this._scene, linkId);
|
|
4355
|
+
if (!edge?.waypoints || index < 0 || index >= edge.waypoints.length)
|
|
4356
|
+
return;
|
|
4357
|
+
const wps = edge.waypoints.filter((_, i) => i !== index);
|
|
4358
|
+
const r = updateLink(this._scene, linkId, (e) => ({ ...e, waypoints: wps }));
|
|
4359
|
+
this._scene = r.scene;
|
|
4360
|
+
this._history.push(r.patch);
|
|
4361
|
+
this.notify();
|
|
4362
|
+
}
|
|
4363
|
+
/**
|
|
4364
|
+
* Remove the pinned (fixed) elbow segment that matches the given
|
|
4365
|
+
* geometry — double-click a segment handle to return it to the auto
|
|
4366
|
+
* route. Matches by axis + nearest pinned perpendicular `pos` (exact for
|
|
4367
|
+
* a pinned segment), `at` as tiebreak. The reroute pass re-flows on the
|
|
4368
|
+
* next render (fixedSegments is part of the elbow signature). One undo
|
|
4369
|
+
* step.
|
|
4370
|
+
*/
|
|
4371
|
+
resetSegmentPin(linkId, axis, pos, at) {
|
|
4372
|
+
const edge = getLink(this._scene, linkId);
|
|
4373
|
+
if (!edge?.fixedSegments || edge.fixedSegments.length === 0)
|
|
4374
|
+
return;
|
|
4375
|
+
let bestIdx = -1;
|
|
4376
|
+
let bestD = Infinity;
|
|
4377
|
+
for (let i = 0; i < edge.fixedSegments.length; i++) {
|
|
4378
|
+
const f = req(edge.fixedSegments[i]);
|
|
4379
|
+
if (f.axis !== axis)
|
|
4380
|
+
continue;
|
|
4381
|
+
const d = Math.abs(f.pos - pos) + Math.abs(f.at - at) * 0.001;
|
|
4382
|
+
if (d < bestD) {
|
|
4383
|
+
bestD = d;
|
|
4384
|
+
bestIdx = i;
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
if (bestIdx < 0)
|
|
4388
|
+
return;
|
|
4389
|
+
const fixed = edge.fixedSegments.filter((_, i) => i !== bestIdx);
|
|
4390
|
+
const r = updateLink(this._scene, linkId, (e) => ({ ...e, fixedSegments: fixed }));
|
|
4391
|
+
this._scene = r.scene;
|
|
4392
|
+
this._history.push(r.patch);
|
|
4393
|
+
this.notify();
|
|
4394
|
+
}
|
|
4395
|
+
/** Whether the selected link has obstacle-avoidance routing enabled. */
|
|
4396
|
+
get selectedLinkAvoidsObstacles() {
|
|
4397
|
+
const id = this.selectedLink;
|
|
4398
|
+
if (id === null)
|
|
4399
|
+
return false;
|
|
4400
|
+
return getLink(this._scene, id)?.avoidObstacles === true;
|
|
4401
|
+
}
|
|
4402
|
+
/**
|
|
4403
|
+
* Toggle persistent "route around shapes" on the selected link (standard
|
|
4404
|
+
* model). Enabling sets `avoidObstacles` and forces `orthogonal` routing —
|
|
4405
|
+
* the elbow router then keeps the path clear of EVERY scene shape and
|
|
4406
|
+
* re-routes whenever an obstacle moves into the way (see
|
|
4407
|
+
* `routeElbowLink` / `elbowSignature`). Disabling drops the flag; the
|
|
4408
|
+
* routing type is left as-is. One undo step; the routed path itself is
|
|
4409
|
+
* derived (recomputed by `rerouteElbows`). No-op when no link is selected.
|
|
4410
|
+
*/
|
|
4411
|
+
setSelectedLinkAvoidObstacles(enabled) {
|
|
4412
|
+
const id = this.selectedLink;
|
|
4413
|
+
if (id === null)
|
|
4414
|
+
return;
|
|
4415
|
+
const edge = getLink(this._scene, id);
|
|
4416
|
+
if (!edge || edge.avoidObstacles === enabled)
|
|
4417
|
+
return;
|
|
4418
|
+
const r = updateLink(this._scene, id, (e) => ({
|
|
4419
|
+
...e,
|
|
4420
|
+
avoidObstacles: enabled,
|
|
4421
|
+
...(enabled ? { routing: "orthogonal" } : {}),
|
|
4422
|
+
}));
|
|
4423
|
+
this._scene = r.scene;
|
|
4424
|
+
this._history.push(r.patch);
|
|
4425
|
+
// Force the next reroute to recompute with the new mode.
|
|
4426
|
+
this.elbowRouteSig.delete(id);
|
|
4427
|
+
this.notify();
|
|
4428
|
+
}
|
|
4429
|
+
updateHoveredLinkTarget(worldPoint) {
|
|
4430
|
+
const shape = this.acceleratedElementAt(worldPoint);
|
|
4431
|
+
if (!shape) {
|
|
4432
|
+
if (this.hoveredLinkTarget !== null) {
|
|
4433
|
+
this.hoveredLinkTarget = null;
|
|
4434
|
+
this.notify();
|
|
4435
|
+
}
|
|
4436
|
+
return;
|
|
4437
|
+
}
|
|
4438
|
+
const result = this.snapEngine.snap({
|
|
4439
|
+
scene: this._scene,
|
|
4440
|
+
probe: worldPoint,
|
|
4441
|
+
threshold: this.snapThreshold,
|
|
4442
|
+
gesture: "draw-edge",
|
|
4443
|
+
});
|
|
4444
|
+
// Prefer a snap candidate that belongs to the current shape —
|
|
4445
|
+
// ensures the "ghost" points don't jump to a nearby shape.
|
|
4446
|
+
const onTarget = result.all.filter((c) => c.metadata?.elementId === shape.id);
|
|
4447
|
+
const anchor = onTarget.find((c) => c.kind === "anchor");
|
|
4448
|
+
const outline = onTarget.find((c) => c.kind === "outline");
|
|
4449
|
+
const ref = anchor?.metadata?.ref;
|
|
4450
|
+
let activeName = null;
|
|
4451
|
+
if (ref?.kind === "named") {
|
|
4452
|
+
activeName = ref.name;
|
|
4453
|
+
}
|
|
4454
|
+
else if (ref?.kind === "edge" && ref.t === 0.5) {
|
|
4455
|
+
activeName = `edge-${ref.index}`;
|
|
4456
|
+
}
|
|
4457
|
+
const outlinePoint = !activeName && outline ? outline.snapped : undefined;
|
|
4458
|
+
const prev = this.hoveredLinkTarget;
|
|
4459
|
+
if (prev?.elementId === shape.id &&
|
|
4460
|
+
prev.activeAnchor === activeName &&
|
|
4461
|
+
prev.outlinePoint?.x === outlinePoint?.x &&
|
|
4462
|
+
prev.outlinePoint?.y === outlinePoint?.y) {
|
|
4463
|
+
return;
|
|
4464
|
+
}
|
|
4465
|
+
// Mode mirrors snapLinkEndpoint: a named-anchor OR edge (outline) hit →
|
|
4466
|
+
// *fixed* point (show the dot, no float halo); only the body interior with
|
|
4467
|
+
// no edge/dot snap → floating (attach to the whole element).
|
|
4468
|
+
const mode = activeName !== null || outlinePoint ? "point" : "element";
|
|
4469
|
+
this.hoveredLinkTarget = { elementId: shape.id, activeAnchor: activeName, outlinePoint, mode };
|
|
4470
|
+
this.notify();
|
|
4471
|
+
}
|
|
4472
|
+
// Pure body in `./editor/applies/edge.ts`.
|
|
4473
|
+
applyLinkPreview(fromElement, fromPoint, toPoint) {
|
|
4474
|
+
const ep = computeLinkPreviewEndpoints(this._scene, fromElement, fromPoint, toPoint);
|
|
4475
|
+
// Match the preview to the connector that will be committed: when new
|
|
4476
|
+
// links default to elbow, draw the orthogonal route, not a straight line.
|
|
4477
|
+
if (DEFAULT_LINK_ROUTING === "orthogonal") {
|
|
4478
|
+
const hit = this.hitTest(ep.to);
|
|
4479
|
+
const toElement = hit.kind === "element" ? hit.id : null;
|
|
4480
|
+
const points = routeElbowPreview(this._scene, fromElement, ep.from, toElement, ep.to);
|
|
4481
|
+
this.edgePreview = { ...ep, points };
|
|
4482
|
+
}
|
|
4483
|
+
else {
|
|
4484
|
+
this.edgePreview = ep;
|
|
4485
|
+
}
|
|
4486
|
+
this.notify();
|
|
4487
|
+
}
|
|
4488
|
+
// Gesture lifecycle — recordGesturePatch / commitGesture /
|
|
4489
|
+
// cancelGesture / finalizeOpenGestureTx / maybeRevertModeAfterCreate
|
|
4490
|
+
// live in `./editor/gesture-tx.ts`. The thin instance methods below
|
|
4491
|
+
// preserve the original call sites.
|
|
4492
|
+
recordGesturePatch(patch) {
|
|
4493
|
+
// Snapshot the pre-gesture scene the moment the transaction opens, so a
|
|
4494
|
+
// later cancel/Escape can restore it (the history tx only records undo data,
|
|
4495
|
+
// it doesn't roll `_scene` back). Callers apply the patch to `_scene` BEFORE
|
|
4496
|
+
// recording, so reconstruct the pre-state by inverting this first patch.
|
|
4497
|
+
if (this.gestureTx === null)
|
|
4498
|
+
this.gestureStartScene = apply(this._scene, invert(patch));
|
|
4499
|
+
this.gestures.record(patch);
|
|
4500
|
+
}
|
|
4501
|
+
commitGesture() {
|
|
4502
|
+
this._resizeOriginElement = null;
|
|
4503
|
+
this.gestures.commit();
|
|
4504
|
+
this.gestureStartScene = null;
|
|
4505
|
+
}
|
|
4506
|
+
finalizeOpenGestureTx() {
|
|
4507
|
+
this.gestures.finalize();
|
|
4508
|
+
}
|
|
4509
|
+
/**
|
|
4510
|
+
* End-of-drag container hookup. Runs after the state machine has
|
|
4511
|
+
* received POINTER_UP but before the gesture transaction commits,
|
|
4512
|
+
* so reparent + auto-grow land in one undo step with the drag itself.
|
|
4513
|
+
*
|
|
4514
|
+
* Rules:
|
|
4515
|
+
* - If the shape was dropped over a container and is not yet its child →
|
|
4516
|
+
* set `parentId`. If the shape extends past the dropZone, the zone is
|
|
4517
|
+
* grown (and the container's size with it).
|
|
4518
|
+
* - If the shape was someone's child but its final world bounds no longer
|
|
4519
|
+
* intersect the parent's drop-zone → clear `parentId` (drag-out).
|
|
4520
|
+
* - Cycles (a container inside its own descendant) are prevented by the
|
|
4521
|
+
* `containerHover` pipeline above — the exclude set rules them out.
|
|
4522
|
+
*/
|
|
4523
|
+
// Pure body in `./editor/container-ops.ts`. Editor exposes a
|
|
4524
|
+
// small `ContainerOpsRef` bridge so the module can mutate scene
|
|
4525
|
+
// + push patches into the running gesture transaction.
|
|
4526
|
+
applyContainerDrop(worldPoint) {
|
|
4527
|
+
applyContainerDropPure(this.containerOpsRef, worldPoint);
|
|
4528
|
+
}
|
|
4529
|
+
// Public-private hybrid — also called from AutoLayoutScheduler.
|
|
4530
|
+
maybeGrowContainer(containerId, childId) {
|
|
4531
|
+
maybeGrowContainerPure(this.containerOpsRef, containerId, childId);
|
|
4532
|
+
}
|
|
4533
|
+
// Pure body in `./editor/container-ops.ts`.
|
|
4534
|
+
clampContainerToChildren(shape, raw, handle) {
|
|
4535
|
+
return clampContainerToChildrenPure(this._scene, shape, raw, handle);
|
|
4536
|
+
}
|
|
4537
|
+
/**
|
|
4538
|
+
* Return the running gesture tx, or open a new one if the drag finished
|
|
4539
|
+
* with an empty transaction (a move-by-zero-pixels gesture can still
|
|
4540
|
+
* carry a container reparent).
|
|
4541
|
+
*/
|
|
4542
|
+
beginOrAttachGesture() {
|
|
4543
|
+
this.gestureTx ??= this._history.transaction();
|
|
4544
|
+
return this.gestureTx;
|
|
4545
|
+
}
|
|
4546
|
+
// Body moved to `./editor/gesture-tx.ts`.
|
|
4547
|
+
cancelGesture() {
|
|
4548
|
+
this._resizeOriginElement = null;
|
|
4549
|
+
this.gestures.cancel();
|
|
4550
|
+
// Roll the scene back to the pre-gesture snapshot — cancelling the history
|
|
4551
|
+
// transaction alone leaves the live drag mutations in `_scene`.
|
|
4552
|
+
if (this.gestureStartScene !== null) {
|
|
4553
|
+
this._scene = this.gestureStartScene;
|
|
4554
|
+
this.gestureStartScene = null;
|
|
4555
|
+
}
|
|
4556
|
+
}
|
|
4557
|
+
/**
|
|
4558
|
+
* Drop ids from the selection that no longer exist in the scene. Needed
|
|
4559
|
+
* after undoing a CREATE — the shape goes away and the selection becomes
|
|
4560
|
+
* stale.
|
|
4561
|
+
*/
|
|
4562
|
+
pruneSelection() {
|
|
4563
|
+
let next = null;
|
|
4564
|
+
for (const id of this._selection) {
|
|
4565
|
+
if (!this._scene.elements.has(id)) {
|
|
4566
|
+
next ??= new Set(this._selection);
|
|
4567
|
+
next.delete(id);
|
|
4568
|
+
}
|
|
4569
|
+
}
|
|
4570
|
+
if (next !== null)
|
|
4571
|
+
this._selection = next;
|
|
4572
|
+
}
|
|
4573
|
+
notify() {
|
|
4574
|
+
this.scheduleRender();
|
|
4575
|
+
fanOutEvents(this.eventCache, this.events, this.observableSnapshot());
|
|
4576
|
+
for (const fn of this.listeners)
|
|
4577
|
+
fn();
|
|
4578
|
+
this.autoCompactScheduler.schedule();
|
|
4579
|
+
this.autoLayoutScheduler.schedule();
|
|
4580
|
+
// A pan / zoom / scene edit may have scrolled an animated shape into
|
|
4581
|
+
// view — re-arm the (viewport-culled) animation tick.
|
|
4582
|
+
this.maybeAnimate();
|
|
4583
|
+
}
|
|
4584
|
+
/**
|
|
4585
|
+
* Pending `requestAnimationFrame` id for the next render, or null
|
|
4586
|
+
* when no render is scheduled. Used to coalesce bursts of `notify()`
|
|
4587
|
+
* calls (drag-pan, drag shape, multi-key, scripted batch mutations)
|
|
4588
|
+
* into a single render per frame.
|
|
4589
|
+
*/
|
|
4590
|
+
renderRafId = null;
|
|
4591
|
+
/** Unsubscribe for the animation-content-ready listener (decode → re-render). */
|
|
4592
|
+
animationContentOff = null;
|
|
4593
|
+
/**
|
|
4594
|
+
* Schedule a render on the next animation frame. Idempotent —
|
|
4595
|
+
* multiple calls within the same frame collapse to one render.
|
|
4596
|
+
*
|
|
4597
|
+
* Falls back to a synchronous render when `requestAnimationFrame`
|
|
4598
|
+
* is unavailable (Node without jsdom, SSR). Browser / test environments
|
|
4599
|
+
* with rAF get the coalesced path.
|
|
4600
|
+
*
|
|
4601
|
+
* Use {@link forceRender} when you need the render to happen
|
|
4602
|
+
* immediately (PNG export, screenshot, visual-regression tests that
|
|
4603
|
+
* compare bitmap output after a mutation).
|
|
4604
|
+
*/
|
|
4605
|
+
scheduleRender() {
|
|
4606
|
+
if (this.renderRafId !== null)
|
|
4607
|
+
return;
|
|
4608
|
+
if (typeof requestAnimationFrame === "undefined") {
|
|
4609
|
+
// SSR / Node fallback. Keep behaviour synchronous so headless
|
|
4610
|
+
// renderers and tests that don't poll rAFs still see the
|
|
4611
|
+
// updated frame.
|
|
4612
|
+
this.render();
|
|
4613
|
+
return;
|
|
4614
|
+
}
|
|
4615
|
+
this.renderRafId = requestAnimationFrame(() => {
|
|
4616
|
+
this.renderRafId = null;
|
|
4617
|
+
this.render();
|
|
4618
|
+
});
|
|
4619
|
+
}
|
|
4620
|
+
/**
|
|
4621
|
+
* Synchronously render the current state. Cancels any pending
|
|
4622
|
+
* rAF-scheduled render so the next browser frame doesn't paint
|
|
4623
|
+
* a stale state on top.
|
|
4624
|
+
*
|
|
4625
|
+
* Hosts only need this when they read back the rendered bitmap
|
|
4626
|
+
* immediately after a mutation — `editor.toPng()`, custom
|
|
4627
|
+
* `canvas.toDataURL()` flows, visual-regression test asserts.
|
|
4628
|
+
* Normal interactive flows should let `scheduleRender` do its job.
|
|
4629
|
+
*/
|
|
4630
|
+
forceRender() {
|
|
4631
|
+
if (this.renderRafId !== null && typeof cancelAnimationFrame !== "undefined") {
|
|
4632
|
+
cancelAnimationFrame(this.renderRafId);
|
|
4633
|
+
this.renderRafId = null;
|
|
4634
|
+
}
|
|
4635
|
+
this.render();
|
|
4636
|
+
}
|
|
4637
|
+
/**
|
|
4638
|
+
* Typed event surface — subscribe to a specific slice (`mode`,
|
|
4639
|
+
* `selection`, `scene`, `history`, `viewport`) or the umbrella
|
|
4640
|
+
* `change`. Replaces ad-hoc selectors over the coarse `subscribe()`
|
|
4641
|
+
* for callers that only care about one dimension. The legacy
|
|
4642
|
+
* `subscribe()` still works and fires in lock-step.
|
|
4643
|
+
*/
|
|
4644
|
+
on(event, fn) {
|
|
4645
|
+
// Cast through `never`: TS can't prove that EditorEvents[K]
|
|
4646
|
+
// satisfies the emitter's `extends AnyListener ? T : never`
|
|
4647
|
+
// conditional through a generic body. Every entry of
|
|
4648
|
+
// EditorEvents is a function by construction so this is safe.
|
|
4649
|
+
return this.events.on(event, fn);
|
|
4650
|
+
}
|
|
4651
|
+
off(event, fn) {
|
|
4652
|
+
this.events.off(event, fn);
|
|
4653
|
+
}
|
|
4654
|
+
/**
|
|
4655
|
+
* Public command — re-run the auto-layout spec on the given
|
|
4656
|
+
* container shape, regardless of whether the children set actually
|
|
4657
|
+
* changed. Useful as an "auto-arrange" toolbar action or for hosts
|
|
4658
|
+
* adopting auto-layout after creating shapes programmatically.
|
|
4659
|
+
* Returns the patch that was applied, or `null` when nothing
|
|
4660
|
+
* changed (no spec, no children, or children already in position).
|
|
4661
|
+
* Single undo step.
|
|
4662
|
+
*/
|
|
4663
|
+
runLayout(parentId) {
|
|
4664
|
+
const patch = runAutoLayout(this._scene, parentId);
|
|
4665
|
+
if (!patch)
|
|
4666
|
+
return null;
|
|
4667
|
+
this._scene = apply(this._scene, patch);
|
|
4668
|
+
this._history.push(patch);
|
|
4669
|
+
// Refresh the recorded signature so the post-notify auto-check
|
|
4670
|
+
// doesn't fire a second redundant run.
|
|
4671
|
+
this.autoLayoutScheduler.resetSignature(parentId);
|
|
4672
|
+
this.notify();
|
|
4673
|
+
return patch;
|
|
4674
|
+
}
|
|
4675
|
+
/**
|
|
4676
|
+
* Per-link signature of the inputs that determine an elbow route
|
|
4677
|
+
* (endpoint refs + bound-shape bounds + fixedSegments). When unchanged
|
|
4678
|
+
* between frames the A* route is reused — see `rerouteElbows`.
|
|
4679
|
+
*/
|
|
4680
|
+
elbowRouteSig = new Map();
|
|
4681
|
+
elbowSignature(edge) {
|
|
4682
|
+
const part = (ep) => {
|
|
4683
|
+
if (ep.kind === "point")
|
|
4684
|
+
return `p:${ep.position.x},${ep.position.y}`;
|
|
4685
|
+
const s = getElement(this._scene, ep.elementId);
|
|
4686
|
+
const b = s ? getElementWorldBounds(s) : null;
|
|
4687
|
+
const ref = ep.kind === "anchor"
|
|
4688
|
+
? JSON.stringify(ep.anchor)
|
|
4689
|
+
: ep.kind === "outline"
|
|
4690
|
+
? `o:${ep.ratio}`
|
|
4691
|
+
: "f";
|
|
4692
|
+
return `${ep.kind}:${ep.elementId}:${ref}:${b ? `${b.x},${b.y},${b.width},${b.height}` : "x"}`;
|
|
4693
|
+
};
|
|
4694
|
+
const base = `${part(edge.from)}|${part(edge.to)}|${JSON.stringify(edge.fixedSegments ?? null)}`;
|
|
4695
|
+
// Avoid-obstacles links depend on EVERY shape's geometry, so their route
|
|
4696
|
+
// must invalidate when any obstacle moves — fold a digest of all element
|
|
4697
|
+
// bboxes into the signature. Only paid by links that opt in.
|
|
4698
|
+
if (edge.avoidObstacles === true) {
|
|
4699
|
+
let digest = "|avoid:";
|
|
4700
|
+
for (const el of this._scene.elements.values()) {
|
|
4701
|
+
const bb = getElementWorldBounds(el);
|
|
4702
|
+
digest += `${el.id},${bb.x},${bb.y},${bb.width},${bb.height};`;
|
|
4703
|
+
}
|
|
4704
|
+
return base + digest;
|
|
4705
|
+
}
|
|
4706
|
+
return base;
|
|
4707
|
+
}
|
|
4708
|
+
/**
|
|
4709
|
+
* Choke-point reroute (standard model): recompute `routedPoints` for
|
|
4710
|
+
* every orthogonal link whose inputs changed since the last pass, and
|
|
4711
|
+
* bake the result into `_scene`. Runs once per frame before paint —
|
|
4712
|
+
* derived state, so no history push / notify (would loop). Cheap when
|
|
4713
|
+
* nothing moved (signature short-circuit).
|
|
4714
|
+
*/
|
|
4715
|
+
rerouteElbows() {
|
|
4716
|
+
let next = this._scene;
|
|
4717
|
+
for (const [id, edge] of this._scene.links) {
|
|
4718
|
+
if ((edge.routing ?? "straight") !== "orthogonal")
|
|
4719
|
+
continue;
|
|
4720
|
+
const sig = this.elbowSignature(edge);
|
|
4721
|
+
if (this.elbowRouteSig.get(id) === sig)
|
|
4722
|
+
continue;
|
|
4723
|
+
this.elbowRouteSig.set(id, sig);
|
|
4724
|
+
const routedPoints = routeElbowLink(next, edge);
|
|
4725
|
+
next = updateLink(next, id, (e) => ({ ...e, routedPoints })).scene;
|
|
4726
|
+
}
|
|
4727
|
+
this._scene = next;
|
|
4728
|
+
}
|
|
4729
|
+
// Pure body in `./editor/render-orchestrator.ts` (~130 lines).
|
|
4730
|
+
render() {
|
|
4731
|
+
this.rerouteElbows();
|
|
4732
|
+
// Feed the renderer's animation clock our per-shape playback state
|
|
4733
|
+
// so paused / reduced-motion GIFs freeze and resumed ones continue
|
|
4734
|
+
// from the right frame. Set immediately before the synchronous
|
|
4735
|
+
// render pass (the shape-renderer has no options channel).
|
|
4736
|
+
setAnimationClock((shape) => this.playbackClock(shape.id));
|
|
4737
|
+
renderEditor(this);
|
|
4738
|
+
// Present AFTER the paint, on the same tick — deferred-submission
|
|
4739
|
+
// surfaces (WebGL2 / OffscreenCanvas) would otherwise lag one frame.
|
|
4740
|
+
this.onAfterRender?.();
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4743
|
+
/**
|
|
4744
|
+
* Throwaway id for the transient click-create ghost preview element built by
|
|
4745
|
+
* `previewClickCreate`. Never enters the scene / history — it lives only for
|
|
4746
|
+
* the duration of one overlay paint, so any stable constant is fine.
|
|
4747
|
+
*/
|
|
4748
|
+
const PREVIEW_GHOST_ELEMENT_ID = "__ghost-preview__";
|
|
4749
|
+
/** Throwaway link id for the click-create ghost preview. See above. */
|
|
4750
|
+
const PREVIEW_GHOST_LINK_ID = "__ghost-preview-link__";
|
|
4751
|
+
const distanceTo = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
4752
|
+
// `coverageRatio` moved to `./editor/container-ops.ts`.
|
|
4753
|
+
// `hasWidthHeight` moved to `./editor/shape-traits.ts` for shared
|
|
4754
|
+
// use by container-ops and the future applies/resize module.
|
|
4755
|
+
/**
|
|
4756
|
+
* Type guard — `true` when the value already implements the
|
|
4757
|
+
* `HistoryProvider` surface. Used to decide between "host supplied
|
|
4758
|
+
* an existing backend (use it as-is)" and "host supplied options
|
|
4759
|
+
* (build a default `History`)". Checks the methods that every
|
|
4760
|
+
* provider must expose; missing methods → treat as options.
|
|
4761
|
+
*/
|
|
4762
|
+
const isHistoryProvider = (value) => {
|
|
4763
|
+
if (!value || typeof value !== "object")
|
|
4764
|
+
return false;
|
|
4765
|
+
return (typeof value.push === "function" &&
|
|
4766
|
+
typeof value.undo === "function" &&
|
|
4767
|
+
typeof value.redo === "function" &&
|
|
4768
|
+
typeof value.transaction === "function");
|
|
4769
|
+
};
|
|
4770
|
+
// `describeNudge` moved to `./editor/public/selection-ops.ts`.
|
|
4771
|
+
/** Distance from point `p` to the finite segment `a`–`b` (world space). */
|
|
4772
|
+
function distanceToSegmentPt(p, a, b) {
|
|
4773
|
+
const dx = b.x - a.x;
|
|
4774
|
+
const dy = b.y - a.y;
|
|
4775
|
+
const lenSq = dx * dx + dy * dy;
|
|
4776
|
+
if (lenSq === 0)
|
|
4777
|
+
return Math.hypot(p.x - a.x, p.y - a.y);
|
|
4778
|
+
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
|
|
4779
|
+
t = Math.max(0, Math.min(1, t));
|
|
4780
|
+
return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t));
|
|
4781
|
+
}
|
|
4782
|
+
/**
|
|
4783
|
+
* Convert a snap candidate into an `LinkEndpoint`. Anchor snap → named
|
|
4784
|
+
* anchor ref; outline snap → outline ref with the sampled ratio. Falls
|
|
4785
|
+
* back to a free point if the metadata isn't recognised.
|
|
4786
|
+
*/
|
|
4787
|
+
const endpointFromSnap = (elementId, candidate, shape) => {
|
|
4788
|
+
if (candidate.kind === "anchor") {
|
|
4789
|
+
const ref = candidate.metadata?.ref;
|
|
4790
|
+
if (ref)
|
|
4791
|
+
return { kind: "anchor", elementId, anchor: ref };
|
|
4792
|
+
}
|
|
4793
|
+
if (candidate.kind === "outline" && typeof candidate.metadata?.ratio === "number") {
|
|
4794
|
+
return { kind: "outline", elementId, ratio: candidate.metadata.ratio };
|
|
4795
|
+
}
|
|
4796
|
+
// Defensive fallback — should not happen with built-in contributors.
|
|
4797
|
+
void shape;
|
|
4798
|
+
return { kind: "point", position: candidate.snapped };
|
|
4799
|
+
};
|
|
4800
|
+
// `resizeFromHandle`, `applyResizeConstraints`, the four handle-
|
|
4801
|
+
// quadrant predicates moved to `./editor/resize-helpers.ts` so
|
|
4802
|
+
// they're shared between applies/resize and the container clamp.
|
|
4803
|
+
//# sourceMappingURL=editor.js.map
|