@oh-just-another/state 0.58.0 → 0.60.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 +158 -0
- package/README.md +78 -27
- package/dist/.tsbuildinfo +1 -1
- package/dist/actions/actionArrange.d.ts +13 -0
- package/dist/actions/actionArrange.d.ts.map +1 -0
- package/dist/actions/actionArrange.js +130 -0
- package/dist/actions/actionArrange.js.map +1 -0
- package/dist/actions/actionClipboard.d.ts +2 -0
- package/dist/actions/actionClipboard.d.ts.map +1 -1
- package/dist/actions/actionClipboard.js +32 -1
- package/dist/actions/actionClipboard.js.map +1 -1
- package/dist/actions/actionKeyboard.d.ts.map +1 -1
- package/dist/actions/actionKeyboard.js +77 -9
- package/dist/actions/actionKeyboard.js.map +1 -1
- package/dist/actions/actionMode.d.ts +4 -0
- package/dist/actions/actionMode.d.ts.map +1 -1
- package/dist/actions/actionMode.js +54 -0
- package/dist/actions/actionMode.js.map +1 -1
- package/dist/actions/actionSelection.d.ts.map +1 -1
- package/dist/actions/actionSelection.js +1 -0
- package/dist/actions/actionSelection.js.map +1 -1
- package/dist/actions/actionText.d.ts +5 -0
- package/dist/actions/actionText.d.ts.map +1 -0
- package/dist/actions/actionText.js +37 -0
- package/dist/actions/actionText.js.map +1 -0
- package/dist/actions/actionView.d.ts +6 -0
- package/dist/actions/actionView.d.ts.map +1 -1
- package/dist/actions/actionView.js +20 -2
- package/dist/actions/actionView.js.map +1 -1
- package/dist/actions/actionZoom.d.ts.map +1 -1
- package/dist/actions/actionZoom.js +5 -0
- package/dist/actions/actionZoom.js.map +1 -1
- package/dist/actions/index.d.ts +6 -4
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +6 -4
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/registry.d.ts.map +1 -1
- package/dist/actions/registry.js +17 -0
- package/dist/actions/registry.js.map +1 -1
- package/dist/actions/types.d.ts +10 -1
- package/dist/actions/types.d.ts.map +1 -1
- package/dist/actions/types.js.map +1 -1
- package/dist/auto-layout-scheduler.d.ts +1 -3
- package/dist/auto-layout-scheduler.d.ts.map +1 -1
- package/dist/auto-layout-scheduler.js +1 -2
- package/dist/auto-layout-scheduler.js.map +1 -1
- package/dist/built-in-handlers.d.ts.map +1 -1
- package/dist/built-in-handlers.js +14 -1
- package/dist/built-in-handlers.js.map +1 -1
- package/dist/clipboard.d.ts +0 -14
- package/dist/clipboard.d.ts.map +1 -1
- package/dist/clipboard.js +1 -1
- package/dist/clipboard.js.map +1 -1
- package/dist/constants.d.ts +208 -41
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +210 -25
- package/dist/constants.js.map +1 -1
- package/dist/dom-events.d.ts +8 -2
- package/dist/dom-events.d.ts.map +1 -1
- package/dist/dom-events.js +12 -0
- package/dist/dom-events.js.map +1 -1
- package/dist/editor/animation-scene.d.ts +36 -0
- package/dist/editor/animation-scene.d.ts.map +1 -0
- package/dist/editor/animation-scene.js +194 -0
- package/dist/editor/animation-scene.js.map +1 -0
- package/dist/editor/animation.d.ts +43 -0
- package/dist/editor/animation.d.ts.map +1 -0
- package/dist/editor/animation.js +80 -0
- package/dist/editor/animation.js.map +1 -0
- package/dist/editor/applies/arrange.d.ts +46 -0
- package/dist/editor/applies/arrange.d.ts.map +1 -0
- package/dist/editor/applies/arrange.js +162 -0
- package/dist/editor/applies/arrange.js.map +1 -0
- package/dist/editor/applies/create.d.ts +0 -6
- package/dist/editor/applies/create.d.ts.map +1 -1
- package/dist/editor/applies/create.js +1 -1
- package/dist/editor/applies/create.js.map +1 -1
- package/dist/editor/applies/edge.d.ts +8 -1
- package/dist/editor/applies/edge.d.ts.map +1 -1
- package/dist/editor/applies/edge.js +31 -1
- package/dist/editor/applies/edge.js.map +1 -1
- package/dist/editor/applies/link-move.d.ts +0 -8
- package/dist/editor/applies/link-move.d.ts.map +1 -1
- package/dist/editor/applies/link-move.js +1 -1
- package/dist/editor/applies/link-move.js.map +1 -1
- package/dist/editor/applies/move.d.ts +6 -0
- package/dist/editor/applies/move.d.ts.map +1 -1
- package/dist/editor/applies/move.js +6 -0
- package/dist/editor/applies/move.js.map +1 -1
- package/dist/editor/applies/resize.d.ts +26 -5
- package/dist/editor/applies/resize.d.ts.map +1 -1
- package/dist/editor/applies/resize.js +91 -13
- package/dist/editor/applies/resize.js.map +1 -1
- package/dist/editor/caret-blink.d.ts +18 -0
- package/dist/editor/caret-blink.d.ts.map +1 -0
- package/dist/editor/caret-blink.js +42 -0
- package/dist/editor/caret-blink.js.map +1 -0
- package/dist/editor/container-ops.d.ts.map +1 -1
- package/dist/editor/container-ops.js +2 -2
- package/dist/editor/container-ops.js.map +1 -1
- package/dist/editor/dirty-rect.d.ts +23 -0
- package/dist/editor/dirty-rect.d.ts.map +1 -0
- package/dist/editor/dirty-rect.js +115 -0
- package/dist/editor/dirty-rect.js.map +1 -0
- package/dist/editor/gif-playback.d.ts +45 -0
- package/dist/editor/gif-playback.d.ts.map +1 -0
- package/dist/editor/gif-playback.js +125 -0
- package/dist/editor/gif-playback.js.map +1 -0
- package/dist/editor/hit-test.d.ts +3 -3
- package/dist/editor/hit-test.d.ts.map +1 -1
- package/dist/editor/hit-test.js +40 -15
- package/dist/editor/hit-test.js.map +1 -1
- package/dist/editor/interaction-state.d.ts +184 -0
- package/dist/editor/interaction-state.d.ts.map +1 -0
- package/dist/editor/interaction-state.js +139 -0
- package/dist/editor/interaction-state.js.map +1 -0
- package/dist/editor/link-handle-drag.d.ts +152 -0
- package/dist/editor/link-handle-drag.d.ts.map +1 -0
- package/dist/editor/link-handle-drag.js +300 -0
- package/dist/editor/link-handle-drag.js.map +1 -0
- package/dist/editor/long-press.d.ts.map +1 -1
- package/dist/editor/long-press.js +2 -2
- package/dist/editor/long-press.js.map +1 -1
- package/dist/editor/pinch.d.ts.map +1 -1
- package/dist/editor/pinch.js +6 -6
- package/dist/editor/pinch.js.map +1 -1
- package/dist/editor/pointer-binding.d.ts +9 -11
- package/dist/editor/pointer-binding.d.ts.map +1 -1
- package/dist/editor/pointer-binding.js +1191 -890
- package/dist/editor/pointer-binding.js.map +1 -1
- package/dist/editor/public/arrange-group.d.ts +3 -9
- package/dist/editor/public/arrange-group.d.ts.map +1 -1
- package/dist/editor/public/arrange-group.js +4 -6
- package/dist/editor/public/arrange-group.js.map +1 -1
- package/dist/editor/public/brush.d.ts +31 -12
- package/dist/editor/public/brush.d.ts.map +1 -1
- package/dist/editor/public/brush.js +66 -17
- package/dist/editor/public/brush.js.map +1 -1
- package/dist/editor/public/clipboard.d.ts +2 -4
- package/dist/editor/public/clipboard.d.ts.map +1 -1
- package/dist/editor/public/clipboard.js +2 -4
- package/dist/editor/public/clipboard.js.map +1 -1
- package/dist/editor/public/cursor.d.ts +29 -0
- package/dist/editor/public/cursor.d.ts.map +1 -0
- package/dist/editor/public/cursor.js +146 -0
- package/dist/editor/public/cursor.js.map +1 -0
- package/dist/editor/public/eraser.d.ts +55 -0
- package/dist/editor/public/eraser.d.ts.map +1 -0
- package/dist/editor/public/eraser.js +65 -0
- package/dist/editor/public/eraser.js.map +1 -0
- package/dist/editor/public/frame-name.d.ts +20 -0
- package/dist/editor/public/frame-name.d.ts.map +1 -0
- package/dist/editor/public/frame-name.js +54 -0
- package/dist/editor/public/frame-name.js.map +1 -0
- package/dist/editor/public/image-insert.d.ts +1 -1
- package/dist/editor/public/image-insert.d.ts.map +1 -1
- package/dist/editor/public/laser.d.ts +38 -0
- package/dist/editor/public/laser.d.ts.map +1 -0
- package/dist/editor/public/laser.js +42 -0
- package/dist/editor/public/laser.js.map +1 -0
- package/dist/editor/public/layers.d.ts.map +1 -1
- package/dist/editor/public/layers.js +2 -2
- package/dist/editor/public/layers.js.map +1 -1
- package/dist/editor/public/link.d.ts +11 -2
- package/dist/editor/public/link.d.ts.map +1 -1
- package/dist/editor/public/link.js +70 -2
- package/dist/editor/public/link.js.map +1 -1
- package/dist/editor/public/placement.d.ts +60 -2
- package/dist/editor/public/placement.d.ts.map +1 -1
- package/dist/editor/public/placement.js +193 -2
- package/dist/editor/public/placement.js.map +1 -1
- package/dist/editor/public/selection-ops.d.ts +35 -1
- package/dist/editor/public/selection-ops.d.ts.map +1 -1
- package/dist/editor/public/selection-ops.js +97 -2
- package/dist/editor/public/selection-ops.js.map +1 -1
- package/dist/editor/public/stroke-eraser-coverage.d.ts +37 -0
- package/dist/editor/public/stroke-eraser-coverage.d.ts.map +1 -0
- package/dist/editor/public/stroke-eraser-coverage.js +168 -0
- package/dist/editor/public/stroke-eraser-coverage.js.map +1 -0
- package/dist/editor/public/stroke-eraser.d.ts +50 -0
- package/dist/editor/public/stroke-eraser.d.ts.map +1 -0
- package/dist/editor/public/stroke-eraser.js +176 -0
- package/dist/editor/public/stroke-eraser.js.map +1 -0
- package/dist/editor/public/stroke-smoothing.d.ts +21 -0
- package/dist/editor/public/stroke-smoothing.d.ts.map +1 -0
- package/dist/editor/public/stroke-smoothing.js +37 -0
- package/dist/editor/public/stroke-smoothing.js.map +1 -0
- package/dist/editor/public/text-edit.d.ts +1 -10
- package/dist/editor/public/text-edit.d.ts.map +1 -1
- package/dist/editor/public/text-edit.js +2 -16
- package/dist/editor/public/text-edit.js.map +1 -1
- package/dist/editor/public/tool-ops.d.ts +130 -0
- package/dist/editor/public/tool-ops.d.ts.map +1 -0
- package/dist/editor/public/tool-ops.js +392 -0
- package/dist/editor/public/tool-ops.js.map +1 -0
- package/dist/editor/public/z-order.d.ts +1 -12
- package/dist/editor/public/z-order.d.ts.map +1 -1
- package/dist/editor/public/z-order.js +5 -5
- package/dist/editor/public/z-order.js.map +1 -1
- package/dist/editor/public/zoom-pan.d.ts +15 -4
- package/dist/editor/public/zoom-pan.d.ts.map +1 -1
- package/dist/editor/public/zoom-pan.js +39 -7
- package/dist/editor/public/zoom-pan.js.map +1 -1
- package/dist/editor/render-orchestrator.d.ts +177 -5
- package/dist/editor/render-orchestrator.d.ts.map +1 -1
- package/dist/editor/render-orchestrator.js +519 -358
- package/dist/editor/render-orchestrator.js.map +1 -1
- package/dist/editor/resize-helpers.d.ts +21 -5
- package/dist/editor/resize-helpers.d.ts.map +1 -1
- package/dist/editor/resize-helpers.js +59 -19
- package/dist/editor/resize-helpers.js.map +1 -1
- package/dist/editor/text-edit.d.ts +136 -0
- package/dist/editor/text-edit.d.ts.map +1 -0
- package/dist/editor/text-edit.js +328 -0
- package/dist/editor/text-edit.js.map +1 -0
- package/dist/editor/viewport-helpers.d.ts.map +1 -1
- package/dist/editor/viewport-helpers.js +3 -3
- package/dist/editor/viewport-helpers.js.map +1 -1
- package/dist/editor-events.d.ts +1 -1
- package/dist/editor-events.d.ts.map +1 -1
- package/dist/editor.d.ts +603 -346
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +1843 -1630
- package/dist/editor.js.map +1 -1
- package/dist/file-drop.d.ts.map +1 -1
- package/dist/file-drop.js +1 -2
- package/dist/file-drop.js.map +1 -1
- package/dist/frame-helpers.d.ts.map +1 -1
- package/dist/frame-helpers.js +5 -5
- package/dist/frame-helpers.js.map +1 -1
- package/dist/group-helpers.d.ts.map +1 -1
- package/dist/group-helpers.js +4 -4
- package/dist/group-helpers.js.map +1 -1
- package/dist/handle.d.ts +59 -6
- package/dist/handle.d.ts.map +1 -1
- package/dist/handle.js +99 -7
- package/dist/handle.js.map +1 -1
- package/dist/hotkeys-binding.d.ts +26 -0
- package/dist/hotkeys-binding.d.ts.map +1 -0
- package/dist/hotkeys-binding.js +35 -0
- package/dist/hotkeys-binding.js.map +1 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/link-selection.d.ts +3 -5
- package/dist/link-selection.d.ts.map +1 -1
- package/dist/link-selection.js +3 -12
- package/dist/link-selection.js.map +1 -1
- package/dist/machine.d.ts +27 -1
- package/dist/machine.d.ts.map +1 -1
- package/dist/machine.js +38 -0
- package/dist/machine.js.map +1 -1
- package/dist/modes.d.ts +15 -1
- package/dist/modes.d.ts.map +1 -1
- package/dist/modes.js.map +1 -1
- package/dist/overlay.d.ts +92 -6
- package/dist/overlay.d.ts.map +1 -1
- package/dist/overlay.js +582 -125
- package/dist/overlay.js.map +1 -1
- package/dist/search.d.ts +37 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +45 -0
- package/dist/search.js.map +1 -0
- package/dist/selection.d.ts.map +1 -1
- package/dist/selection.js +2 -10
- package/dist/selection.js.map +1 -1
- package/dist/util.d.ts +8 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +17 -0
- package/dist/util.js.map +1 -0
- package/package.json +7 -7
package/dist/editor.js
CHANGED
|
@@ -1,49 +1,62 @@
|
|
|
1
1
|
import { createActor } from "xstate";
|
|
2
2
|
import { createEmitter } from "@oh-just-another/events";
|
|
3
3
|
import { elementId as castElementId } from "@oh-just-another/types";
|
|
4
|
-
import { addElement, anchorSnapper, apply, buildSpatialIndex,
|
|
4
|
+
import { addElement, addLink, endpointElementId, anchorSnapper, apply, buildSpatialIndex, isElementHidden, isElementLocked, runAutoLayout, DEFAULT_LAYER_ID, routeElbowLink, routeElbowPreview, getLink, getLinkPath, getElement, getElementAt, getElementAtIndexed, getElementLocalBounds, localToWorld, worldToLocal, isFrame, isGroup, isText, isImage, isBrush, brushBodyColor, getElementWorldBounds, setTextMeasurer, getScreenToWorld, gridSnapper, outlineSnapper, SnapEngine, invert, updateLink, updateElement, isAnchorRef, isSnapToGridEnabled, resolveSnapSpacing, } from "@oh-just-another/scene";
|
|
5
5
|
import { layerId as castLayerId, } from "@oh-just-another/types";
|
|
6
|
-
import { bounds as B, matrix } from "@oh-just-another/math";
|
|
7
|
-
import {
|
|
6
|
+
import { bounds as B, matrix, vec2 } from "@oh-just-another/math";
|
|
7
|
+
import { onAnimationContentReady, setActiveRasterizer, setActiveTextShaper, ElementCache, } from "@oh-just-another/renderer-core";
|
|
8
8
|
import { History, } from "@oh-just-another/history";
|
|
9
|
-
import {
|
|
9
|
+
import { DEFAULT_LINK_ROUTING } from "./constants.js";
|
|
10
10
|
import { FileDropRegistry } from "./file-drop.js";
|
|
11
11
|
import { imageFileDropHandler, videoFileDropHandler } from "./built-in-handlers.js";
|
|
12
|
-
import { AnimationTick } from "./animation-tick.js";
|
|
13
12
|
import { computeDimElements as computeDimElementsHelper, isDescendantOfGroup as isDescendantOfGroupHelper, pickDrillTarget as pickDrillTargetHelper, promoteToGroupRoot as promoteToGroupRootHelper, topGroupAncestor as topGroupAncestorHelper, } from "./group-helpers.js";
|
|
14
13
|
import { assignFrameMembers as assignFrameMembersHelper, nextFrameName as nextFrameNameHelper, reconcileFrameMembership as reconcileFrameMembershipHelper, } from "./frame-helpers.js";
|
|
15
14
|
import { AutoCompactScheduler } from "./auto-compact.js";
|
|
16
15
|
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,
|
|
18
|
-
import { HANDLE_HIT_SLOP
|
|
19
|
-
import {
|
|
16
|
+
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, TOUCH_ANCHOR_START_HIT_SLOP, TOUCH_ANCHOR_DOT_CLICK_RADIUS, DOUBLE_CLICK_MS, DOUBLE_CLICK_TOLERANCE_PX, WHEEL_ZOOM_STEP, ROTATE_SNAP_RADIANS, CROP_HANDLE_HIT_RADIUS, FLOWCHART_MAX_SIBLINGS, ERASER_TRAIL_TTL_MS, } from "./constants.js";
|
|
17
|
+
import { HANDLE_HIT_SLOP } from "./handle.js";
|
|
18
|
+
import { req } from "./util.js";
|
|
20
19
|
import { interactionMachine, } from "./machine.js";
|
|
21
20
|
import { createEventCache, fanOutEvents, primeEventCache, } from "./editor/event-fanout.js";
|
|
21
|
+
import { AnimationController } from "./editor/animation.js";
|
|
22
22
|
import { GestureController } from "./editor/gesture-tx.js";
|
|
23
|
+
import { GifPlaybackController } from "./editor/gif-playback.js";
|
|
24
|
+
import * as animScene from "./editor/animation-scene.js";
|
|
25
|
+
import { computeSceneDirtyRect } from "./editor/dirty-rect.js";
|
|
23
26
|
import { LongPressController } from "./editor/long-press.js";
|
|
24
27
|
import { pickPressTarget } from "./editor/hit-test.js";
|
|
25
28
|
import { PinchController } from "./editor/pinch.js";
|
|
26
29
|
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";
|
|
30
|
+
import { computeGroupResizePatches, computeElementResize, computeRotatedElementResize, computeTextResize, } from "./editor/applies/resize.js";
|
|
28
31
|
import { bindPointerEvents as bindPointerEventsExternal } from "./editor/pointer-binding.js";
|
|
29
|
-
import {
|
|
32
|
+
import { InteractionState, } from "./editor/interaction-state.js";
|
|
33
|
+
import { beginBrushStroke as beginBrushStrokePure, commitBrushStroke as commitBrushStrokePure, extendBrushStroke as extendBrushStrokePure, smoothBrushPoints, brushStyleFromSettings, DEFAULT_BRUSH_SETTINGS, newBrushId, } from "./editor/public/brush.js";
|
|
34
|
+
import { beginEraseStroke as beginEraseStrokePure, sampleErase as sampleErasePure, computeEraseCommit, } from "./editor/public/eraser.js";
|
|
35
|
+
import { computeEraseFromMasks, computeStrokeErasePreviewFromMasks, markErasedIntervals, } from "./editor/public/stroke-eraser.js";
|
|
36
|
+
import { coveredLength } from "./editor/public/stroke-eraser-coverage.js";
|
|
37
|
+
import { beginLaserStroke as beginLaserStrokePure, extendLaserStroke as extendLaserStrokePure, pruneLaserStrokes, } from "./editor/public/laser.js";
|
|
30
38
|
import { copySelected as copySelectedPure, pasteFromClipboard, selectionFromPasted, } from "./editor/public/clipboard.js";
|
|
31
39
|
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";
|
|
40
|
+
import { computePan, computeResetZoom, computeSetGrid, computeViewportResize, computeZoomAt, computeZoomToFit, computeZoomToBounds, computeRevealBounds, } from "./editor/public/zoom-pan.js";
|
|
33
41
|
import { computeAddAnnotation, computeAddComment, computeRemoveAnnotation, computeRemoveComment, computeToggleAnnotationResolved, hitAnnotation as hitAnnotationPure, } from "./editor/public/annotations.js";
|
|
34
|
-
import {
|
|
42
|
+
import { frameHeaderAt as computeFrameHeaderAt, computeFrameNameCommit, } from "./editor/public/frame-name.js";
|
|
43
|
+
import { computeCursor } from "./editor/public/cursor.js";
|
|
35
44
|
import { compactLayerZOrderPatches, computeBringForward, computeBringToFront, computeSendBackward, computeSendToBack, } from "./editor/public/z-order.js";
|
|
36
45
|
import { computeArrangeAsGrid, computeArrangeAsStack, computeGroupSelected, computeUngroup, expandSelectionWithDescendants, newGroupElementId, pickFocusCycle, selectionRoots, } from "./editor/public/arrange-group.js";
|
|
37
46
|
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 {
|
|
47
|
+
import { computeDeleteSelection, computeDuplicateSelection, computeMoveSelectionBy, computeSelectAll, computeSelectAllLinks, computeAdjustFontSize, computeApplyTextRunStyle, computeSetSelection, computeUpdateStyle, computeUpdateTextProps, describeNudge as describeNudgePure, findClosestInDirection, selectionFromNewIds, } from "./editor/public/selection-ops.js";
|
|
48
|
+
import { computeSetLink, normalizeHref, safeHref, snapLinkEndpoint as snapLinkEndpointPure, } from "./editor/public/link.js";
|
|
49
|
+
import { beginPlacementState, buildElementAtCursor, buildTextElementAt, computePlacementCancel, computePlacementContainerDrop, computePlacementUpdate, computeLinkedElementFromAnchor, computeDuplicateInPlace, computeShapeAtLinkDrop, newElementIdAtCursor, previewClickCreate as previewClickCreatePure, } from "./editor/public/placement.js";
|
|
50
|
+
import { computeConvertType, computeCommitImageCrop, computeCropBodyPan, computeCropHandleDrag, computeSpawnConnectedNode, computeSpawnConnectedNodes, cropFullImageLocalRect, cropHandleWorldPoints, CROP_HANDLES, FULL_CROP, pickColorAt, } from "./editor/public/tool-ops.js";
|
|
51
|
+
import { renderEditor, } from "./editor/render-orchestrator.js";
|
|
52
|
+
import { TextEditController } from "./editor/text-edit.js";
|
|
53
|
+
import { LinkHandleDragController } from "./editor/link-handle-drag.js";
|
|
42
54
|
import { combinedSelectionBounds as combinedSelectionBoundsPure, computeViewportWorld as computeViewportWorldPure, groupChildrenUnion as groupChildrenUnionPure, } from "./editor/viewport-helpers.js";
|
|
43
55
|
import { computeHiddenElements as computeHiddenElementsPure } from "./editor/shape-filters.js";
|
|
44
56
|
import { selectByBounds as selectByBoundsPure, selectByBoundsLive as selectByBoundsLivePure, selectLinksByBoundsLive as selectLinksByBoundsLivePure, } from "./editor/applies/selection.js";
|
|
45
|
-
import {
|
|
46
|
-
import { computeAnnotationMovePatch, computeGroupMovePatches, computeElementMovePatch, } from "./editor/applies/move.js";
|
|
57
|
+
import { computeLinkPreviewEndpoints, elbowSignature } from "./editor/applies/edge.js";
|
|
58
|
+
import { computeAnnotationMovePatch, computeGroupMovePatches, computeElementMovePatch, constrainDeltaToAxis, } from "./editor/applies/move.js";
|
|
59
|
+
import { computeAlignPatches, computeDistributePatches, computeFlipPatches, computeRotatePatches, selectionCenter, } from "./editor/applies/arrange.js";
|
|
47
60
|
import { computeMovingLinkPatches, computeMovingLinkForNudge } from "./editor/applies/link-move.js";
|
|
48
61
|
import { computeCreateLink, computeCreateElement, newLinkId, newElementId, } from "./editor/applies/create.js";
|
|
49
62
|
import { snapCreateBounds, snapGroupDelta, snapMoveDelta, snapResizeDelta, } from "./editor/applies/snap-grid.js";
|
|
@@ -51,36 +64,27 @@ import {} from "./overlay.js";
|
|
|
51
64
|
import * as Selection from "./selection.js";
|
|
52
65
|
import * as LinkSelection from "./link-selection.js";
|
|
53
66
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
67
|
+
* Machine-emit types dropped while the editor is in read-only mode — every
|
|
68
|
+
* scene mutation the interaction machine can produce. Selection / lasso /
|
|
69
|
+
* preview-clear emits are absent so a viewer keeps click + marquee select.
|
|
56
70
|
*/
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
};
|
|
71
|
+
const READ_ONLY_BLOCKED_EMITS = new Set([
|
|
72
|
+
"MOVE_SHAPE",
|
|
73
|
+
"RESIZE_GROUP",
|
|
74
|
+
"RESIZE_SHAPE",
|
|
75
|
+
"ROTATE",
|
|
76
|
+
"CREATE_SHAPE",
|
|
77
|
+
"CREATE_EDGE",
|
|
78
|
+
"MOVE_ANNOTATION",
|
|
79
|
+
"COMMIT_ANNOTATION_DRAG",
|
|
80
|
+
"UPDATE_EDGE_ENDPOINT",
|
|
81
|
+
"UPDATE_EDGE_ENDPOINT_PREVIEW",
|
|
82
|
+
"DRAW_EDGE_PREVIEW",
|
|
83
|
+
]);
|
|
84
|
+
/** Shared empty id set — returned by `pendingErase` when no eraser stroke runs. */
|
|
85
|
+
const EMPTY_ELEMENT_SET = Object.freeze(new Set());
|
|
86
|
+
/** Monotonic wall-clock in ms, matching the domain used by the overlay fade. */
|
|
87
|
+
const nowMs = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
84
88
|
export class Editor {
|
|
85
89
|
host;
|
|
86
90
|
mainTarget;
|
|
@@ -95,10 +99,13 @@ export class Editor {
|
|
|
95
99
|
*/
|
|
96
100
|
debugHitZones = false;
|
|
97
101
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
102
|
+
* Read-only / view mode. When true the pointer paths that create,
|
|
103
|
+
* move, resize, rotate or delete are gated (pan / zoom / select stay
|
|
104
|
+
* live) and the action registry only runs actions flagged
|
|
105
|
+
* `viewMode`. View-only — never persisted or recorded in history.
|
|
106
|
+
* Read via {@link readOnly}; flip via {@link setReadOnly}.
|
|
100
107
|
*/
|
|
101
|
-
|
|
108
|
+
_readOnly = false;
|
|
102
109
|
actor;
|
|
103
110
|
listeners = new Set();
|
|
104
111
|
/**
|
|
@@ -113,7 +120,7 @@ export class Editor {
|
|
|
113
120
|
events = createEmitter();
|
|
114
121
|
/**
|
|
115
122
|
* Last-emitted snapshot of every observable slice. Used by
|
|
116
|
-
* `fanOutEvents`
|
|
123
|
+
* `fanOutEvents` to decide which
|
|
117
124
|
* typed events to fire on each `notify()` — only the slices
|
|
118
125
|
* whose identity changed since the previous notify get an event.
|
|
119
126
|
*/
|
|
@@ -122,45 +129,55 @@ export class Editor {
|
|
|
122
129
|
_scene;
|
|
123
130
|
_selection = Selection.EMPTY;
|
|
124
131
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
132
|
+
* Ephemeral interaction / gesture state (previews, gesture origins,
|
|
133
|
+
* transient modifiers). Single source of truth for the short-lived fields
|
|
134
|
+
* the pointer handlers, render orchestrator and container-ops read/write
|
|
135
|
+
* while a gesture is in flight. The public fields below delegate to it so
|
|
136
|
+
* external writers keep referencing `editor.<field>` unchanged.
|
|
129
137
|
*/
|
|
130
|
-
|
|
138
|
+
interaction = new InteractionState();
|
|
139
|
+
/** Snapshot of an in-progress annotation-pin drag. */
|
|
140
|
+
get annotationDrag() {
|
|
141
|
+
return this.interaction.annotationDrag;
|
|
142
|
+
}
|
|
143
|
+
set annotationDrag(v) {
|
|
144
|
+
this.interaction.annotationDrag = v;
|
|
145
|
+
}
|
|
131
146
|
/** Live preview while drawing a new shape; null when not drawing. */
|
|
132
|
-
drawingPreview
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
147
|
+
get drawingPreview() {
|
|
148
|
+
return this.interaction.drawingPreview;
|
|
149
|
+
}
|
|
150
|
+
set drawingPreview(v) {
|
|
151
|
+
this.interaction.drawingPreview = v;
|
|
152
|
+
}
|
|
153
|
+
/** Live preview of an edge being drawn. */
|
|
154
|
+
get edgePreview() {
|
|
155
|
+
return this.interaction.edgePreview;
|
|
156
|
+
}
|
|
157
|
+
set edgePreview(v) {
|
|
158
|
+
this.interaction.edgePreview = v;
|
|
159
|
+
}
|
|
160
|
+
/** Active "drag a link from a start-anchor" gesture. */
|
|
161
|
+
get linkDragFromAnchor() {
|
|
162
|
+
return this.interaction.linkDragFromAnchor;
|
|
163
|
+
}
|
|
164
|
+
set linkDragFromAnchor(v) {
|
|
165
|
+
this.interaction.linkDragFromAnchor = v;
|
|
166
|
+
}
|
|
167
|
+
/** Element hovered while draw-edge mode is active (drives the port overlay). */
|
|
168
|
+
get hoveredLinkTarget() {
|
|
169
|
+
return this.interaction.hoveredLinkTarget;
|
|
170
|
+
}
|
|
171
|
+
set hoveredLinkTarget(v) {
|
|
172
|
+
this.interaction.hoveredLinkTarget = v;
|
|
173
|
+
}
|
|
174
|
+
/** Last idle cursor position (world) in select mode — grows the nearest dot. */
|
|
175
|
+
get hoverCursorWorld() {
|
|
176
|
+
return this.interaction.hoverCursorWorld;
|
|
177
|
+
}
|
|
178
|
+
set hoverCursorWorld(v) {
|
|
179
|
+
this.interaction.hoverCursorWorld = v;
|
|
180
|
+
}
|
|
164
181
|
/**
|
|
165
182
|
* Currently selected links (connectors). Links are first-class members
|
|
166
183
|
* of the selection: they coexist with selected elements, join Cmd+A and
|
|
@@ -186,62 +203,67 @@ export class Editor {
|
|
|
186
203
|
/**
|
|
187
204
|
* Mid-drag preview state when the user is dragging an edge endpoint.
|
|
188
205
|
* Drawn as an overlay line + handle dot so the user sees the target.
|
|
206
|
+
* State lives in `LinkHandleDragController`; this is a delegate.
|
|
189
207
|
*/
|
|
190
|
-
linkEndpointDrag
|
|
208
|
+
get linkEndpointDrag() {
|
|
209
|
+
return this.linkHandles.endpointDrag;
|
|
210
|
+
}
|
|
191
211
|
/**
|
|
192
212
|
* Host-managed waypoint (bend-point) drag of the selected link. `index`
|
|
193
213
|
* is the position in `edge.waypoints`. `pendingInsert` means the gesture
|
|
194
214
|
* began on a segment midpoint and will splice a new waypoint on the
|
|
195
215
|
* first move (so a no-move click adds nothing). Live-mutated through the
|
|
196
216
|
* gesture transaction → one undo step per drag.
|
|
217
|
+
* State lives in `LinkHandleDragController`; this is a delegate.
|
|
197
218
|
*/
|
|
198
|
-
linkWaypointDrag
|
|
219
|
+
get linkWaypointDrag() {
|
|
220
|
+
return this.linkHandles.waypointDrag;
|
|
221
|
+
}
|
|
199
222
|
/**
|
|
200
|
-
* Host-managed elbow segment drag. `
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
223
|
+
* Host-managed elbow segment drag. `axis` is the segment's orientation.
|
|
224
|
+
* Dragging pins the segment's perpendicular coordinate into
|
|
225
|
+
* `Link.fixedSegments`; the reroute pass re-flows the rest. One undo
|
|
226
|
+
* step via the gesture tx.
|
|
227
|
+
* State lives in `LinkHandleDragController`; this is a delegate.
|
|
204
228
|
*/
|
|
205
|
-
linkSegmentDrag
|
|
229
|
+
get linkSegmentDrag() {
|
|
230
|
+
return this.linkHandles.segmentDrag;
|
|
231
|
+
}
|
|
206
232
|
/** Live lasso bounds during a rubber-band select gesture. */
|
|
207
|
-
lassoPreview
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
* so it never compounds across pointermove ticks. Cleared on gesture
|
|
242
|
-
* end (commit / cancel).
|
|
243
|
-
*/
|
|
244
|
-
_resizeOriginElement = null;
|
|
233
|
+
get lassoPreview() {
|
|
234
|
+
return this.interaction.lassoPreview;
|
|
235
|
+
}
|
|
236
|
+
set lassoPreview(v) {
|
|
237
|
+
this.interaction.lassoPreview = v;
|
|
238
|
+
}
|
|
239
|
+
/** Snapshot of every selected shape's `position` at press-down (multi-drag). */
|
|
240
|
+
get groupMoveOrigin() {
|
|
241
|
+
return this.interaction.groupMoveOrigin;
|
|
242
|
+
}
|
|
243
|
+
set groupMoveOrigin(v) {
|
|
244
|
+
this.interaction.groupMoveOrigin = v;
|
|
245
|
+
}
|
|
246
|
+
/** Press-time snapshot of connectors that follow a multi-element drag rigidly. */
|
|
247
|
+
get groupLinkMoveOrigin() {
|
|
248
|
+
return this.interaction.groupLinkMoveOrigin;
|
|
249
|
+
}
|
|
250
|
+
set groupLinkMoveOrigin(v) {
|
|
251
|
+
this.interaction.groupLinkMoveOrigin = v;
|
|
252
|
+
}
|
|
253
|
+
/** Per-shape snapshot for a group-resize gesture. */
|
|
254
|
+
get groupResizeOrigin() {
|
|
255
|
+
return this.interaction.groupResizeOrigin;
|
|
256
|
+
}
|
|
257
|
+
set groupResizeOrigin(v) {
|
|
258
|
+
this.interaction.groupResizeOrigin = v;
|
|
259
|
+
}
|
|
260
|
+
/** Press-time snapshot for a rotate gesture. */
|
|
261
|
+
get rotateGestureOrigin() {
|
|
262
|
+
return this.interaction.rotateGestureOrigin;
|
|
263
|
+
}
|
|
264
|
+
set rotateGestureOrigin(v) {
|
|
265
|
+
this.interaction.rotateGestureOrigin = v;
|
|
266
|
+
}
|
|
245
267
|
/**
|
|
246
268
|
* Active layer — new shapes created via `addElement` / `applyCreate` land
|
|
247
269
|
* here when their input doesn't specify a `layerId`. Defaults to the
|
|
@@ -264,12 +286,11 @@ export class Editor {
|
|
|
264
286
|
/** Snap threshold in world units. */
|
|
265
287
|
snapThreshold = DEFAULT_SNAP_THRESHOLD;
|
|
266
288
|
/**
|
|
267
|
-
*
|
|
268
|
-
* (
|
|
269
|
-
*
|
|
270
|
-
* move / resize / create wrappers; never persisted.
|
|
289
|
+
* In-editor style memory for copy-style / paste-style. Holds the visual
|
|
290
|
+
* `style` (fill / stroke / dash / …) captured from a shape; `null` until a
|
|
291
|
+
* copy happens. Not the OS clipboard — a lightweight per-editor buffer.
|
|
271
292
|
*/
|
|
272
|
-
|
|
293
|
+
styleClipboard = null;
|
|
273
294
|
/**
|
|
274
295
|
* Persistent world-bounds cache shared with `renderScene` for viewport
|
|
275
296
|
* culling. Object-identity keyed — invalidates automatically whenever
|
|
@@ -291,36 +312,64 @@ export class Editor {
|
|
|
291
312
|
* escape, click outside the group's descendants, or `cancelInteraction`.
|
|
292
313
|
*/
|
|
293
314
|
_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
315
|
/**
|
|
312
316
|
* In-progress brush stroke. Hosts push points via
|
|
313
317
|
* `extendBrushStroke`; the overlay reads it through
|
|
314
318
|
* `pendingBrushStroke` to draw a live preview.
|
|
315
319
|
*/
|
|
316
|
-
brushStroke
|
|
320
|
+
get brushStroke() {
|
|
321
|
+
return this.interaction.brushStroke;
|
|
322
|
+
}
|
|
323
|
+
set brushStroke(v) {
|
|
324
|
+
this.interaction.brushStroke = v;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The in-progress brush stroke with its captured vertices Catmull-Rom-smoothed
|
|
328
|
+
* for the LIVE overlay preview — the SAME resampler `commitBrushStroke` applies
|
|
329
|
+
* on release (see {@link smoothBrushPoints}), so the stroke reads smooth while
|
|
330
|
+
* drawn instead of snapping from an angular polyline to a curve on release. A
|
|
331
|
+
* fresh object each call (points diverge from `brushStroke.points`), so the
|
|
332
|
+
* overlay memo repaints every move that grows the stroke.
|
|
333
|
+
*/
|
|
334
|
+
get brushPreviewStroke() {
|
|
335
|
+
const s = this.interaction.brushStroke;
|
|
336
|
+
if (!s)
|
|
337
|
+
return null;
|
|
338
|
+
const style = brushStyleFromSettings(this._brushSettings);
|
|
339
|
+
return {
|
|
340
|
+
origin: s.origin,
|
|
341
|
+
points: smoothBrushPoints(s.points),
|
|
342
|
+
fill: brushBodyColor(style),
|
|
343
|
+
opacity: style.opacity ?? 1,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/** In-progress eraser stroke (pending-delete set), or null between strokes. */
|
|
347
|
+
get eraseStroke() {
|
|
348
|
+
return this.interaction.eraseStroke;
|
|
349
|
+
}
|
|
350
|
+
set eraseStroke(v) {
|
|
351
|
+
this.interaction.eraseStroke = v;
|
|
352
|
+
}
|
|
353
|
+
/** Ids swept by the current eraser stroke — previewed dimmed, deleted on release. */
|
|
354
|
+
get pendingErase() {
|
|
355
|
+
return this.interaction.eraseStroke?.pending ?? EMPTY_ELEMENT_SET;
|
|
356
|
+
}
|
|
357
|
+
/** Live laser-pointer trails (ephemeral, fading). Empty when none active. */
|
|
358
|
+
get laserStrokes() {
|
|
359
|
+
return this.interaction.laserStrokes;
|
|
360
|
+
}
|
|
317
361
|
/**
|
|
318
362
|
* Last world-space pointer position observed by the host's onMove
|
|
319
363
|
* handler. `paste()` uses it as the default drop target so a fresh
|
|
320
364
|
* paste lands under the cursor instead of overlapping the originals.
|
|
321
365
|
* `null` until the pointer first enters the host.
|
|
322
366
|
*/
|
|
323
|
-
lastPointerWorld
|
|
367
|
+
get lastPointerWorld() {
|
|
368
|
+
return this.interaction.lastPointerWorld;
|
|
369
|
+
}
|
|
370
|
+
set lastPointerWorld(v) {
|
|
371
|
+
this.interaction.lastPointerWorld = v;
|
|
372
|
+
}
|
|
324
373
|
/** Host-registered custom cursor images per role (see `setCursorOverride`). */
|
|
325
374
|
cursorOverrides = new Map();
|
|
326
375
|
/**
|
|
@@ -339,11 +388,27 @@ export class Editor {
|
|
|
339
388
|
* pass would never visibly apply.
|
|
340
389
|
*/
|
|
341
390
|
lastRenderedEnteredGroup = null;
|
|
391
|
+
/**
|
|
392
|
+
* Whether the last paint had eraser-dim active — paired with
|
|
393
|
+
* `lastRenderedScene` like {@link lastRenderedEnteredGroup}. When an eraser
|
|
394
|
+
* stroke ENDS by cancel (Esc), the marked shapes un-dim without a scene
|
|
395
|
+
* change, so the dirty-rect diff is empty and the dim would linger on screen;
|
|
396
|
+
* this lets that active→inactive transition force one full repaint.
|
|
397
|
+
*/
|
|
398
|
+
lastRenderedEraseActive = false;
|
|
399
|
+
/**
|
|
400
|
+
* Set whenever an eraser move actually CHANGES the marked / cut set (a new
|
|
401
|
+
* shape marked, un-marked, or a brush point cut). Gates the forced full
|
|
402
|
+
* repaint during erasing: only the frames that change the preview repaint the
|
|
403
|
+
* whole scene; a slowly-moving or stopped cursor over already-covered area
|
|
404
|
+
* skips the expensive main pass (only the overlay cursor / trail refresh).
|
|
405
|
+
* Cleared after each paint.
|
|
406
|
+
*/
|
|
407
|
+
eraseDirty = false;
|
|
342
408
|
/**
|
|
343
409
|
* Fractional-order compaction scheduler (microtask-coalesced).
|
|
344
410
|
* Triggered from every `notify()`; only does real work when at
|
|
345
411
|
* least one shape/edge order string crossed AUTO_COMPACT_THRESHOLD.
|
|
346
|
-
* See `./auto-compact.ts` for the extracted logic.
|
|
347
412
|
*/
|
|
348
413
|
autoCompactScheduler = new AutoCompactScheduler({
|
|
349
414
|
getScene: () => this._scene,
|
|
@@ -353,8 +418,7 @@ export class Editor {
|
|
|
353
418
|
});
|
|
354
419
|
/**
|
|
355
420
|
* Auto-layout scheduler — microtask-coalesced re-run of every
|
|
356
|
-
* shape carrying `metadata.autoLayout`.
|
|
357
|
-
* `./auto-layout-scheduler.ts` for the extracted logic.
|
|
421
|
+
* shape carrying `metadata.autoLayout`.
|
|
358
422
|
*/
|
|
359
423
|
autoLayoutScheduler = new AutoLayoutScheduler({
|
|
360
424
|
getScene: () => this._scene,
|
|
@@ -385,7 +449,12 @@ export class Editor {
|
|
|
385
449
|
* gestures, set in onDown when press lands on a shape and cleared
|
|
386
450
|
* in onUp / cancel.
|
|
387
451
|
*/
|
|
388
|
-
dragElementId
|
|
452
|
+
get dragElementId() {
|
|
453
|
+
return this.interaction.dragElementId;
|
|
454
|
+
}
|
|
455
|
+
set dragElementId(v) {
|
|
456
|
+
this.interaction.dragElementId = v;
|
|
457
|
+
}
|
|
389
458
|
/**
|
|
390
459
|
* Element that the current press added to the selection additively
|
|
391
460
|
* (shift / meta click on an unselected shape). The press promotes it
|
|
@@ -393,14 +462,24 @@ export class Editor {
|
|
|
393
462
|
* otherwise `SELECT_TOGGLE` it straight back off, so it consults this
|
|
394
463
|
* to skip that redundant toggle. Reset at every press-down.
|
|
395
464
|
*/
|
|
396
|
-
additivePressAdded
|
|
465
|
+
get additivePressAdded() {
|
|
466
|
+
return this.interaction.additivePressAdded;
|
|
467
|
+
}
|
|
468
|
+
set additivePressAdded(v) {
|
|
469
|
+
this.interaction.additivePressAdded = v;
|
|
470
|
+
}
|
|
397
471
|
/**
|
|
398
472
|
* Live container highlight: the container shape the dragged item is
|
|
399
473
|
* currently hovering over. Drawn by the overlay as a dashed
|
|
400
474
|
* accent rect on the container's drop-zone so the user sees where the
|
|
401
475
|
* shape will land after release.
|
|
402
476
|
*/
|
|
403
|
-
containerHover
|
|
477
|
+
get containerHover() {
|
|
478
|
+
return this.interaction.containerHover;
|
|
479
|
+
}
|
|
480
|
+
set containerHover(v) {
|
|
481
|
+
this.interaction.containerHover = v;
|
|
482
|
+
}
|
|
404
483
|
/**
|
|
405
484
|
* Remote peer cursors / selections, pushed in by the host (typically
|
|
406
485
|
* a `bindAwareness(editor, awareness)` helper in `@collab`). The
|
|
@@ -420,7 +499,9 @@ export class Editor {
|
|
|
420
499
|
* two or more entries we enter a pinch / pan gesture and bypass the
|
|
421
500
|
* interaction machine — `pinchOrigin` holds the baseline.
|
|
422
501
|
*/
|
|
423
|
-
activePointers
|
|
502
|
+
get activePointers() {
|
|
503
|
+
return this.interaction.activePointers;
|
|
504
|
+
}
|
|
424
505
|
/**
|
|
425
506
|
* One-finger-pan candidate: set at pointer-down when a TOUCH press lands
|
|
426
507
|
* on empty canvas in select mode. A tap (no movement) still falls through
|
|
@@ -428,11 +509,16 @@ export class Editor {
|
|
|
428
509
|
* this to a real pan instead of a marquee lasso (mobile convention).
|
|
429
510
|
* Screen-space origin point.
|
|
430
511
|
*/
|
|
431
|
-
touchPanCandidate
|
|
432
|
-
|
|
433
|
-
|
|
512
|
+
get touchPanCandidate() {
|
|
513
|
+
return this.interaction.touchPanCandidate;
|
|
514
|
+
}
|
|
515
|
+
set touchPanCandidate(v) {
|
|
516
|
+
this.interaction.touchPanCandidate = v;
|
|
517
|
+
}
|
|
518
|
+
// Pinch gesture state lives in PinchController; `pinch.isActive()`
|
|
519
|
+
// reports whether a two-finger gesture is in flight.
|
|
434
520
|
pinch;
|
|
435
|
-
/** Bridge for
|
|
521
|
+
/** Bridge for the container-ops helpers. Built lazily in constructor. */
|
|
436
522
|
containerOpsRef;
|
|
437
523
|
/**
|
|
438
524
|
* Space-bar held → next pointer drag pans the canvas instead of
|
|
@@ -440,7 +526,12 @@ export class Editor {
|
|
|
440
526
|
* "grab" / "grabbing". Wires a window-level keydown/keyup listener
|
|
441
527
|
* in `bindPointerEvents`.
|
|
442
528
|
*/
|
|
443
|
-
spaceHeld
|
|
529
|
+
get spaceHeld() {
|
|
530
|
+
return this.interaction.spaceHeld;
|
|
531
|
+
}
|
|
532
|
+
set spaceHeld(v) {
|
|
533
|
+
this.interaction.spaceHeld = v;
|
|
534
|
+
}
|
|
444
535
|
/**
|
|
445
536
|
* Host-supplied tile compositor — when set (via
|
|
446
537
|
* `EditorOptions.useTileCache` + `tileCompose`), the per-frame
|
|
@@ -487,23 +578,33 @@ export class Editor {
|
|
|
487
578
|
* only treat right-click releases as potential context-menu
|
|
488
579
|
* triggers (Space + left-drag never opens a menu).
|
|
489
580
|
*/
|
|
490
|
-
panGesture
|
|
581
|
+
get panGesture() {
|
|
582
|
+
return this.interaction.panGesture;
|
|
583
|
+
}
|
|
584
|
+
set panGesture(v) {
|
|
585
|
+
this.interaction.panGesture = v;
|
|
586
|
+
}
|
|
491
587
|
/**
|
|
492
588
|
* Set on a right-click pointerdown so the upcoming native
|
|
493
589
|
* `contextmenu` event can be unconditionally preventDefault'ed
|
|
494
590
|
* (the gesture decides whether to fire the menu manually on
|
|
495
591
|
* pointerup based on whether the user dragged).
|
|
496
592
|
*/
|
|
497
|
-
suppressNextContextMenu
|
|
593
|
+
get suppressNextContextMenu() {
|
|
594
|
+
return this.interaction.suppressNextContextMenu;
|
|
595
|
+
}
|
|
596
|
+
set suppressNextContextMenu(v) {
|
|
597
|
+
this.interaction.suppressNextContextMenu = v;
|
|
598
|
+
}
|
|
498
599
|
/**
|
|
499
600
|
* Long-press tracking. Starts on `pointerdown`; cancelled on
|
|
500
601
|
* `pointermove > LONG_PRESS_MAX_MOVEMENT_PX` or `pointerup` before
|
|
501
602
|
* the timer fires. Hosts subscribe via `onLongPress` to surface a
|
|
502
603
|
* context menu (mobile alternative to right-click).
|
|
503
604
|
*/
|
|
504
|
-
// Long-press timer + origin live in LongPressController
|
|
505
|
-
//
|
|
506
|
-
//
|
|
605
|
+
// Long-press timer + origin live in LongPressController. The Set of
|
|
606
|
+
// subscribers stays here because `onLongPress` is part of the public
|
|
607
|
+
// Editor API.
|
|
507
608
|
longPress;
|
|
508
609
|
longPressListeners = new Set();
|
|
509
610
|
/**
|
|
@@ -538,11 +639,22 @@ export class Editor {
|
|
|
538
639
|
/**
|
|
539
640
|
* Wraps gesture lifecycle (transaction open/commit/cancel +
|
|
540
641
|
* post-create mode revert) so editor.ts doesn't carry the bodies.
|
|
541
|
-
*
|
|
542
|
-
* controller calls back through the narrow `GestureRef` bridge
|
|
642
|
+
* The controller calls back through the narrow `GestureRef` bridge
|
|
543
643
|
* built lazily below.
|
|
544
644
|
*/
|
|
545
645
|
gestures;
|
|
646
|
+
/**
|
|
647
|
+
* Owns the inline text-edit session (edited shape, pending creation,
|
|
648
|
+
* origin snapshot, live selection, drag anchor and caret blink).
|
|
649
|
+
* Editor keeps thin delegate wrappers so the public API is unchanged.
|
|
650
|
+
*/
|
|
651
|
+
textEdit;
|
|
652
|
+
/**
|
|
653
|
+
* Owns the link edit-handle drags (waypoint / segment / endpoint) and
|
|
654
|
+
* the handle double-click detector. Editor keeps thin delegate
|
|
655
|
+
* wrappers so the public API is unchanged.
|
|
656
|
+
*/
|
|
657
|
+
linkHandles;
|
|
546
658
|
constructor(options) {
|
|
547
659
|
this.host = options.host;
|
|
548
660
|
this.mainTarget = options.mainTarget;
|
|
@@ -553,6 +665,55 @@ export class Editor {
|
|
|
553
665
|
this._history = isHistoryProvider(options.history)
|
|
554
666
|
? options.history
|
|
555
667
|
: new History(options.history ?? {});
|
|
668
|
+
this.tileComposeFn =
|
|
669
|
+
options.useTileCache === true && options.tileCompose ? options.tileCompose : null;
|
|
670
|
+
this.initControllers();
|
|
671
|
+
this.initGlobalHooks(options);
|
|
672
|
+
this.initInputMode(options);
|
|
673
|
+
this.initActor(options);
|
|
674
|
+
this.unbind = this.bindPointerEvents();
|
|
675
|
+
// Pause animation playback when the tab / window is hidden (browsers
|
|
676
|
+
// throttle rAF to ~1fps in background but don't stop it; an explicit
|
|
677
|
+
// stop saves the decode + render entirely). Resume when visible again,
|
|
678
|
+
// viewport permitting.
|
|
679
|
+
this.animation.attach();
|
|
680
|
+
// Restore GIF/video bytes onto animated image shapes loaded from
|
|
681
|
+
// an initial scene (e.g. localStorage), then arm the tick so the
|
|
682
|
+
// animation plays from first paint.
|
|
683
|
+
animScene.rehydrateAnimatedImages(this);
|
|
684
|
+
// Rebuild live handles for static images restored from storage — their
|
|
685
|
+
// `metadata.image` didn't survive serialisation and `src` is a dead
|
|
686
|
+
// `blob:` URL, so decode the bytes back from `Scene.files`. Async;
|
|
687
|
+
// repaints itself when the decode lands.
|
|
688
|
+
void animScene.rehydrateStaticImages(this);
|
|
689
|
+
this.maybeAnimate();
|
|
690
|
+
// An animated adapter (GIF) decodes asynchronously; when a decode
|
|
691
|
+
// completes it nudges us here. Re-render so a PAUSED animated shape
|
|
692
|
+
// (reduced-motion / auto-stopped / frozen) — which has no tick to
|
|
693
|
+
// pick the frames up — paints its decoded frame after reload.
|
|
694
|
+
this.animationContentOff = onAnimationContentReady(() => {
|
|
695
|
+
this.scheduleRender();
|
|
696
|
+
});
|
|
697
|
+
// First paint — synchronous so the canvas isn't blank for one
|
|
698
|
+
// frame on mount. Hosts that mount + immediately read the
|
|
699
|
+
// bitmap also get a consistent first frame.
|
|
700
|
+
this.forceRender();
|
|
701
|
+
// Prime the typed-event cache with the editor's initial state so
|
|
702
|
+
// the *first* user-driven update only emits on a real flip.
|
|
703
|
+
// Without this, an `editor.on("mode", fn)` listener installed
|
|
704
|
+
// before any change would fire on the very next `setMode(current)`
|
|
705
|
+
// call because every cached slice would still be `null`.
|
|
706
|
+
primeEventCache(this.eventCache, this.observableSnapshot());
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Build the interaction controllers (gestures, text edit, link-handle
|
|
710
|
+
* drag, long-press, pinch) and the container-ops bridge. Each wires to a
|
|
711
|
+
* narrow getter/setter surface over the editor's mutable fields, so the
|
|
712
|
+
* controllers live in their own modules without importing Editor. The
|
|
713
|
+
* getters/setters in the object literals rebind `this`, so a single
|
|
714
|
+
* `self` alias captures the Editor reference for all of them.
|
|
715
|
+
*/
|
|
716
|
+
initControllers() {
|
|
556
717
|
// Build the gesture controller against a narrow getter/setter
|
|
557
718
|
// bridge to the editor's mutable state. The bridge is a thin
|
|
558
719
|
// adapter — keeps `gestureTx`/`dragElementId` etc. as `private`
|
|
@@ -614,8 +775,98 @@ export class Editor {
|
|
|
614
775
|
self.notify();
|
|
615
776
|
},
|
|
616
777
|
});
|
|
617
|
-
|
|
618
|
-
|
|
778
|
+
// Same bridge pattern for the text-edit controller: scene access goes
|
|
779
|
+
// through get/set so live edits replace `_scene` without history.
|
|
780
|
+
this.textEdit = new TextEditController({
|
|
781
|
+
get scene() {
|
|
782
|
+
return self._scene;
|
|
783
|
+
},
|
|
784
|
+
set scene(s) {
|
|
785
|
+
self._scene = s;
|
|
786
|
+
},
|
|
787
|
+
pushHistory: (patch) => {
|
|
788
|
+
self._history.push(patch);
|
|
789
|
+
},
|
|
790
|
+
notify: () => {
|
|
791
|
+
self.notify();
|
|
792
|
+
},
|
|
793
|
+
isLayerLocked: (id) => self.isLayerLocked(id),
|
|
794
|
+
clearSelectionFor: (id) => {
|
|
795
|
+
if (self._selection.has(id))
|
|
796
|
+
self._selection = Selection.EMPTY;
|
|
797
|
+
},
|
|
798
|
+
mainTarget: this.mainTarget,
|
|
799
|
+
});
|
|
800
|
+
// Same bridge pattern for the link handle-drag controller.
|
|
801
|
+
this.linkHandles = new LinkHandleDragController({
|
|
802
|
+
get scene() {
|
|
803
|
+
return self._scene;
|
|
804
|
+
},
|
|
805
|
+
set scene(s) {
|
|
806
|
+
self._scene = s;
|
|
807
|
+
},
|
|
808
|
+
pushHistory: (patch) => {
|
|
809
|
+
self._history.push(patch);
|
|
810
|
+
},
|
|
811
|
+
recordGesturePatch: (patch) => {
|
|
812
|
+
self.recordGesturePatch(patch);
|
|
813
|
+
},
|
|
814
|
+
commitGesture: () => {
|
|
815
|
+
self.commitGesture();
|
|
816
|
+
},
|
|
817
|
+
cancelGesture: () => {
|
|
818
|
+
self.cancelGesture();
|
|
819
|
+
},
|
|
820
|
+
hasGestureTx: () => self.gestureTx !== null,
|
|
821
|
+
notify: () => {
|
|
822
|
+
self.notify();
|
|
823
|
+
},
|
|
824
|
+
linkAttachTargetAt: (worldPoint) => self.linkAttachTargetAt(worldPoint),
|
|
825
|
+
snapLinkEndpoint: (targetId, worldPoint) => self.snapLinkEndpoint(targetId, worldPoint),
|
|
826
|
+
updateHoveredLinkTarget: (worldPoint) => {
|
|
827
|
+
self.updateHoveredLinkTarget(worldPoint);
|
|
828
|
+
},
|
|
829
|
+
clearHoveredLinkTarget: () => {
|
|
830
|
+
self.hoveredLinkTarget = null;
|
|
831
|
+
},
|
|
832
|
+
});
|
|
833
|
+
// Long-press controller — fired on touch-hold; fans out to
|
|
834
|
+
// host-registered listeners (mobile alt to right-click).
|
|
835
|
+
this.longPress = new LongPressController((p) => this.screenToWorld(p), (payload) => {
|
|
836
|
+
for (const fn of this.longPressListeners)
|
|
837
|
+
fn(payload);
|
|
838
|
+
});
|
|
839
|
+
// Pinch gesture controller — two-finger pan + zoom. Hooks into
|
|
840
|
+
// the editor's own zoomAt / panBy / screenToWorld.
|
|
841
|
+
this.pinch = new PinchController((p) => this.screenToWorld(p), (factor, anchorWorld) => {
|
|
842
|
+
this.zoomAt(factor, anchorWorld);
|
|
843
|
+
}, (delta) => {
|
|
844
|
+
this.panBy(delta);
|
|
845
|
+
});
|
|
846
|
+
// Bridge for the container-ops helpers — narrow surface that the
|
|
847
|
+
// pure functions call back into.
|
|
848
|
+
this.containerOpsRef = {
|
|
849
|
+
get scene() {
|
|
850
|
+
return self._scene;
|
|
851
|
+
},
|
|
852
|
+
get dragElementId() {
|
|
853
|
+
return self.dragElementId;
|
|
854
|
+
},
|
|
855
|
+
get containerHover() {
|
|
856
|
+
return self.containerHover;
|
|
857
|
+
},
|
|
858
|
+
applyPatch(patch, nextScene) {
|
|
859
|
+
self._scene = nextScene;
|
|
860
|
+
self.beginOrAttachGesture().add(patch);
|
|
861
|
+
},
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Install process-global hooks the host opted into: a custom text shaper
|
|
866
|
+
* and rasterizer for the WebGL2 backend, plus the scene text measurer that
|
|
867
|
+
* routes through the renderer's own metrics so selection boxes hug text.
|
|
868
|
+
*/
|
|
869
|
+
initGlobalHooks(options) {
|
|
619
870
|
// If the host plugged a TextShaper, install it process-globally so the
|
|
620
871
|
// built-in text renderer's wrap path uses it instead of
|
|
621
872
|
// Canvas2D.measureText. Hosts that don't care leave the field unset and
|
|
@@ -640,6 +891,12 @@ export class Editor {
|
|
|
640
891
|
});
|
|
641
892
|
return this.mainTarget.measureText(text).width;
|
|
642
893
|
});
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Resolve the input mode (`touch` vs `mouse`, `auto` reads the coarse-
|
|
897
|
+
* pointer media query) and the derived hit slops / thresholds once.
|
|
898
|
+
*/
|
|
899
|
+
initInputMode(options) {
|
|
643
900
|
// Resolve input mode + derived hit slops once. `auto` reads
|
|
644
901
|
// `matchMedia('(pointer: coarse)')` when available; SSR falls
|
|
645
902
|
// back to `mouse`.
|
|
@@ -667,6 +924,13 @@ export class Editor {
|
|
|
667
924
|
this.inputMode === "touch" ? TOUCH_ANCHOR_START_HIT_SLOP : ANCHOR_START_HIT_SLOP;
|
|
668
925
|
this.anchorClickRadius =
|
|
669
926
|
this.inputMode === "touch" ? TOUCH_ANCHOR_DOT_CLICK_RADIUS : ANCHOR_DOT_CLICK_RADIUS;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Create and start the interaction state-machine actor, wire its render /
|
|
930
|
+
* emit subscriptions, register the built-in file-drop handlers, and apply
|
|
931
|
+
* the initial mode.
|
|
932
|
+
*/
|
|
933
|
+
initActor(options) {
|
|
670
934
|
this.actor = createActor(interactionMachine);
|
|
671
935
|
this.actor.subscribe({
|
|
672
936
|
next: () => {
|
|
@@ -683,82 +947,55 @@ export class Editor {
|
|
|
683
947
|
// *after* and only fire for files we don't already handle.
|
|
684
948
|
this.fileDropRegistry.register(imageFileDropHandler);
|
|
685
949
|
this.fileDropRegistry.register(videoFileDropHandler);
|
|
950
|
+
this._readOnly = options.readOnly ?? false;
|
|
686
951
|
if (options.initialMode) {
|
|
687
952
|
this.actor.send({ type: "SET_MODE", mode: options.initialMode });
|
|
688
953
|
}
|
|
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
954
|
}
|
|
752
|
-
/**
|
|
955
|
+
/**
|
|
956
|
+
* Last {@link observableSnapshot} object, reused while none of its slices
|
|
957
|
+
* have flipped. `null` until the first snapshot is built.
|
|
958
|
+
*/
|
|
959
|
+
snapshotCache = null;
|
|
960
|
+
/**
|
|
961
|
+
* Snapshot used by event-fanout. Kept private — internal API.
|
|
962
|
+
*
|
|
963
|
+
* Memoized by slice identity: `notify()` fires on many mutations that touch
|
|
964
|
+
* no observable slice (annotation focus, cursor pushes, viewport-only re-arm),
|
|
965
|
+
* so rebuilding the object every call is pure churn on the hot drag path.
|
|
966
|
+
* We reuse the cached object whenever all six slices compare equal (refs for
|
|
967
|
+
* mode/selection/selectedLinks/scene — scene uses structural sharing so a new
|
|
968
|
+
* ref iff something changed — plus the two history booleans), and only
|
|
969
|
+
* allocate a fresh one on a real flip. `fanOutEvents` sees identical values
|
|
970
|
+
* either way, so emitted events are unchanged.
|
|
971
|
+
*/
|
|
753
972
|
observableSnapshot() {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
973
|
+
const mode = this.mode;
|
|
974
|
+
const selection = this._selection;
|
|
975
|
+
const selectedLinks = this._selectedLinks;
|
|
976
|
+
const scene = this._scene;
|
|
977
|
+
const canUndo = this.canUndo;
|
|
978
|
+
const canRedo = this.canRedo;
|
|
979
|
+
const cached = this.snapshotCache;
|
|
980
|
+
if (cached !== null &&
|
|
981
|
+
cached.mode === mode &&
|
|
982
|
+
cached.selection === selection &&
|
|
983
|
+
cached.selectedLinks === selectedLinks &&
|
|
984
|
+
cached.scene === scene &&
|
|
985
|
+
cached.canUndo === canUndo &&
|
|
986
|
+
cached.canRedo === canRedo) {
|
|
987
|
+
return cached;
|
|
988
|
+
}
|
|
989
|
+
const snapshot = {
|
|
990
|
+
mode,
|
|
991
|
+
selection,
|
|
992
|
+
selectedLinks,
|
|
993
|
+
scene,
|
|
994
|
+
canUndo,
|
|
995
|
+
canRedo,
|
|
761
996
|
};
|
|
997
|
+
this.snapshotCache = snapshot;
|
|
998
|
+
return snapshot;
|
|
762
999
|
}
|
|
763
1000
|
// --- Public state ---
|
|
764
1001
|
get scene() {
|
|
@@ -822,7 +1059,6 @@ export class Editor {
|
|
|
822
1059
|
this._selectedAnnotation = id;
|
|
823
1060
|
this.notify();
|
|
824
1061
|
}
|
|
825
|
-
// Pure bodies in `./editor/public/annotations.ts`.
|
|
826
1062
|
addAnnotation(opts) {
|
|
827
1063
|
const result = computeAddAnnotation(this._scene, opts, this.commentAuthor, (p) => this.uniqueId(p));
|
|
828
1064
|
this._scene = result.scene;
|
|
@@ -934,16 +1170,40 @@ export class Editor {
|
|
|
934
1170
|
this.debugHitZones = on;
|
|
935
1171
|
this.scheduleRender();
|
|
936
1172
|
}
|
|
937
|
-
/**
|
|
938
|
-
|
|
939
|
-
|
|
1173
|
+
/**
|
|
1174
|
+
* Read-only / view mode flag. `true` gates pointer edits and
|
|
1175
|
+
* non-`viewMode` actions while leaving pan / zoom / select live.
|
|
1176
|
+
*/
|
|
1177
|
+
get readOnly() {
|
|
1178
|
+
return this._readOnly;
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Enter / leave read-only (view) mode. Notifies subscribers so the UI
|
|
1182
|
+
* can re-render disabled chrome, and repaints (no visual diff today, but
|
|
1183
|
+
* keeps the contract symmetric with other view toggles). Idempotent.
|
|
1184
|
+
*/
|
|
1185
|
+
setReadOnly(on) {
|
|
1186
|
+
if (this._readOnly === on)
|
|
940
1187
|
return;
|
|
941
|
-
this.
|
|
1188
|
+
this._readOnly = on;
|
|
1189
|
+
this.notify();
|
|
942
1190
|
this.scheduleRender();
|
|
943
1191
|
}
|
|
944
|
-
/** Toggle
|
|
1192
|
+
/** Toggle read-only (view) mode. */
|
|
1193
|
+
toggleReadOnly() {
|
|
1194
|
+
this.setReadOnly(!this._readOnly);
|
|
1195
|
+
}
|
|
1196
|
+
/** Whether the background grid is enabled for the scene. */
|
|
1197
|
+
get gridEnabled() {
|
|
1198
|
+
return this._scene.viewport.gridEnabled;
|
|
1199
|
+
}
|
|
1200
|
+
/** Show/hide the background grid (`g`). Persists in the viewport, not in history. */
|
|
1201
|
+
setGridVisible(on) {
|
|
1202
|
+
this.setGrid({ enabled: on });
|
|
1203
|
+
}
|
|
1204
|
+
/** Toggle the background grid on/off. */
|
|
945
1205
|
toggleGrid() {
|
|
946
|
-
this.
|
|
1206
|
+
this.setGrid({ enabled: !this.gridEnabled });
|
|
947
1207
|
}
|
|
948
1208
|
/** Whether the active draw-mode sticks after a create (toolbar lock). */
|
|
949
1209
|
get toolLocked() {
|
|
@@ -971,6 +1231,8 @@ export class Editor {
|
|
|
971
1231
|
* object — Link is readonly). No-op when no edge is selected.
|
|
972
1232
|
*/
|
|
973
1233
|
updateSelectedLink(updater) {
|
|
1234
|
+
if (this.readOnly)
|
|
1235
|
+
return;
|
|
974
1236
|
const id = this.selectedLink;
|
|
975
1237
|
if (id === null)
|
|
976
1238
|
return;
|
|
@@ -1016,14 +1278,15 @@ export class Editor {
|
|
|
1016
1278
|
this._toolLocked = locked;
|
|
1017
1279
|
this.notify();
|
|
1018
1280
|
}
|
|
1019
|
-
// Body moved to `./editor/gesture-tx.ts`.
|
|
1020
1281
|
maybeRevertModeAfterCreate() {
|
|
1021
1282
|
this.gestures.maybeRevertModeAfterCreate();
|
|
1022
1283
|
}
|
|
1023
1284
|
setMode(mode) {
|
|
1285
|
+
// A tool switch cancels any armed colour-picker pipette.
|
|
1286
|
+
this.pendingEyedropperPick = null;
|
|
1024
1287
|
// Switching tools commits any in-flight text edit (standard: leaving the
|
|
1025
1288
|
// editing context ends it, keeping the typed text).
|
|
1026
|
-
if (this.
|
|
1289
|
+
if (this.editingTextElement !== null)
|
|
1027
1290
|
this.commitTextEdit();
|
|
1028
1291
|
// Cancel any in-progress drag gesture so the partial state is not recorded.
|
|
1029
1292
|
if (this.gestureTx) {
|
|
@@ -1086,13 +1349,12 @@ export class Editor {
|
|
|
1086
1349
|
* file-drop handler, a host CDN URL, an SVG string in
|
|
1087
1350
|
* `image/svg+xml;base64,...` form.
|
|
1088
1351
|
*/
|
|
1089
|
-
// Pure body in `./editor/public/image-insert.ts`.
|
|
1090
1352
|
insertImage(input) {
|
|
1091
1353
|
const id = castElementId(this.uniqueId("img"));
|
|
1092
1354
|
const shape = buildImageElement(this._scene, input, id, this._activeLayerId);
|
|
1093
1355
|
this.addElement(shape);
|
|
1094
1356
|
if (input.animated) {
|
|
1095
|
-
this.
|
|
1357
|
+
this.gifPlayback.ensure(id);
|
|
1096
1358
|
this.maybeAnimate();
|
|
1097
1359
|
}
|
|
1098
1360
|
return id;
|
|
@@ -1110,161 +1372,49 @@ export class Editor {
|
|
|
1110
1372
|
* current frame of natively-animated elements. Self-terminates
|
|
1111
1373
|
* when no animated shapes remain.
|
|
1112
1374
|
*
|
|
1113
|
-
* Lifecycle managed by the `AnimationTick` helper
|
|
1114
|
-
*
|
|
1115
|
-
* `
|
|
1116
|
-
*/
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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.
|
|
1375
|
+
* Lifecycle managed by the `AnimationTick` helper.
|
|
1376
|
+
* `insertImage({animated:true})` and `loadScene` start the tick;
|
|
1377
|
+
* `dispose()` stops it.
|
|
1378
|
+
*/
|
|
1379
|
+
animation = new AnimationController({
|
|
1380
|
+
// Laser trails also need a per-frame repaint to animate their fade — OR
|
|
1381
|
+
// them into the tick predicate so the same rAF loop drives both.
|
|
1382
|
+
hasVisibleAnimatedElement: () => animScene.hasVisibleAnimatedElement(this) || this.hasActiveLaser(),
|
|
1383
|
+
autoStopHeavyGifs: () => {
|
|
1384
|
+
animScene.autoStopHeavyGifs(this);
|
|
1385
|
+
},
|
|
1386
|
+
forceAnimationRepaint: () => {
|
|
1387
|
+
// The scene reference hasn't changed, but the adapter advanced the GIF
|
|
1388
|
+
// frame — drop the paint cache and force a repaint.
|
|
1142
1389
|
this.lastRenderedScene = null;
|
|
1143
1390
|
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
1391
|
},
|
|
1148
1392
|
});
|
|
1149
|
-
// Pure body in `./editor/public/image-insert.ts`.
|
|
1150
1393
|
hasAnimatedElement() {
|
|
1151
1394
|
return hasAnimatedElement(this._scene);
|
|
1152
1395
|
}
|
|
1153
1396
|
/**
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1156
|
-
*
|
|
1157
|
-
*
|
|
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()`.
|
|
1397
|
+
* Re-arm the animation tick after a change that may have brought an
|
|
1398
|
+
* animated shape into (or out of) view — pan / zoom / scene edit.
|
|
1399
|
+
* `AnimationTick.start()` no-ops when already running or when
|
|
1400
|
+
* `isAnimated()` is false, so this is cheap to call from `notify()`.
|
|
1179
1401
|
*/
|
|
1180
1402
|
maybeAnimate() {
|
|
1181
|
-
|
|
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
|
-
}
|
|
1403
|
+
this.animation.maybe();
|
|
1218
1404
|
}
|
|
1219
1405
|
/**
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1406
|
+
* Per-shape GIF playback state (auto-stop + reduced-motion). Extracted into
|
|
1407
|
+
* a controller; the Editor keeps the orchestration (scene iteration,
|
|
1408
|
+
* animation tick, render scheduling) and delegates state ops here.
|
|
1222
1409
|
*/
|
|
1223
|
-
|
|
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
|
-
}
|
|
1410
|
+
gifPlayback = new GifPlaybackController();
|
|
1244
1411
|
/**
|
|
1245
1412
|
* Toggle GIF playback for a shape — wired to a click on an animated
|
|
1246
1413
|
* image (resume after auto-stop, play after reduced-motion). Resuming
|
|
1247
1414
|
* continues from the frozen frame.
|
|
1248
1415
|
*/
|
|
1249
1416
|
togglePlayback(id) {
|
|
1250
|
-
|
|
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
|
-
}
|
|
1417
|
+
this.gifPlayback.toggle(id);
|
|
1268
1418
|
this.maybeAnimate();
|
|
1269
1419
|
this.scheduleRender();
|
|
1270
1420
|
}
|
|
@@ -1275,24 +1425,14 @@ export class Editor {
|
|
|
1275
1425
|
* Pass `null` when the pointer leaves all shapes.
|
|
1276
1426
|
*/
|
|
1277
1427
|
hoverAnimatedElement(id) {
|
|
1278
|
-
if (this.
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
-
}
|
|
1428
|
+
if (this.gifPlayback.hoverEnter(id)) {
|
|
1429
|
+
this.maybeAnimate();
|
|
1430
|
+
this.scheduleRender();
|
|
1291
1431
|
}
|
|
1292
1432
|
}
|
|
1293
1433
|
/** True when the shape's GIF is paused (drives the overlay badge). */
|
|
1294
1434
|
isPlaybackPaused(id) {
|
|
1295
|
-
return this.
|
|
1435
|
+
return this.gifPlayback.isPaused(id);
|
|
1296
1436
|
}
|
|
1297
1437
|
/**
|
|
1298
1438
|
* Record the idle cursor position so the overlay can grow the SINGLE
|
|
@@ -1315,74 +1455,6 @@ export class Editor {
|
|
|
1315
1455
|
const t = this.hoveredLinkTarget;
|
|
1316
1456
|
return t ? { elementId: t.elementId, mode: t.mode } : null;
|
|
1317
1457
|
}
|
|
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
1458
|
/**
|
|
1387
1459
|
* Drag-to-place flow for palette templates. Adds the shape to the
|
|
1388
1460
|
* scene immediately so the user sees it dragging under the cursor,
|
|
@@ -1393,7 +1465,6 @@ export class Editor {
|
|
|
1393
1465
|
* Typical wiring: HTML5 dragenter starts the placement, dragover
|
|
1394
1466
|
* updates, drop commits, dragleave / window keydown(Escape) cancel.
|
|
1395
1467
|
*/
|
|
1396
|
-
// Placement helpers live in `./editor/public/placement.ts`.
|
|
1397
1468
|
// Editor owns the transaction lifecycle and selection mutate;
|
|
1398
1469
|
// the closure threads scene mutations through the pure helpers.
|
|
1399
1470
|
beginPlacement(shape) {
|
|
@@ -1437,8 +1508,9 @@ export class Editor {
|
|
|
1437
1508
|
},
|
|
1438
1509
|
};
|
|
1439
1510
|
}
|
|
1440
|
-
// Pure body in `./editor/public/selection-ops.ts`.
|
|
1441
1511
|
deleteSelected() {
|
|
1512
|
+
if (this.readOnly)
|
|
1513
|
+
return;
|
|
1442
1514
|
const result = computeDeleteSelection(this._scene, this._selection, this._selectedLinks);
|
|
1443
1515
|
if (!result)
|
|
1444
1516
|
return;
|
|
@@ -1457,15 +1529,14 @@ export class Editor {
|
|
|
1457
1529
|
* cleared by `commitTextEdit` / `cancelTextEdit`. The host overlay
|
|
1458
1530
|
* (`<TextEditorOverlay>` in `@react-ui`) subscribes via `editor`
|
|
1459
1531
|
* and renders a `<textarea>` positioned over the shape.
|
|
1532
|
+
* State lives in `TextEditController`; this is a delegate.
|
|
1460
1533
|
*/
|
|
1461
|
-
_editingTextElement = null;
|
|
1462
1534
|
get editingTextElement() {
|
|
1463
|
-
return this.
|
|
1535
|
+
return this.textEdit.editingElement;
|
|
1464
1536
|
}
|
|
1465
1537
|
/** Link whose caption is being edited inline (double-click), or null. */
|
|
1466
|
-
_editingLinkCaption = null;
|
|
1467
1538
|
get editingLinkCaption() {
|
|
1468
|
-
return this.
|
|
1539
|
+
return this.interaction.editingLinkCaption;
|
|
1469
1540
|
}
|
|
1470
1541
|
/**
|
|
1471
1542
|
* Frame whose NAME (header label) is being edited inline (double-click
|
|
@@ -1476,82 +1547,32 @@ export class Editor {
|
|
|
1476
1547
|
get editingFrameName() {
|
|
1477
1548
|
return this._editingFrameName;
|
|
1478
1549
|
}
|
|
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
1550
|
get editingTextSelection() {
|
|
1504
|
-
return this.
|
|
1551
|
+
return this.textEdit.selection;
|
|
1505
1552
|
}
|
|
1506
1553
|
/** Caret offset = the moving end of the selection. */
|
|
1507
1554
|
get editingTextCaret() {
|
|
1508
|
-
|
|
1509
|
-
return null;
|
|
1510
|
-
return this._textSel.dir === "backward" ? this._textSel.start : this._textSel.end;
|
|
1555
|
+
return this.textEdit.caret;
|
|
1511
1556
|
}
|
|
1512
1557
|
get caretBlinkOn() {
|
|
1513
|
-
return this.
|
|
1558
|
+
return this.textEdit.caretBlinkOn;
|
|
1514
1559
|
}
|
|
1515
1560
|
/** `true` while a canvas drag-select inside the edited text is active. */
|
|
1516
1561
|
get isTextDragging() {
|
|
1517
|
-
return this.
|
|
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;
|
|
1562
|
+
return this.textEdit.isDragging;
|
|
1541
1563
|
}
|
|
1542
1564
|
/**
|
|
1543
1565
|
* Begin editing a text shape's body. No-op when the shape doesn't
|
|
1544
1566
|
* exist or isn't a text shape. Concurrent edits commit themselves
|
|
1545
1567
|
* (only one shape at a time). Caret defaults to the end of the text.
|
|
1546
1568
|
*/
|
|
1547
|
-
// Pure bodies in `./editor/public/text-edit.ts`.
|
|
1548
1569
|
/** Open inline caption editing for a link (double-click). */
|
|
1549
1570
|
beginLinkCaptionEdit(id) {
|
|
1550
1571
|
if (!getLink(this._scene, id))
|
|
1551
1572
|
return;
|
|
1552
|
-
if (this.
|
|
1573
|
+
if (this.editingTextElement !== null)
|
|
1553
1574
|
this.commitTextEdit();
|
|
1554
|
-
this.
|
|
1575
|
+
this.interaction.editingLinkCaption = id;
|
|
1555
1576
|
this.notify();
|
|
1556
1577
|
}
|
|
1557
1578
|
/**
|
|
@@ -1560,8 +1581,8 @@ export class Editor {
|
|
|
1560
1581
|
* styling. One undo step. Clears caption-edit mode.
|
|
1561
1582
|
*/
|
|
1562
1583
|
commitLinkCaptionEdit(text) {
|
|
1563
|
-
const id = this.
|
|
1564
|
-
this.
|
|
1584
|
+
const id = this.interaction.editingLinkCaption;
|
|
1585
|
+
this.interaction.editingLinkCaption = null;
|
|
1565
1586
|
if (id === null) {
|
|
1566
1587
|
this.notify();
|
|
1567
1588
|
return;
|
|
@@ -1588,9 +1609,9 @@ export class Editor {
|
|
|
1588
1609
|
}
|
|
1589
1610
|
/** Cancel link caption editing without changing the label. */
|
|
1590
1611
|
cancelLinkCaptionEdit() {
|
|
1591
|
-
if (this.
|
|
1612
|
+
if (this.interaction.editingLinkCaption === null)
|
|
1592
1613
|
return;
|
|
1593
|
-
this.
|
|
1614
|
+
this.interaction.editingLinkCaption = null;
|
|
1594
1615
|
this.notify();
|
|
1595
1616
|
}
|
|
1596
1617
|
/** World-space anchor point for a link's caption (midpoint of its path). */
|
|
@@ -1604,12 +1625,12 @@ export class Editor {
|
|
|
1604
1625
|
const t = edge.label?.position ?? 0.5;
|
|
1605
1626
|
let total = 0;
|
|
1606
1627
|
for (let i = 1; i < path.length; i++)
|
|
1607
|
-
total +=
|
|
1628
|
+
total += vec2.distance(req(path[i - 1]), req(path[i]));
|
|
1608
1629
|
let remaining = total * t;
|
|
1609
1630
|
for (let i = 1; i < path.length; i++) {
|
|
1610
1631
|
const a = req(path[i - 1]);
|
|
1611
1632
|
const b = req(path[i]);
|
|
1612
|
-
const seg =
|
|
1633
|
+
const seg = vec2.distance(a, b);
|
|
1613
1634
|
if (remaining <= seg) {
|
|
1614
1635
|
const r = seg === 0 ? 0 : remaining / seg;
|
|
1615
1636
|
return { x: a.x + (b.x - a.x) * r, y: a.y + (b.y - a.y) * r };
|
|
@@ -1619,19 +1640,7 @@ export class Editor {
|
|
|
1619
1640
|
return req(path[path.length - 1]);
|
|
1620
1641
|
}
|
|
1621
1642
|
beginTextEdit(id) {
|
|
1622
|
-
|
|
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();
|
|
1643
|
+
this.textEdit.begin(id);
|
|
1635
1644
|
}
|
|
1636
1645
|
// --- Frame name inline editing (double-click the header) ---
|
|
1637
1646
|
/**
|
|
@@ -1640,11 +1649,11 @@ export class Editor {
|
|
|
1640
1649
|
*/
|
|
1641
1650
|
beginFrameNameEdit(id) {
|
|
1642
1651
|
const shape = getElement(this._scene, id);
|
|
1643
|
-
if (shape
|
|
1652
|
+
if (shape === undefined || !isFrame(shape))
|
|
1644
1653
|
return;
|
|
1645
1654
|
if (this.isLayerLocked(shape.layerId))
|
|
1646
1655
|
return;
|
|
1647
|
-
if (this.
|
|
1656
|
+
if (this.editingTextElement !== null)
|
|
1648
1657
|
this.commitTextEdit();
|
|
1649
1658
|
this._editingFrameName = id;
|
|
1650
1659
|
this.notify();
|
|
@@ -1659,23 +1668,10 @@ export class Editor {
|
|
|
1659
1668
|
if (id === null)
|
|
1660
1669
|
return;
|
|
1661
1670
|
this._editingFrameName = null;
|
|
1662
|
-
const
|
|
1663
|
-
if (
|
|
1664
|
-
|
|
1665
|
-
|
|
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
|
-
}
|
|
1671
|
+
const r = computeFrameNameCommit(this._scene, id, name);
|
|
1672
|
+
if (r) {
|
|
1673
|
+
this._scene = r.scene;
|
|
1674
|
+
this._history.push(r.patch);
|
|
1679
1675
|
}
|
|
1680
1676
|
this.notify();
|
|
1681
1677
|
}
|
|
@@ -1693,25 +1689,7 @@ export class Editor {
|
|
|
1693
1689
|
* hit-test bounds. Assumes unrotated frames (the common case).
|
|
1694
1690
|
*/
|
|
1695
1691
|
frameHeaderAt(p) {
|
|
1696
|
-
|
|
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;
|
|
1692
|
+
return computeFrameHeaderAt(this._scene, p);
|
|
1715
1693
|
}
|
|
1716
1694
|
/**
|
|
1717
1695
|
* Live edit transport from the hidden `<textarea>`: replace the
|
|
@@ -1720,22 +1698,11 @@ export class Editor {
|
|
|
1720
1698
|
* once on commit. No-op when not editing.
|
|
1721
1699
|
*/
|
|
1722
1700
|
setEditingText(value, selStart, selEnd, dir = "forward") {
|
|
1723
|
-
|
|
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();
|
|
1701
|
+
this.textEdit.setText(value, selStart, selEnd, dir);
|
|
1731
1702
|
}
|
|
1732
1703
|
/** Selection-only update (arrows / shift-select / click) — no text change. */
|
|
1733
1704
|
setEditingSelection(selStart, selEnd, dir = "forward") {
|
|
1734
|
-
|
|
1735
|
-
return;
|
|
1736
|
-
this._textSel = { start: selStart, end: selEnd, dir };
|
|
1737
|
-
this.wakeCaret();
|
|
1738
|
-
this.notify();
|
|
1705
|
+
this.textEdit.setSelection(selStart, selEnd, dir);
|
|
1739
1706
|
}
|
|
1740
1707
|
/**
|
|
1741
1708
|
* Map a world-space point to a caret offset in the edited text. Used
|
|
@@ -1743,20 +1710,7 @@ export class Editor {
|
|
|
1743
1710
|
* not editing or the shape is gone.
|
|
1744
1711
|
*/
|
|
1745
1712
|
caretIndexAtWorldPoint(worldPoint) {
|
|
1746
|
-
|
|
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);
|
|
1713
|
+
return this.textEdit.caretIndexAtWorldPoint(worldPoint);
|
|
1760
1714
|
}
|
|
1761
1715
|
/**
|
|
1762
1716
|
* `true` when a point is inside the currently-edited text shape's
|
|
@@ -1764,65 +1718,19 @@ export class Editor {
|
|
|
1764
1718
|
* repositioning the caret (inside) and committing (outside).
|
|
1765
1719
|
*/
|
|
1766
1720
|
editedElementContainsPoint(worldPoint) {
|
|
1767
|
-
|
|
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);
|
|
1721
|
+
return this.textEdit.editedElementContainsPoint(worldPoint);
|
|
1778
1722
|
}
|
|
1779
1723
|
/** Place a collapsed caret at the clicked point and start a drag-select. */
|
|
1780
1724
|
setTextCaretFromPoint(worldPoint) {
|
|
1781
|
-
|
|
1782
|
-
if (idx === null)
|
|
1783
|
-
return;
|
|
1784
|
-
this._textDragAnchor = idx;
|
|
1785
|
-
this.setEditingSelection(idx, idx, "forward");
|
|
1725
|
+
this.textEdit.setCaretFromPoint(worldPoint);
|
|
1786
1726
|
}
|
|
1787
1727
|
/** Extend the selection from the drag anchor to the current point. */
|
|
1788
1728
|
extendTextSelectionToPoint(worldPoint) {
|
|
1789
|
-
|
|
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");
|
|
1729
|
+
this.textEdit.extendSelectionToPoint(worldPoint);
|
|
1799
1730
|
}
|
|
1800
1731
|
/** End a canvas drag-select (clears the drag anchor). */
|
|
1801
1732
|
endTextDragSelect() {
|
|
1802
|
-
this.
|
|
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;
|
|
1733
|
+
this.textEdit.endDragSelect();
|
|
1826
1734
|
}
|
|
1827
1735
|
/**
|
|
1828
1736
|
* World-space caret + selection geometry for the overlay pass.
|
|
@@ -1830,110 +1738,13 @@ export class Editor {
|
|
|
1830
1738
|
* off so the overlay can simply skip drawing it.
|
|
1831
1739
|
*/
|
|
1832
1740
|
editingTextOverlay() {
|
|
1833
|
-
|
|
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 };
|
|
1741
|
+
return this.textEdit.overlay();
|
|
1859
1742
|
}
|
|
1860
1743
|
commitTextEdit(next) {
|
|
1861
|
-
|
|
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();
|
|
1744
|
+
this.textEdit.commit(next);
|
|
1909
1745
|
}
|
|
1910
1746
|
cancelTextEdit() {
|
|
1911
|
-
|
|
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();
|
|
1747
|
+
this.textEdit.cancel();
|
|
1937
1748
|
}
|
|
1938
1749
|
/**
|
|
1939
1750
|
* Translate every selected shape by the given world-space delta.
|
|
@@ -1941,8 +1752,9 @@ export class Editor {
|
|
|
1941
1752
|
* keyboard navigation; hosts pass `{ x: 1, y: 0 }` for fine nudge
|
|
1942
1753
|
* and `{ x: 10, y: 0 }` for shift-arrow.
|
|
1943
1754
|
*/
|
|
1944
|
-
// Pure body in `./editor/public/selection-ops.ts`.
|
|
1945
1755
|
moveSelectionBy(delta) {
|
|
1756
|
+
if (this.readOnly)
|
|
1757
|
+
return;
|
|
1946
1758
|
if (this._selection.size === 0 && this._selectedLinks.size === 0)
|
|
1947
1759
|
return;
|
|
1948
1760
|
// Locked / layer-locked elements don't move (they're still selectable).
|
|
@@ -1980,7 +1792,6 @@ export class Editor {
|
|
|
1980
1792
|
* Hosts can bind this to "Enter" while in a draw mode, providing a
|
|
1981
1793
|
* mouse-free alternative to drag-out creation.
|
|
1982
1794
|
*/
|
|
1983
|
-
// Pure body in `./editor/public/placement.ts`.
|
|
1984
1795
|
createElementAtCursor() {
|
|
1985
1796
|
const vp = this._scene.viewport;
|
|
1986
1797
|
const world = this.screenToWorld({
|
|
@@ -2008,11 +1819,11 @@ export class Editor {
|
|
|
2008
1819
|
const id = newElementIdAtCursor(++this.nextId);
|
|
2009
1820
|
const shape = buildTextElementAt(this._scene, worldPoint, this._activeLayerId, id);
|
|
2010
1821
|
// No history push here — the placeholder is "pending" until the
|
|
2011
|
-
// first commit (see `
|
|
1822
|
+
// first commit (see `TextEditController.markPendingCreate`). This way an abandoned
|
|
2012
1823
|
// text never pollutes the undo stack.
|
|
2013
1824
|
const r = addElement(this._scene, shape);
|
|
2014
1825
|
this._scene = r.scene;
|
|
2015
|
-
this.
|
|
1826
|
+
this.textEdit.markPendingCreate(id);
|
|
2016
1827
|
this._selection = Selection.single(id);
|
|
2017
1828
|
this.maybeRevertModeAfterCreate();
|
|
2018
1829
|
this.notify();
|
|
@@ -2020,19 +1831,32 @@ export class Editor {
|
|
|
2020
1831
|
this.beginTextEdit(id);
|
|
2021
1832
|
return id;
|
|
2022
1833
|
}
|
|
2023
|
-
|
|
1834
|
+
/** Current brush paint settings (line colour, fill, opacity, width). */
|
|
1835
|
+
_brushSettings = DEFAULT_BRUSH_SETTINGS;
|
|
1836
|
+
get brushSettings() {
|
|
1837
|
+
return this._brushSettings;
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Update one or more brush paint settings (e.g. from the drawing panel). New
|
|
1841
|
+
* strokes pick them up on commit; the width also drives the pressure curve and
|
|
1842
|
+
* the eraser radius. Merges over the current settings.
|
|
1843
|
+
*/
|
|
1844
|
+
setBrushSettings(patch) {
|
|
1845
|
+
this._brushSettings = { ...this._brushSettings, ...patch };
|
|
1846
|
+
this.notify();
|
|
1847
|
+
}
|
|
2024
1848
|
beginBrushStroke(world, pressure = 0.5) {
|
|
2025
|
-
this.brushStroke = beginBrushStrokePure(world, pressure);
|
|
1849
|
+
this.brushStroke = beginBrushStrokePure(world, pressure, this._brushSettings.width);
|
|
2026
1850
|
this.notify();
|
|
2027
1851
|
}
|
|
2028
1852
|
extendBrushStroke(world, pressure = 0.5) {
|
|
2029
1853
|
if (!this.brushStroke)
|
|
2030
1854
|
return;
|
|
2031
|
-
extendBrushStrokePure(this.brushStroke, world, pressure);
|
|
1855
|
+
extendBrushStrokePure(this.brushStroke, world, pressure, this._brushSettings.width);
|
|
2032
1856
|
this.notify();
|
|
2033
1857
|
}
|
|
2034
1858
|
commitBrushStroke() {
|
|
2035
|
-
const result = commitBrushStrokePure(this._scene, this.brushStroke, this._activeLayerId, newBrushId(++this.nextId));
|
|
1859
|
+
const result = commitBrushStrokePure(this._scene, this.brushStroke, this._activeLayerId, newBrushId(++this.nextId), brushStyleFromSettings(this._brushSettings));
|
|
2036
1860
|
if (!result) {
|
|
2037
1861
|
this.brushStroke = null;
|
|
2038
1862
|
this.notify();
|
|
@@ -2054,7 +1878,223 @@ export class Editor {
|
|
|
2054
1878
|
get pendingBrushStroke() {
|
|
2055
1879
|
return this.brushStroke;
|
|
2056
1880
|
}
|
|
2057
|
-
//
|
|
1881
|
+
// --- Eraser tool ---
|
|
1882
|
+
/**
|
|
1883
|
+
* Start an eraser stroke at `world`, seeding it with the shape under it so a
|
|
1884
|
+
* plain click erases. With `restore` (Alt held at press) it seeds nothing —
|
|
1885
|
+
* the gesture is in un-mark mode, and there's nothing marked yet to rescue.
|
|
1886
|
+
*/
|
|
1887
|
+
beginEraseStroke(world, restore = false, strokeErase = false) {
|
|
1888
|
+
const stroke = beginEraseStrokePure(world, strokeErase);
|
|
1889
|
+
if (!restore) {
|
|
1890
|
+
const hit = this.acceleratedElementAt(world);
|
|
1891
|
+
// In stroke-erase mode brushes are cut by the path, not object-deleted —
|
|
1892
|
+
// don't seed a brush into `pending`.
|
|
1893
|
+
if (hit && !(strokeErase && hit.type === "brush"))
|
|
1894
|
+
stroke.pending.add(hit.id);
|
|
1895
|
+
}
|
|
1896
|
+
this.eraseStroke = stroke;
|
|
1897
|
+
// The initial seed / cut changes the marked set → the first frame must fully
|
|
1898
|
+
// repaint so the dim / cut preview shows.
|
|
1899
|
+
this.eraseDirty = true;
|
|
1900
|
+
// Stroke mode: mark brush points under the press point (degenerate segment)
|
|
1901
|
+
// so a click still cuts.
|
|
1902
|
+
if (strokeErase)
|
|
1903
|
+
this.markStrokeEraseSegment(stroke, world, world);
|
|
1904
|
+
// Start a fading eraser trail (a fresh array so the render-overlay memo
|
|
1905
|
+
// rebuilds this frame — same reasoning as `beginLaserStroke`).
|
|
1906
|
+
this.interaction.eraserTrail = [beginLaserStrokePure(world, nowMs())];
|
|
1907
|
+
this.maybeAnimate();
|
|
1908
|
+
this.notify();
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* Incrementally mark the brush points erased by the eraser segment `a → b`
|
|
1912
|
+
* (world). Iterates the current brushes and grows `stroke.erased` in place —
|
|
1913
|
+
* O(points) per move (each already-erased point is skipped), so a long drag
|
|
1914
|
+
* no longer costs O(points × path length) per frame. Radius is the on-screen
|
|
1915
|
+
* eraser ring converted to world units.
|
|
1916
|
+
*/
|
|
1917
|
+
markStrokeEraseSegment(stroke, a, b) {
|
|
1918
|
+
const zoom = this._scene.viewport.zoom || 1;
|
|
1919
|
+
const radius = this._brushSettings.width / zoom;
|
|
1920
|
+
let changed = false;
|
|
1921
|
+
for (const el of this._scene.elements.values()) {
|
|
1922
|
+
if (!isBrush(el))
|
|
1923
|
+
continue;
|
|
1924
|
+
const existing = stroke.erased.get(el.id) ?? [];
|
|
1925
|
+
const merged = markErasedIntervals(el, existing, a, b, radius);
|
|
1926
|
+
// Grew the covered span (or first coverage of a single-point brush).
|
|
1927
|
+
if (merged.length > existing.length ||
|
|
1928
|
+
coveredLength(merged) > coveredLength(existing) + 1e-6) {
|
|
1929
|
+
stroke.erased.set(el.id, merged);
|
|
1930
|
+
changed = true;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
return changed;
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* Extend the eraser stroke to `world`, sweeping shapes along the segment.
|
|
1937
|
+
* `restore` (Alt held) un-marks swept shapes instead of marking them.
|
|
1938
|
+
*/
|
|
1939
|
+
extendEraseStroke(world, restore = false) {
|
|
1940
|
+
const stroke = this.eraseStroke;
|
|
1941
|
+
if (!stroke)
|
|
1942
|
+
return;
|
|
1943
|
+
const changed = sampleErasePure(stroke.last, world, (p) => this.acceleratedElementAt(p), stroke.pending, restore, stroke.strokeMode);
|
|
1944
|
+
// Incrementally cut brush points along the new segment (stroke mode only).
|
|
1945
|
+
const cut = stroke.strokeMode ? this.markStrokeEraseSegment(stroke, stroke.last, world) : false;
|
|
1946
|
+
// Only a frame that actually changed the marked / cut set needs the forced
|
|
1947
|
+
// full repaint (see `eraseDirty`); a move over already-covered area doesn't.
|
|
1948
|
+
if (changed || cut)
|
|
1949
|
+
this.eraseDirty = true;
|
|
1950
|
+
stroke.last = world;
|
|
1951
|
+
// Grow the fading trail alongside the sweep. Reassign the array reference
|
|
1952
|
+
// (like the laser) so the overlay memo repaints the trail on this move. If
|
|
1953
|
+
// the trail had faded to empty (a pause with the button held), start a fresh
|
|
1954
|
+
// one — otherwise resuming the drag would leave `eraserTrail` empty.
|
|
1955
|
+
const trail = this.interaction.eraserTrail;
|
|
1956
|
+
const active = trail[trail.length - 1];
|
|
1957
|
+
if (active) {
|
|
1958
|
+
extendLaserStrokePure(active, world, nowMs());
|
|
1959
|
+
this.interaction.eraserTrail = trail.slice();
|
|
1960
|
+
}
|
|
1961
|
+
else {
|
|
1962
|
+
this.interaction.eraserTrail = [beginLaserStrokePure(world, nowMs())];
|
|
1963
|
+
}
|
|
1964
|
+
this.maybeAnimate();
|
|
1965
|
+
// Always repaint: the cursor ring follows the pointer every move regardless
|
|
1966
|
+
// of whether anything was marked / cut. Cheap now — a frame that changes
|
|
1967
|
+
// nothing skips the full main pass (see `eraseDirty`) and only redraws the
|
|
1968
|
+
// overlay cursor / trail.
|
|
1969
|
+
this.notify();
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Commit the eraser stroke — delete every swept shape in ONE undo step (with
|
|
1973
|
+
* their attached links). No-op delete when nothing was swept. Returns the
|
|
1974
|
+
* count removed.
|
|
1975
|
+
*/
|
|
1976
|
+
commitEraseStroke() {
|
|
1977
|
+
const stroke = this.eraseStroke;
|
|
1978
|
+
if (!stroke)
|
|
1979
|
+
return 0;
|
|
1980
|
+
this.eraseStroke = null;
|
|
1981
|
+
// Object-erase part: delete every swept (non-brush, in stroke mode) shape.
|
|
1982
|
+
const objectResult = computeEraseCommit(this._scene, stroke.pending);
|
|
1983
|
+
let scene = objectResult ? objectResult.scene : this._scene;
|
|
1984
|
+
const patches = objectResult ? [...objectResult.patches] : [];
|
|
1985
|
+
// Stroke-erase part (Shift): cut every brush with erased points (accumulated
|
|
1986
|
+
// incrementally during the drag) into fragments.
|
|
1987
|
+
const removedBrushIds = [];
|
|
1988
|
+
if (stroke.strokeMode) {
|
|
1989
|
+
const strokeResult = computeEraseFromMasks(scene, stroke.erased, () => newBrushId(++this.nextId));
|
|
1990
|
+
if (strokeResult) {
|
|
1991
|
+
scene = strokeResult.scene;
|
|
1992
|
+
patches.push(...strokeResult.patches);
|
|
1993
|
+
removedBrushIds.push(...strokeResult.removedIds);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
if (patches.length === 0) {
|
|
1997
|
+
this.notify();
|
|
1998
|
+
return 0;
|
|
1999
|
+
}
|
|
2000
|
+
// Fold the object-deletes and brush cuts into ONE undo step.
|
|
2001
|
+
const tx = this._history.transaction();
|
|
2002
|
+
this._scene = scene;
|
|
2003
|
+
for (const patch of patches)
|
|
2004
|
+
tx.add(patch);
|
|
2005
|
+
tx.commit();
|
|
2006
|
+
// Drop any erased ids from the live selection so no stale handle lingers.
|
|
2007
|
+
let sel = this._selection;
|
|
2008
|
+
for (const id of stroke.pending)
|
|
2009
|
+
sel = Selection.remove(sel, id);
|
|
2010
|
+
for (const id of removedBrushIds)
|
|
2011
|
+
sel = Selection.remove(sel, id);
|
|
2012
|
+
this._selection = sel;
|
|
2013
|
+
this.notify();
|
|
2014
|
+
return stroke.pending.size + removedBrushIds.length;
|
|
2015
|
+
}
|
|
2016
|
+
/** Abort the eraser stroke without deleting anything. */
|
|
2017
|
+
cancelEraseStroke() {
|
|
2018
|
+
if (!this.eraseStroke)
|
|
2019
|
+
return;
|
|
2020
|
+
this.eraseStroke = null;
|
|
2021
|
+
this.notify();
|
|
2022
|
+
}
|
|
2023
|
+
// --- Laser pointer ---
|
|
2024
|
+
/** True only while the pointer is down in laser mode (a trail is being laid). */
|
|
2025
|
+
get laserDrawing() {
|
|
2026
|
+
return this.interaction.laserDrawing;
|
|
2027
|
+
}
|
|
2028
|
+
/** Start a laser trail at `world` (ephemeral — never enters the scene). */
|
|
2029
|
+
beginLaserStroke(world) {
|
|
2030
|
+
// Reassign the array (not just `.push`) so its identity changes: the
|
|
2031
|
+
// render-overlay memo keys on the `laserStrokes` reference, so an in-place
|
|
2032
|
+
// mutation would leave the signature unchanged and the memo would reuse a
|
|
2033
|
+
// stale options bag that omits the trail — the trail then wouldn't paint
|
|
2034
|
+
// until a later prune reallocated the array (~TTL later). A fresh reference
|
|
2035
|
+
// forces the memo to rebuild and the trail to render on this very frame.
|
|
2036
|
+
this.interaction.laserStrokes = [
|
|
2037
|
+
...this.interaction.laserStrokes,
|
|
2038
|
+
beginLaserStrokePure(world, nowMs()),
|
|
2039
|
+
];
|
|
2040
|
+
this.interaction.laserDrawing = true;
|
|
2041
|
+
this.maybeAnimate();
|
|
2042
|
+
this.notify();
|
|
2043
|
+
}
|
|
2044
|
+
/** Append a point to the active laser trail (no-op unless drawing). */
|
|
2045
|
+
extendLaserStroke(world) {
|
|
2046
|
+
if (!this.interaction.laserDrawing)
|
|
2047
|
+
return;
|
|
2048
|
+
const strokes = this.interaction.laserStrokes;
|
|
2049
|
+
const active = strokes[strokes.length - 1];
|
|
2050
|
+
if (!active)
|
|
2051
|
+
return;
|
|
2052
|
+
extendLaserStrokePure(active, world, nowMs());
|
|
2053
|
+
// Fresh array reference (same reasoning as `beginLaserStroke`) so the
|
|
2054
|
+
// overlay memo rebuilds and repaints the growing trail on THIS move,
|
|
2055
|
+
// instead of waiting for the animation loop to happen to reallocate it.
|
|
2056
|
+
this.interaction.laserStrokes = strokes.slice();
|
|
2057
|
+
this.maybeAnimate();
|
|
2058
|
+
this.notify();
|
|
2059
|
+
}
|
|
2060
|
+
/** End the active laser trail — it keeps fading via the animation tick. */
|
|
2061
|
+
endLaserStroke() {
|
|
2062
|
+
this.interaction.laserDrawing = false;
|
|
2063
|
+
// The stroke is already ephemeral and the tick prunes it; re-arm in case
|
|
2064
|
+
// the tick wasn't running.
|
|
2065
|
+
this.maybeAnimate();
|
|
2066
|
+
}
|
|
2067
|
+
/**
|
|
2068
|
+
* True while any laser trail OR eraser trail still has visible points (drives
|
|
2069
|
+
* the fade tick — so the trail keeps melting after the pointer stops).
|
|
2070
|
+
*/
|
|
2071
|
+
hasActiveLaser() {
|
|
2072
|
+
return this.interaction.laserStrokes.length > 0 || this.interaction.eraserTrail.length > 0;
|
|
2073
|
+
}
|
|
2074
|
+
/** Live eraser drag trail (ephemeral, fading). Empty when none active. */
|
|
2075
|
+
get eraserTrail() {
|
|
2076
|
+
return this.interaction.eraserTrail;
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* Drop expired laser/eraser trail points (called once per frame before paint).
|
|
2080
|
+
* Self-terminating: once both arrays empty the animation tick stops.
|
|
2081
|
+
*/
|
|
2082
|
+
pruneLaser() {
|
|
2083
|
+
const strokes = this.interaction.laserStrokes;
|
|
2084
|
+
if (strokes.length > 0) {
|
|
2085
|
+
const r = pruneLaserStrokes(strokes, nowMs());
|
|
2086
|
+
if (r.changed)
|
|
2087
|
+
this.interaction.laserStrokes = r.strokes;
|
|
2088
|
+
}
|
|
2089
|
+
const trail = this.interaction.eraserTrail;
|
|
2090
|
+
if (trail.length > 0) {
|
|
2091
|
+
// Prune at the eraser's own (shorter) TTL so points don't linger in the
|
|
2092
|
+
// array long after they've faded to invisible.
|
|
2093
|
+
const r = pruneLaserStrokes(trail, nowMs(), ERASER_TRAIL_TTL_MS);
|
|
2094
|
+
if (r.changed)
|
|
2095
|
+
this.interaction.eraserTrail = r.strokes;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2058
2098
|
arrangeAsGrid(opts = {}) {
|
|
2059
2099
|
const origin = this.combinedSelectionBounds() ?? { x: 0, y: 0 };
|
|
2060
2100
|
const result = computeArrangeAsGrid(this._scene, this._selection, opts, origin);
|
|
@@ -2076,6 +2116,8 @@ export class Editor {
|
|
|
2076
2116
|
this.announce(`Stacked ${result.count} shapes ${result.direction}`);
|
|
2077
2117
|
}
|
|
2078
2118
|
groupSelected() {
|
|
2119
|
+
if (this.readOnly)
|
|
2120
|
+
return { kind: "noop" };
|
|
2079
2121
|
const result = computeGroupSelected(this._scene, this._selection, newGroupElementId(++this.nextId));
|
|
2080
2122
|
if (!result)
|
|
2081
2123
|
return { kind: "noop" };
|
|
@@ -2089,6 +2131,8 @@ export class Editor {
|
|
|
2089
2131
|
return { kind: "grouped", groupId: result.groupId };
|
|
2090
2132
|
}
|
|
2091
2133
|
ungroup() {
|
|
2134
|
+
if (this.readOnly)
|
|
2135
|
+
return;
|
|
2092
2136
|
const result = computeUngroup(this._scene, this._selection);
|
|
2093
2137
|
if (!result)
|
|
2094
2138
|
return;
|
|
@@ -2125,22 +2169,24 @@ export class Editor {
|
|
|
2125
2169
|
// scene to exactly where it was (cancelling the history tx alone wouldn't).
|
|
2126
2170
|
this.cancelGesture();
|
|
2127
2171
|
this.actor.send({ type: "POINTER_CANCEL" });
|
|
2128
|
-
this.
|
|
2129
|
-
this.edgePreview = null;
|
|
2130
|
-
this.lassoPreview = null;
|
|
2172
|
+
this.interaction.resetPreviews();
|
|
2131
2173
|
// Abort a host-managed link-from-anchor gesture too — it lives outside
|
|
2132
2174
|
// the machine, so POINTER_CANCEL above doesn't touch it. Without this a
|
|
2133
2175
|
// gesture left mid-flight would keep its preview after Escape.
|
|
2134
|
-
this.linkDragFromAnchor = null;
|
|
2135
|
-
this.
|
|
2136
|
-
this.
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
this.
|
|
2141
|
-
|
|
2142
|
-
//
|
|
2143
|
-
this.
|
|
2176
|
+
this.interaction.linkDragFromAnchor = null;
|
|
2177
|
+
this.interaction.editingLinkCaption = null;
|
|
2178
|
+
this.interaction.pendingLinkDropMenu = null;
|
|
2179
|
+
// Abort an in-progress eraser stroke (nothing deleted) and stop laying a
|
|
2180
|
+
// laser trail — both live outside the machine. Existing laser and eraser
|
|
2181
|
+
// trails keep fading via the tick (not hard-cleared here).
|
|
2182
|
+
this.interaction.eraseStroke = null;
|
|
2183
|
+
this.interaction.laserDrawing = false;
|
|
2184
|
+
// Drop any pending flowchart-create preview (Esc / global cancel abandons it).
|
|
2185
|
+
this.flowchartSession = null;
|
|
2186
|
+
// Waypoint / segment / endpoint-rebind drags: gestureTx.cancel above
|
|
2187
|
+
// already reverted the live re-point; just drop the handle-drag state so
|
|
2188
|
+
// the dots stop tracking.
|
|
2189
|
+
this.linkHandles.reset();
|
|
2144
2190
|
// Esc exits group-isolation if active. The selection that was
|
|
2145
2191
|
// active inside the group is dropped (Esc reads as a full
|
|
2146
2192
|
// "back out" — selecting the group is a separate gesture).
|
|
@@ -2156,8 +2202,9 @@ export class Editor {
|
|
|
2156
2202
|
* Duplicate the selected shapes 10 px down-right of the originals.
|
|
2157
2203
|
* Links between selected shapes are NOT cloned. Single undo step.
|
|
2158
2204
|
*/
|
|
2159
|
-
// Pure body in `./editor/public/selection-ops.ts`.
|
|
2160
2205
|
duplicateSelected() {
|
|
2206
|
+
if (this.readOnly)
|
|
2207
|
+
return;
|
|
2161
2208
|
const result = computeDuplicateSelection(this._scene, this._selection, () => ++this.nextId);
|
|
2162
2209
|
if (!result)
|
|
2163
2210
|
return;
|
|
@@ -2180,6 +2227,8 @@ export class Editor {
|
|
|
2180
2227
|
* originals. One undo step.
|
|
2181
2228
|
*/
|
|
2182
2229
|
duplicateSelectedInPlace(anchorId = null) {
|
|
2230
|
+
if (this.readOnly)
|
|
2231
|
+
return null;
|
|
2183
2232
|
if (this._selection.size === 0)
|
|
2184
2233
|
return null;
|
|
2185
2234
|
// Expand: selection + group descendants (parentId) + frame members (frameId).
|
|
@@ -2199,32 +2248,11 @@ export class Editor {
|
|
|
2199
2248
|
const idMap = new Map();
|
|
2200
2249
|
for (const id of ids)
|
|
2201
2250
|
idMap.set(id, castElementId(this.uniqueId("shape")));
|
|
2251
|
+
const dup = computeDuplicateInPlace(this._scene, ids, idMap);
|
|
2202
2252
|
const tx = this._history.transaction();
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
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
|
-
}
|
|
2253
|
+
this._scene = dup.scene;
|
|
2254
|
+
for (const patch of dup.patches)
|
|
2255
|
+
tx.add(patch);
|
|
2228
2256
|
tx.commit();
|
|
2229
2257
|
// Select the clones of the originally-selected ids.
|
|
2230
2258
|
const selectedClones = [];
|
|
@@ -2247,6 +2275,23 @@ export class Editor {
|
|
|
2247
2275
|
this._selectedLinks = LinkSelection.EMPTY;
|
|
2248
2276
|
this.notify();
|
|
2249
2277
|
}
|
|
2278
|
+
/**
|
|
2279
|
+
* Programmatically select a single link by id (or clear the link
|
|
2280
|
+
* selection with `null`), clearing the element selection so the link
|
|
2281
|
+
* becomes the sole selection. Used by host navigation (search / jump-to)
|
|
2282
|
+
* to frame an edge with {@link zoomToSelection}. No-op when nothing
|
|
2283
|
+
* would change.
|
|
2284
|
+
*/
|
|
2285
|
+
selectLink(id) {
|
|
2286
|
+
const nextLinks = id === null ? LinkSelection.EMPTY : LinkSelection.single(id);
|
|
2287
|
+
const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
|
|
2288
|
+
const elementsChanged = this._selection.size > 0;
|
|
2289
|
+
if (!linksChanged && !elementsChanged)
|
|
2290
|
+
return;
|
|
2291
|
+
this._selection = Selection.EMPTY;
|
|
2292
|
+
this._selectedLinks = nextLinks;
|
|
2293
|
+
this.notify();
|
|
2294
|
+
}
|
|
2250
2295
|
selectAll() {
|
|
2251
2296
|
const next = computeSelectAll(this._scene, this._selection);
|
|
2252
2297
|
const nextLinks = computeSelectAllLinks(this._scene);
|
|
@@ -2269,7 +2314,6 @@ export class Editor {
|
|
|
2269
2314
|
* `navigator.clipboard` (out of scope for the editor).
|
|
2270
2315
|
*/
|
|
2271
2316
|
clipboard = [];
|
|
2272
|
-
// Pure body in `./editor/public/clipboard.ts`.
|
|
2273
2317
|
copySelected() {
|
|
2274
2318
|
const out = copySelectedPure(this._scene, this._selection);
|
|
2275
2319
|
if (out.length === 0)
|
|
@@ -2278,6 +2322,8 @@ export class Editor {
|
|
|
2278
2322
|
this.announce(`Copied ${out.length} shapes`);
|
|
2279
2323
|
}
|
|
2280
2324
|
cutSelected() {
|
|
2325
|
+
if (this.readOnly)
|
|
2326
|
+
return;
|
|
2281
2327
|
this.copySelected();
|
|
2282
2328
|
this.deleteSelected();
|
|
2283
2329
|
}
|
|
@@ -2290,7 +2336,6 @@ export class Editor {
|
|
|
2290
2336
|
*
|
|
2291
2337
|
* New shapes get fresh ids and end up selected. Single undo step.
|
|
2292
2338
|
*/
|
|
2293
|
-
// Pure body in `./editor/public/clipboard.ts`.
|
|
2294
2339
|
paste(targetWorld) {
|
|
2295
2340
|
if (this.clipboard.length === 0)
|
|
2296
2341
|
return;
|
|
@@ -2308,21 +2353,489 @@ export class Editor {
|
|
|
2308
2353
|
this.announce(`Pasted ${result.newIds.length} shapes`);
|
|
2309
2354
|
}
|
|
2310
2355
|
/**
|
|
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.
|
|
2356
|
+
* Merge `partial` into the `style` of every shape in `ids`. Useful
|
|
2357
|
+
* for the inspector / PropertyPanel: flipping `roundness`, swapping
|
|
2358
|
+
* `lineJoin`, changing `stroke` colour across a multi-selection,
|
|
2359
|
+
* etc. All changes go through one history record (single undo).
|
|
2360
|
+
*
|
|
2361
|
+
* No-op when `ids` is empty or none of the targeted shapes exist.
|
|
2362
|
+
*/
|
|
2363
|
+
/**
|
|
2364
|
+
* Capture the visual style of the first selected element into the style
|
|
2365
|
+
* buffer, for a later {@link pasteSelectionStyle}. No-op / clears nothing
|
|
2366
|
+
* when the selection is empty.
|
|
2367
|
+
*/
|
|
2368
|
+
copySelectionStyle() {
|
|
2369
|
+
for (const id of this._selection) {
|
|
2370
|
+
const el = getElement(this._scene, id);
|
|
2371
|
+
if (el !== undefined) {
|
|
2372
|
+
this.styleClipboard = { ...el.style };
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
/** Apply the copied style (if any) to every selected element. One undo step. */
|
|
2378
|
+
pasteSelectionStyle() {
|
|
2379
|
+
if (this.styleClipboard === null)
|
|
2380
|
+
return;
|
|
2381
|
+
this.updateStyle(this._selection, this.styleClipboard);
|
|
2382
|
+
}
|
|
2383
|
+
/** Whether a style has been copied and can be pasted. */
|
|
2384
|
+
get hasStyleClipboard() {
|
|
2385
|
+
return this.styleClipboard !== null;
|
|
2386
|
+
}
|
|
2387
|
+
updateStyle(ids, partial) {
|
|
2388
|
+
if (this.readOnly)
|
|
2389
|
+
return;
|
|
2390
|
+
const result = computeUpdateStyle(this._scene, ids, partial);
|
|
2391
|
+
if (!result)
|
|
2392
|
+
return;
|
|
2393
|
+
this._scene = result.scene;
|
|
2394
|
+
this._history.push(result.patch);
|
|
2395
|
+
this.notify();
|
|
2396
|
+
}
|
|
2397
|
+
/**
|
|
2398
|
+
* Apply a partial text style (bold / italic / colour / decoration) to the
|
|
2399
|
+
* character range `[from, to)` of a single text element, producing styled
|
|
2400
|
+
* runs (rich text). One undo step. No-op when the id isn't a text shape or
|
|
2401
|
+
* the range is empty. Read-only editors ignore it. Use this — rather than
|
|
2402
|
+
* `updateStyle` — to style only PART of a text block (e.g. the current
|
|
2403
|
+
* inline-edit selection).
|
|
2404
|
+
*/
|
|
2405
|
+
applyTextStyleToRange(id, from, to, partial) {
|
|
2406
|
+
if (this.readOnly)
|
|
2407
|
+
return;
|
|
2408
|
+
const result = computeApplyTextRunStyle(this._scene, id, from, to, partial);
|
|
2409
|
+
if (!result)
|
|
2410
|
+
return;
|
|
2411
|
+
this._scene = result.scene;
|
|
2412
|
+
this._history.push(result.patch);
|
|
2413
|
+
this.notify();
|
|
2414
|
+
}
|
|
2415
|
+
// --- F8: Eyedropper ---------------------------------------------------
|
|
2416
|
+
/**
|
|
2417
|
+
* The fill (or stroke, per `role`) colour of the top-most shape under the
|
|
2418
|
+
* world point, or `null` on empty canvas. Pure read — no mutation.
|
|
2419
|
+
*/
|
|
2420
|
+
pickColorAt(worldPoint, role = "fill") {
|
|
2421
|
+
return pickColorAt(this._scene, worldPoint, role);
|
|
2422
|
+
}
|
|
2423
|
+
/** One-shot callback armed by {@link beginEyedropperPick}; consumes the next canvas click. */
|
|
2424
|
+
pendingEyedropperPick = null;
|
|
2425
|
+
/** `true` while a colour-picker pipette is armed and waiting for a canvas click. */
|
|
2426
|
+
get isEyedropperArmed() {
|
|
2427
|
+
return this.pendingEyedropperPick !== null;
|
|
2428
|
+
}
|
|
2429
|
+
/**
|
|
2430
|
+
* Arm the eyedropper for a one-shot canvas pick that routes the sampled colour
|
|
2431
|
+
* to `onPick` (e.g. a colour-picker swatch) instead of the selection fill. Does
|
|
2432
|
+
* NOT change the tool mode — the next canvas press is intercepted by
|
|
2433
|
+
* {@link applyEyedropperAt}. Cancelled by a mode switch or an empty-canvas click.
|
|
2434
|
+
*/
|
|
2435
|
+
beginEyedropperPick(onPick) {
|
|
2436
|
+
this.pendingEyedropperPick = onPick;
|
|
2437
|
+
this.refreshCursor();
|
|
2438
|
+
this.notify();
|
|
2439
|
+
}
|
|
2440
|
+
/**
|
|
2441
|
+
* Sample the colour under `worldPoint`. When a pipette pick is armed (see
|
|
2442
|
+
* {@link beginEyedropperPick}), route the colour to that callback and disarm.
|
|
2443
|
+
* Otherwise (legacy tool path) apply it as the current selection's fill and
|
|
2444
|
+
* revert to `select` mode. Returns the sampled colour, or `null` on empty
|
|
2445
|
+
* canvas. Read-only editors sample but don't mutate.
|
|
2446
|
+
*/
|
|
2447
|
+
applyEyedropperAt(worldPoint) {
|
|
2448
|
+
const color = pickColorAt(this._scene, worldPoint, "fill");
|
|
2449
|
+
const pick = this.pendingEyedropperPick;
|
|
2450
|
+
if (pick !== null) {
|
|
2451
|
+
this.pendingEyedropperPick = null;
|
|
2452
|
+
if (color !== null)
|
|
2453
|
+
pick(color);
|
|
2454
|
+
this.refreshCursor();
|
|
2455
|
+
this.notify();
|
|
2456
|
+
return color;
|
|
2457
|
+
}
|
|
2458
|
+
if (color === null)
|
|
2459
|
+
return null;
|
|
2460
|
+
if (!this.readOnly && this._selection.size > 0) {
|
|
2461
|
+
this.updateStyle(this._selection, { fill: color });
|
|
2462
|
+
}
|
|
2463
|
+
if (this.mode === "eyedropper" && !this.toolLocked)
|
|
2464
|
+
this.setMode("select");
|
|
2465
|
+
return color;
|
|
2466
|
+
}
|
|
2467
|
+
// --- F9: Convert element type ----------------------------------------
|
|
2468
|
+
/**
|
|
2469
|
+
* Convert every convertible selected shape (rectangle / ellipse / diamond)
|
|
2470
|
+
* to `target`, preserving position, size and style. One undo step; no-op
|
|
2471
|
+
* when nothing applies. See {@link ConvertTarget}.
|
|
2472
|
+
*/
|
|
2473
|
+
convertSelection(target) {
|
|
2474
|
+
if (this.readOnly)
|
|
2475
|
+
return;
|
|
2476
|
+
const result = computeConvertType(this._scene, this._selection, target);
|
|
2477
|
+
if (!result)
|
|
2478
|
+
return;
|
|
2479
|
+
this._scene = result.scene;
|
|
2480
|
+
this._history.push(result.patch);
|
|
2481
|
+
this.notify();
|
|
2482
|
+
}
|
|
2483
|
+
// --- F11: Spawn connected node ---------------------------------------
|
|
2484
|
+
/**
|
|
2485
|
+
* Flowchart auto-generate: clone the single selected node offset in
|
|
2486
|
+
* `direction` and connect the two with a fresh link. Selects the new node.
|
|
2487
|
+
* No-op unless exactly one element is selected. One undo step.
|
|
2488
|
+
*/
|
|
2489
|
+
spawnConnectedNode(direction) {
|
|
2490
|
+
if (this.readOnly)
|
|
2491
|
+
return;
|
|
2492
|
+
const ids = [...this._selection];
|
|
2493
|
+
const sourceId = ids.length === 1 ? ids[0] : undefined;
|
|
2494
|
+
if (sourceId === undefined)
|
|
2495
|
+
return;
|
|
2496
|
+
const result = computeSpawnConnectedNode(this._scene, sourceId, direction, newElementId(++this.nextId), newLinkId(++this.nextId));
|
|
2497
|
+
if (!result)
|
|
2498
|
+
return;
|
|
2499
|
+
this._scene = result.scene;
|
|
2500
|
+
this._history.push({ kind: "batch", patches: [...result.patches] });
|
|
2501
|
+
this.setSelection([result.newElementId]);
|
|
2502
|
+
this.notify();
|
|
2503
|
+
}
|
|
2504
|
+
// --- Flowchart CREATE session (Cmd/Ctrl+Arrow, Excalidraw-style) ------
|
|
2505
|
+
/**
|
|
2506
|
+
* Pending flowchart-create session, or `null` when idle. Holds the ORIGINAL
|
|
2507
|
+
* source id + direction, the current sibling `count`, and the pending
|
|
2508
|
+
* `elements` + `links` (a PREVIEW — not yet in the scene / history). Grown by
|
|
2509
|
+
* {@link growFlowchart}, committed by {@link commitFlowchart}, discarded by
|
|
2510
|
+
* {@link cancelFlowchart}.
|
|
2511
|
+
*/
|
|
2512
|
+
flowchartSession = null;
|
|
2513
|
+
/**
|
|
2514
|
+
* Grow the flowchart-create preview one step in `direction`. Starts a session
|
|
2515
|
+
* (count = 1) when idle or when the direction changes; otherwise bumps the
|
|
2516
|
+
* sibling count up to {@link FLOWCHART_MAX_SIBLINGS}. Recomputes the pending
|
|
2517
|
+
* nodes/links from the ORIGINAL source each call. PREVIEW ONLY — never
|
|
2518
|
+
* touches the scene or history until {@link commitFlowchart}. No-op in
|
|
2519
|
+
* read-only mode or unless exactly one element is selected.
|
|
2520
|
+
*/
|
|
2521
|
+
growFlowchart(direction) {
|
|
2522
|
+
if (this._readOnly)
|
|
2523
|
+
return;
|
|
2524
|
+
if (this._selection.size !== 1)
|
|
2525
|
+
return;
|
|
2526
|
+
const ids = [...this._selection];
|
|
2527
|
+
const sourceId = ids[0];
|
|
2528
|
+
if (sourceId === undefined || getElement(this._scene, sourceId) === undefined)
|
|
2529
|
+
return;
|
|
2530
|
+
const session = this.flowchartSession;
|
|
2531
|
+
const count = session?.direction === direction && session.sourceId === sourceId
|
|
2532
|
+
? Math.min(session.count + 1, FLOWCHART_MAX_SIBLINGS)
|
|
2533
|
+
: 1;
|
|
2534
|
+
const { elements, links } = computeSpawnConnectedNodes(this._scene, sourceId, direction, count, () => newElementId(++this.nextId), () => newLinkId(++this.nextId));
|
|
2535
|
+
this.flowchartSession = { sourceId, direction, count, elements, links };
|
|
2536
|
+
this.notify();
|
|
2537
|
+
}
|
|
2538
|
+
/**
|
|
2539
|
+
* Commit the pending flowchart-create preview: add every pending node + link
|
|
2540
|
+
* to the scene as ONE undo step, select the first new node, clear the
|
|
2541
|
+
* session. Returns the first new node's id, or `null` when no session is
|
|
2542
|
+
* active.
|
|
2543
|
+
*/
|
|
2544
|
+
commitFlowchart() {
|
|
2545
|
+
const session = this.flowchartSession;
|
|
2546
|
+
if (session === null)
|
|
2547
|
+
return null;
|
|
2548
|
+
let s = this._scene;
|
|
2549
|
+
const patches = [];
|
|
2550
|
+
for (const el of session.elements) {
|
|
2551
|
+
const r = addElement(s, el);
|
|
2552
|
+
s = r.scene;
|
|
2553
|
+
patches.push(r.patch);
|
|
2554
|
+
}
|
|
2555
|
+
for (const link of session.links) {
|
|
2556
|
+
const r = addLink(s, link);
|
|
2557
|
+
s = r.scene;
|
|
2558
|
+
patches.push(r.patch);
|
|
2559
|
+
}
|
|
2560
|
+
this.flowchartSession = null;
|
|
2561
|
+
const first = session.elements[0]?.id ?? null;
|
|
2562
|
+
if (patches.length === 0) {
|
|
2563
|
+
this.notify();
|
|
2564
|
+
return first;
|
|
2565
|
+
}
|
|
2566
|
+
this._scene = s;
|
|
2567
|
+
this._history.push({ kind: "batch", patches });
|
|
2568
|
+
if (first !== null)
|
|
2569
|
+
this.setSelection([first]);
|
|
2570
|
+
this.notify();
|
|
2571
|
+
return first;
|
|
2572
|
+
}
|
|
2573
|
+
/** Discard the pending flowchart-create preview without committing. */
|
|
2574
|
+
cancelFlowchart() {
|
|
2575
|
+
if (this.flowchartSession === null)
|
|
2576
|
+
return;
|
|
2577
|
+
this.flowchartSession = null;
|
|
2578
|
+
this.notify();
|
|
2579
|
+
}
|
|
2580
|
+
/**
|
|
2581
|
+
* The pending flowchart-create preview (nodes + links), or `null` when no
|
|
2582
|
+
* session is active. Read by the render snapshot to paint the preview on the
|
|
2583
|
+
* overlay. Reference-stable between renders (only changes on grow / commit /
|
|
2584
|
+
* cancel) so the overlay memo doesn't thrash.
|
|
2585
|
+
*/
|
|
2586
|
+
get flowchartPreview() {
|
|
2587
|
+
const session = this.flowchartSession;
|
|
2588
|
+
if (session === null)
|
|
2589
|
+
return null;
|
|
2590
|
+
return { elements: session.elements, links: session.links };
|
|
2591
|
+
}
|
|
2592
|
+
/**
|
|
2593
|
+
* Move the selection to an adjacent node. With exactly one element selected,
|
|
2594
|
+
* prefers a graph neighbour (linked node) best aligned with `direction`;
|
|
2595
|
+
* falls back to the spatial {@link selectClosest} when no neighbour lies that
|
|
2596
|
+
* way. No-op unless exactly one element is selected.
|
|
2597
|
+
*/
|
|
2598
|
+
navigateFlowchart(direction) {
|
|
2599
|
+
if (this._selection.size !== 1)
|
|
2600
|
+
return;
|
|
2601
|
+
const ids = [...this._selection];
|
|
2602
|
+
const sourceId = ids[0];
|
|
2603
|
+
if (sourceId === undefined)
|
|
2604
|
+
return;
|
|
2605
|
+
// Graph neighbours: every element linked to the source by any edge.
|
|
2606
|
+
const neighbours = new Set();
|
|
2607
|
+
for (const link of this._scene.links.values()) {
|
|
2608
|
+
const a = endpointElementId(link.from);
|
|
2609
|
+
const b = endpointElementId(link.to);
|
|
2610
|
+
if (a === sourceId && b !== undefined && b !== sourceId)
|
|
2611
|
+
neighbours.add(b);
|
|
2612
|
+
else if (b === sourceId && a !== undefined && a !== sourceId)
|
|
2613
|
+
neighbours.add(a);
|
|
2614
|
+
}
|
|
2615
|
+
if (neighbours.size > 0) {
|
|
2616
|
+
const ref = this.combinedSelectionBounds();
|
|
2617
|
+
const refCenter = ref
|
|
2618
|
+
? { x: ref.x + ref.width / 2, y: ref.y + ref.height / 2 }
|
|
2619
|
+
: { x: 0, y: 0 };
|
|
2620
|
+
const best = findClosestInDirection(this._scene, this._selection, direction, refCenter, (el) => this.isElementInteractable(el) && neighbours.has(el.id));
|
|
2621
|
+
if (best !== null) {
|
|
2622
|
+
this.setSelection([best]);
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
// No graph neighbour that way — fall back to spatial nearest.
|
|
2627
|
+
this.selectClosest(direction);
|
|
2628
|
+
}
|
|
2629
|
+
// --- F10: Image crop --------------------------------------------------
|
|
2630
|
+
/**
|
|
2631
|
+
* Live image-crop session, or `null` when not cropping. Excalidraw-style:
|
|
2632
|
+
* the crop frame IS the element's visible box, and the user drags edge /
|
|
2633
|
+
* corner handles inward (hides pixels) or the image body (pans the source).
|
|
2634
|
+
*
|
|
2635
|
+
* - `id` — the image being cropped.
|
|
2636
|
+
* - `crop` — pending normalised source rect.
|
|
2637
|
+
* - `position` / `width` / `height` — the pending element box (world position
|
|
2638
|
+
* + local size); a handle drag moves them, a body pan leaves them fixed.
|
|
2639
|
+
* - `drag` — the active gesture, or `null` when only hovering.
|
|
2640
|
+
* - `dragStartWorld` — pointer world position at drag start (body pan basis).
|
|
2641
|
+
*
|
|
2642
|
+
* Seeded on {@link beginImageCrop}; committed by {@link commitImageCrop} (one
|
|
2643
|
+
* undo step), abandoned by {@link cancelImageCrop}.
|
|
2644
|
+
*/
|
|
2645
|
+
cropSession = null;
|
|
2646
|
+
/** The image-crop session (read-only accessor for UI / overlay). */
|
|
2647
|
+
get imageCropSession() {
|
|
2648
|
+
const s = this.cropSession;
|
|
2649
|
+
if (s === null)
|
|
2650
|
+
return null;
|
|
2651
|
+
return { id: s.id, crop: s.crop, position: s.position, width: s.width, height: s.height };
|
|
2652
|
+
}
|
|
2653
|
+
/**
|
|
2654
|
+
* Enter crop mode for the image `id`, seeding the pending crop / box from its
|
|
2655
|
+
* current state (or the full image). No-op for non-image shapes or in
|
|
2656
|
+
* read-only. Typically triggered by a double-click on an image.
|
|
2657
|
+
*/
|
|
2658
|
+
beginImageCrop(id) {
|
|
2659
|
+
if (this.readOnly)
|
|
2660
|
+
return;
|
|
2661
|
+
const el = getElement(this._scene, id);
|
|
2662
|
+
if (el === undefined || !isImage(el))
|
|
2663
|
+
return;
|
|
2664
|
+
this.cancelInteraction();
|
|
2665
|
+
this._selection = Selection.single(id);
|
|
2666
|
+
this.cropSession = {
|
|
2667
|
+
id,
|
|
2668
|
+
crop: el.crop ?? FULL_CROP,
|
|
2669
|
+
position: el.position,
|
|
2670
|
+
width: el.width,
|
|
2671
|
+
height: el.height,
|
|
2672
|
+
drag: null,
|
|
2673
|
+
dragStartWorld: null,
|
|
2674
|
+
};
|
|
2675
|
+
this.setMode("crop");
|
|
2676
|
+
this.refreshCursor();
|
|
2677
|
+
this.notify();
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* Hit-test `worldPoint` against the pending crop chrome: a crop handle when
|
|
2681
|
+
* within {@link CROP_HANDLE_HIT_RADIUS} (screen px, zoom-compensated) of one,
|
|
2682
|
+
* `"body"` when inside the window, else `null`. Returns `null` when not
|
|
2683
|
+
* cropping.
|
|
2684
|
+
*/
|
|
2685
|
+
cropHandleAtWorld(worldPoint) {
|
|
2686
|
+
const session = this.cropSession;
|
|
2687
|
+
if (session === null)
|
|
2688
|
+
return null;
|
|
2689
|
+
const el = getElement(this._scene, session.id);
|
|
2690
|
+
if (el === undefined)
|
|
2691
|
+
return null;
|
|
2692
|
+
const pending = this.pendingCropElement(el);
|
|
2693
|
+
const points = cropHandleWorldPoints(pending);
|
|
2694
|
+
const zoom = this._scene.viewport.zoom || 1;
|
|
2695
|
+
const radius = CROP_HANDLE_HIT_RADIUS / zoom;
|
|
2696
|
+
for (const handle of CROP_HANDLES) {
|
|
2697
|
+
const p = points[handle];
|
|
2698
|
+
if (Math.hypot(worldPoint.x - p.x, worldPoint.y - p.y) <= radius)
|
|
2699
|
+
return handle;
|
|
2700
|
+
}
|
|
2701
|
+
const local = worldToLocal(pending, worldPoint);
|
|
2702
|
+
if (local.x >= 0 && local.x <= session.width && local.y >= 0 && local.y <= session.height) {
|
|
2703
|
+
return "body";
|
|
2704
|
+
}
|
|
2705
|
+
return null;
|
|
2706
|
+
}
|
|
2707
|
+
/** Begin dragging crop handle `handle` from `worldPoint`. */
|
|
2708
|
+
beginImageCropHandle(handle, worldPoint) {
|
|
2709
|
+
if (this.cropSession === null)
|
|
2710
|
+
return;
|
|
2711
|
+
this.cropSession = {
|
|
2712
|
+
...this.cropSession,
|
|
2713
|
+
drag: { kind: "handle", handle },
|
|
2714
|
+
dragStartWorld: worldPoint,
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
/** Begin panning the image body under the fixed window from `worldPoint`. */
|
|
2718
|
+
beginImageCropBody(worldPoint) {
|
|
2719
|
+
if (this.cropSession === null)
|
|
2720
|
+
return;
|
|
2721
|
+
this.cropSession = { ...this.cropSession, drag: { kind: "body" }, dragStartWorld: worldPoint };
|
|
2722
|
+
}
|
|
2723
|
+
/**
|
|
2724
|
+
* Update the active crop drag to `worldPoint` — resize the window (handle) or
|
|
2725
|
+
* pan the source (body). Geometry is recomputed from the ORIGINAL element so
|
|
2726
|
+
* it stays stable across many moves. No-op when no drag is active.
|
|
2727
|
+
*/
|
|
2728
|
+
updateImageCropDrag(worldPoint) {
|
|
2729
|
+
const session = this.cropSession;
|
|
2730
|
+
if (session?.drag == null || session.dragStartWorld === null)
|
|
2731
|
+
return;
|
|
2732
|
+
const el = getElement(this._scene, session.id);
|
|
2733
|
+
if (el === undefined)
|
|
2734
|
+
return;
|
|
2735
|
+
const baseCrop = el.crop ?? FULL_CROP;
|
|
2736
|
+
if (session.drag.kind === "handle") {
|
|
2737
|
+
const r = computeCropHandleDrag(el, baseCrop, session.drag.handle, worldPoint);
|
|
2738
|
+
this.cropSession = {
|
|
2739
|
+
...session,
|
|
2740
|
+
crop: r.crop,
|
|
2741
|
+
position: r.position,
|
|
2742
|
+
width: r.width,
|
|
2743
|
+
height: r.height,
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2746
|
+
else {
|
|
2747
|
+
const r = computeCropBodyPan(el, baseCrop, session.dragStartWorld, worldPoint);
|
|
2748
|
+
this.cropSession = { ...session, crop: r.crop };
|
|
2749
|
+
}
|
|
2750
|
+
this.notify();
|
|
2751
|
+
}
|
|
2752
|
+
/** Finish the current crop drag (keeps the pending crop / box). */
|
|
2753
|
+
endImageCropDrag() {
|
|
2754
|
+
if (this.cropSession === null)
|
|
2755
|
+
return;
|
|
2756
|
+
this.cropSession = { ...this.cropSession, drag: null, dragStartWorld: null };
|
|
2757
|
+
}
|
|
2758
|
+
/** Apply the pending crop + box and leave crop mode. One undo step. */
|
|
2759
|
+
commitImageCrop() {
|
|
2760
|
+
const session = this.cropSession;
|
|
2761
|
+
if (session === null)
|
|
2762
|
+
return;
|
|
2763
|
+
const result = computeCommitImageCrop(this._scene, session.id, {
|
|
2764
|
+
crop: session.crop,
|
|
2765
|
+
position: session.position,
|
|
2766
|
+
width: session.width,
|
|
2767
|
+
height: session.height,
|
|
2768
|
+
});
|
|
2769
|
+
this.cropSession = null;
|
|
2770
|
+
if (result) {
|
|
2771
|
+
this._scene = result.scene;
|
|
2772
|
+
this._history.push(result.patch);
|
|
2773
|
+
}
|
|
2774
|
+
this.setMode("select");
|
|
2775
|
+
this.refreshCursor();
|
|
2776
|
+
this.notify();
|
|
2777
|
+
}
|
|
2778
|
+
/** Abandon the crop session without changing the image. */
|
|
2779
|
+
cancelImageCrop() {
|
|
2780
|
+
if (this.cropSession === null)
|
|
2781
|
+
return;
|
|
2782
|
+
this.cropSession = null;
|
|
2783
|
+
this.setMode("select");
|
|
2784
|
+
this.refreshCursor();
|
|
2785
|
+
this.notify();
|
|
2786
|
+
}
|
|
2787
|
+
/**
|
|
2788
|
+
* Synthetic element carrying the PENDING crop box (position / size) over the
|
|
2789
|
+
* original element's rotation / scale — the frame the user currently sees.
|
|
2790
|
+
* Used to project the crop frame and handles.
|
|
2791
|
+
*/
|
|
2792
|
+
pendingCropElement(el) {
|
|
2793
|
+
const session = this.cropSession;
|
|
2794
|
+
if (session === null)
|
|
2795
|
+
return el;
|
|
2796
|
+
return {
|
|
2797
|
+
...el,
|
|
2798
|
+
position: session.position,
|
|
2799
|
+
width: session.width,
|
|
2800
|
+
height: session.height,
|
|
2801
|
+
};
|
|
2802
|
+
}
|
|
2803
|
+
/**
|
|
2804
|
+
* World-space corners (clockwise) of the pending crop frame, or `null` when
|
|
2805
|
+
* not cropping. The frame is the pending element box mapped through its
|
|
2806
|
+
* local→world transform (so rotation / scale are honoured).
|
|
2317
2807
|
*/
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
this.
|
|
2808
|
+
cropFrameCorners() {
|
|
2809
|
+
const session = this.cropSession;
|
|
2810
|
+
if (session === null)
|
|
2811
|
+
return null;
|
|
2812
|
+
const el = getElement(this._scene, session.id);
|
|
2813
|
+
if (el === undefined)
|
|
2814
|
+
return null;
|
|
2815
|
+
const pending = this.pendingCropElement(el);
|
|
2816
|
+
const b = getElementLocalBounds(pending);
|
|
2817
|
+
return [
|
|
2818
|
+
{ x: b.x, y: b.y },
|
|
2819
|
+
{ x: b.x + b.width, y: b.y },
|
|
2820
|
+
{ x: b.x + b.width, y: b.y + b.height },
|
|
2821
|
+
{ x: b.x, y: b.y + b.height },
|
|
2822
|
+
].map((p) => localToWorld(pending, p));
|
|
2823
|
+
}
|
|
2824
|
+
/**
|
|
2825
|
+
* Ghost-image overlay descriptor for the crop session: the ORIGINAL element
|
|
2826
|
+
* (its transform + live bitmap handle) and the virtual full-image LOCAL rect
|
|
2827
|
+
* the whole bitmap occupies. `null` when not cropping. The overlay paints the
|
|
2828
|
+
* full bitmap faintly over this rect so hidden parts stay visible.
|
|
2829
|
+
*/
|
|
2830
|
+
cropGhost() {
|
|
2831
|
+
const session = this.cropSession;
|
|
2832
|
+
if (session === null)
|
|
2833
|
+
return null;
|
|
2834
|
+
const el = getElement(this._scene, session.id);
|
|
2835
|
+
if (el === undefined)
|
|
2836
|
+
return null;
|
|
2837
|
+
const baseCrop = el.crop ?? FULL_CROP;
|
|
2838
|
+
return { element: el, fullRect: cropFullImageLocalRect(el, baseCrop) };
|
|
2326
2839
|
}
|
|
2327
2840
|
/**
|
|
2328
2841
|
* Update non-style text properties (`fontSize`, `fontFamily`,
|
|
@@ -2330,6 +2843,8 @@ export class Editor {
|
|
|
2330
2843
|
* skipped. Single undo step. Used by the text contextual panel.
|
|
2331
2844
|
*/
|
|
2332
2845
|
updateTextProps(ids, partial) {
|
|
2846
|
+
if (this.readOnly)
|
|
2847
|
+
return;
|
|
2333
2848
|
const result = computeUpdateTextProps(this._scene, ids, partial);
|
|
2334
2849
|
if (!result)
|
|
2335
2850
|
return;
|
|
@@ -2337,6 +2852,21 @@ export class Editor {
|
|
|
2337
2852
|
this._history.push(result.patch);
|
|
2338
2853
|
this.notify();
|
|
2339
2854
|
}
|
|
2855
|
+
/**
|
|
2856
|
+
* Step the font size of every selected text shape up or down by one gentle
|
|
2857
|
+
* multiplicative increment (each shape relative to its own size). One
|
|
2858
|
+
* undoable step; no-op when no text is selected.
|
|
2859
|
+
*/
|
|
2860
|
+
adjustSelectionFontSize(direction) {
|
|
2861
|
+
if (this.readOnly)
|
|
2862
|
+
return;
|
|
2863
|
+
const result = computeAdjustFontSize(this._scene, this._selection, direction);
|
|
2864
|
+
if (!result)
|
|
2865
|
+
return;
|
|
2866
|
+
this._scene = result.scene;
|
|
2867
|
+
this._history.push(result.patch);
|
|
2868
|
+
this.notify();
|
|
2869
|
+
}
|
|
2340
2870
|
/**
|
|
2341
2871
|
* Set (or clear, with `null`) the element-level hyperlink (`href`) on
|
|
2342
2872
|
* every shape in `ids`. Single undo step. Pass a raw user string — it
|
|
@@ -2346,6 +2876,8 @@ export class Editor {
|
|
|
2346
2876
|
* hover link-popup.
|
|
2347
2877
|
*/
|
|
2348
2878
|
setLink(ids, href) {
|
|
2879
|
+
if (this.readOnly)
|
|
2880
|
+
return;
|
|
2349
2881
|
const normalized = href === null ? null : normalizeHref(href);
|
|
2350
2882
|
const result = computeSetLink(this._scene, ids, normalized);
|
|
2351
2883
|
if (!result)
|
|
@@ -2383,8 +2915,9 @@ export class Editor {
|
|
|
2383
2915
|
return null;
|
|
2384
2916
|
return { id: shape.id, href, bounds: getElementWorldBounds(shape) };
|
|
2385
2917
|
}
|
|
2386
|
-
// Pure bodies in `./editor/public/z-order.ts`.
|
|
2387
2918
|
bringToFront(id) {
|
|
2919
|
+
if (this.readOnly)
|
|
2920
|
+
return;
|
|
2388
2921
|
const result = computeBringToFront(this._scene, id, this._selection);
|
|
2389
2922
|
if (!result)
|
|
2390
2923
|
return;
|
|
@@ -2393,6 +2926,8 @@ export class Editor {
|
|
|
2393
2926
|
this.notify();
|
|
2394
2927
|
}
|
|
2395
2928
|
sendToBack(id) {
|
|
2929
|
+
if (this.readOnly)
|
|
2930
|
+
return;
|
|
2396
2931
|
const result = computeSendToBack(this._scene, id, this._selection);
|
|
2397
2932
|
if (!result)
|
|
2398
2933
|
return;
|
|
@@ -2402,6 +2937,8 @@ export class Editor {
|
|
|
2402
2937
|
}
|
|
2403
2938
|
/** Move the target shape one step toward the top of its layer. */
|
|
2404
2939
|
bringForward(id) {
|
|
2940
|
+
if (this.readOnly)
|
|
2941
|
+
return;
|
|
2405
2942
|
const result = computeBringForward(this._scene, id, this._selection);
|
|
2406
2943
|
if (!result)
|
|
2407
2944
|
return;
|
|
@@ -2411,6 +2948,8 @@ export class Editor {
|
|
|
2411
2948
|
}
|
|
2412
2949
|
/** Move the target shape one step toward the bottom of its layer. */
|
|
2413
2950
|
sendBackward(id) {
|
|
2951
|
+
if (this.readOnly)
|
|
2952
|
+
return;
|
|
2414
2953
|
const result = computeSendBackward(this._scene, id, this._selection);
|
|
2415
2954
|
if (!result)
|
|
2416
2955
|
return;
|
|
@@ -2418,6 +2957,64 @@ export class Editor {
|
|
|
2418
2957
|
this._history.push(result.patch);
|
|
2419
2958
|
this.notify();
|
|
2420
2959
|
}
|
|
2960
|
+
/**
|
|
2961
|
+
* Mirror the current selection about its bounding-box centre on the given
|
|
2962
|
+
* axis — `horizontal` flips left↔right, `vertical` flips top↔bottom. A single
|
|
2963
|
+
* element flips about its own centre. One undoable step.
|
|
2964
|
+
*/
|
|
2965
|
+
flipSelection(axis) {
|
|
2966
|
+
this.commitArrange(computeFlipPatches(this._scene, this._selection, axis));
|
|
2967
|
+
}
|
|
2968
|
+
/**
|
|
2969
|
+
* Align the selection to the given edge / centre line of its bounding box
|
|
2970
|
+
* (e.g. `left` flushes every shape's left edge; `h-center` lines up
|
|
2971
|
+
* horizontal centres). Needs two or more elements. One undoable step.
|
|
2972
|
+
*/
|
|
2973
|
+
alignSelection(edge) {
|
|
2974
|
+
this.commitArrange(computeAlignPatches(this._scene, this._selection, edge));
|
|
2975
|
+
}
|
|
2976
|
+
/**
|
|
2977
|
+
* Evenly space the selection along the given axis so the gaps between
|
|
2978
|
+
* adjacent shapes are equal; the outermost shapes stay put. Needs three or
|
|
2979
|
+
* more elements. One undoable step.
|
|
2980
|
+
*/
|
|
2981
|
+
distributeSelection(axis) {
|
|
2982
|
+
this.commitArrange(computeDistributePatches(this._scene, this._selection, axis));
|
|
2983
|
+
}
|
|
2984
|
+
/**
|
|
2985
|
+
* Rotate the whole selection by `delta` radians about its bounding-box centre
|
|
2986
|
+
* (a single shape turns about its own centre). One undoable step. The live
|
|
2987
|
+
* rotate gesture drives the same maths from a press-time snapshot.
|
|
2988
|
+
*/
|
|
2989
|
+
rotateSelection(delta) {
|
|
2990
|
+
const elements = [];
|
|
2991
|
+
const origin = new Map();
|
|
2992
|
+
for (const id of this._selection) {
|
|
2993
|
+
const el = getElement(this._scene, id);
|
|
2994
|
+
if (el === undefined)
|
|
2995
|
+
continue;
|
|
2996
|
+
elements.push(el);
|
|
2997
|
+
origin.set(id, { position: el.position, rotation: el.rotation });
|
|
2998
|
+
}
|
|
2999
|
+
if (elements.length === 0)
|
|
3000
|
+
return;
|
|
3001
|
+
const pivot = selectionCenter(elements);
|
|
3002
|
+
this.commitArrange(computeRotatePatches(this._scene, origin, pivot, delta));
|
|
3003
|
+
}
|
|
3004
|
+
/** Apply a batch of arrange patches as a single undoable step. */
|
|
3005
|
+
commitArrange(patches) {
|
|
3006
|
+
if (this.readOnly)
|
|
3007
|
+
return;
|
|
3008
|
+
if (patches.length === 0)
|
|
3009
|
+
return;
|
|
3010
|
+
const tx = this._history.transaction();
|
|
3011
|
+
for (const patch of patches) {
|
|
3012
|
+
this._scene = apply(this._scene, patch);
|
|
3013
|
+
tx.add(patch);
|
|
3014
|
+
}
|
|
3015
|
+
tx.commit();
|
|
3016
|
+
this.notify();
|
|
3017
|
+
}
|
|
2421
3018
|
compactLayerZOrder(layerId, options = {}) {
|
|
2422
3019
|
const recordHistory = options.recordHistory ?? true;
|
|
2423
3020
|
const layerIds = layerId ? [layerId] : [...this._scene.layers.keys()];
|
|
@@ -2442,6 +3039,8 @@ export class Editor {
|
|
|
2442
3039
|
* surprising and the operation is rarely chained with other edits.
|
|
2443
3040
|
*/
|
|
2444
3041
|
clear() {
|
|
3042
|
+
if (this.readOnly)
|
|
3043
|
+
return;
|
|
2445
3044
|
if (this._scene.elements.size === 0 && this._scene.links.size === 0)
|
|
2446
3045
|
return;
|
|
2447
3046
|
this._scene = {
|
|
@@ -2468,7 +3067,6 @@ export class Editor {
|
|
|
2468
3067
|
this._activeLayerId = id;
|
|
2469
3068
|
this.notify();
|
|
2470
3069
|
}
|
|
2471
|
-
// Pure bodies in `./editor/public/layers.ts`.
|
|
2472
3070
|
createLayer(name) {
|
|
2473
3071
|
const result = computeCreateLayer(this._scene, name, newLayerId(++this.nextId));
|
|
2474
3072
|
this._scene = result.scene;
|
|
@@ -2515,6 +3113,8 @@ export class Editor {
|
|
|
2515
3113
|
this.notify();
|
|
2516
3114
|
}
|
|
2517
3115
|
moveSelectionToLayer(targetLayer) {
|
|
3116
|
+
if (this.readOnly)
|
|
3117
|
+
return;
|
|
2518
3118
|
const result = computeMoveSelectionToLayer(this._scene, this._selection, targetLayer);
|
|
2519
3119
|
if (!result)
|
|
2520
3120
|
return;
|
|
@@ -2532,7 +3132,6 @@ export class Editor {
|
|
|
2532
3132
|
* x → shapes move right relative to the user). Not recorded in
|
|
2533
3133
|
* history — viewport state is editor-local.
|
|
2534
3134
|
*/
|
|
2535
|
-
// Pure bodies in `./editor/public/zoom-pan.ts`.
|
|
2536
3135
|
panBy(deltaScreen) {
|
|
2537
3136
|
const next = computePan(this._scene, deltaScreen);
|
|
2538
3137
|
if (!next)
|
|
@@ -2578,6 +3177,22 @@ export class Editor {
|
|
|
2578
3177
|
this._scene = next;
|
|
2579
3178
|
this.notify();
|
|
2580
3179
|
}
|
|
3180
|
+
/**
|
|
3181
|
+
* Center the camera on the current selection for a reveal / jump-to (search
|
|
3182
|
+
* navigation). Unlike {@link zoomToSelection}, it does NOT fill the screen —
|
|
3183
|
+
* a small match keeps its size and is merely centered; the zoom only drops to
|
|
3184
|
+
* fit an oversized match. No-op when the selection is empty.
|
|
3185
|
+
*/
|
|
3186
|
+
revealSelection(padding = 80) {
|
|
3187
|
+
const bounds = this.combinedSelectionBounds();
|
|
3188
|
+
if (!bounds)
|
|
3189
|
+
return;
|
|
3190
|
+
const next = computeRevealBounds(this._scene, bounds, padding);
|
|
3191
|
+
if (!next)
|
|
3192
|
+
return;
|
|
3193
|
+
this._scene = next;
|
|
3194
|
+
this.notify();
|
|
3195
|
+
}
|
|
2581
3196
|
/**
|
|
2582
3197
|
* Select the nearest interactable top-level element in `direction` from the
|
|
2583
3198
|
* current selection's centre (or the viewport centre when nothing is
|
|
@@ -2588,43 +3203,13 @@ export class Editor {
|
|
|
2588
3203
|
selectClosest(direction) {
|
|
2589
3204
|
const ref = this.combinedSelectionBounds();
|
|
2590
3205
|
const vp = this._scene.viewport;
|
|
2591
|
-
const
|
|
3206
|
+
const refCenter = ref
|
|
2592
3207
|
? { x: ref.x + ref.width / 2, y: ref.y + ref.height / 2 }
|
|
2593
3208
|
: {
|
|
2594
3209
|
x: vp.pan.x + vp.size.width / 2 / vp.zoom,
|
|
2595
3210
|
y: vp.pan.y + vp.size.height / 2 / vp.zoom,
|
|
2596
3211
|
};
|
|
2597
|
-
const
|
|
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
|
-
}
|
|
3212
|
+
const best = findClosestInDirection(this._scene, this._selection, direction, refCenter, (s) => this.isElementInteractable(s));
|
|
2628
3213
|
if (best === null)
|
|
2629
3214
|
return;
|
|
2630
3215
|
this.setSelection([best]);
|
|
@@ -2664,25 +3249,31 @@ export class Editor {
|
|
|
2664
3249
|
* of the modifier to this. Idempotent; never touches history.
|
|
2665
3250
|
*/
|
|
2666
3251
|
setSnapSuppressed(suppressed) {
|
|
2667
|
-
this.snapSuppressed = suppressed;
|
|
3252
|
+
this.interaction.snapSuppressed = suppressed;
|
|
3253
|
+
}
|
|
3254
|
+
/**
|
|
3255
|
+
* Host hook: mirror the Alt / Shift modifier state so an in-flight resize or
|
|
3256
|
+
* move reacts to it — `alt` resizes about the centre, `shift` locks the
|
|
3257
|
+
* resize aspect ratio (or constrains a move to one axis). The app wires
|
|
3258
|
+
* keydown/keyup of the modifiers to this. Idempotent; never touches history.
|
|
3259
|
+
*/
|
|
3260
|
+
setTransformModifiers(mods) {
|
|
3261
|
+
this.interaction.transformAltKey = mods.alt;
|
|
3262
|
+
this.interaction.transformShiftKey = mods.shift;
|
|
2668
3263
|
}
|
|
2669
3264
|
/**
|
|
2670
|
-
* True when a gesture should snap. Snapping is coupled to grid
|
|
2671
|
-
* it is active only while
|
|
2672
|
-
*
|
|
2673
|
-
*
|
|
2674
|
-
* is confusing, so no grid → no snap, always. `snapToGrid` is an extra
|
|
2675
|
-
* programmatic opt-out; the suppress modifier (Cmd/Ctrl) bypasses snapping
|
|
2676
|
-
* for the current gesture.
|
|
3265
|
+
* True when a gesture should snap. Snapping is coupled to grid display:
|
|
3266
|
+
* it is active only while the grid is enabled (`gridEnabled`) — snapping to
|
|
3267
|
+
* a hidden grid is confusing. `snapToGrid` is an extra programmatic opt-out;
|
|
3268
|
+
* the suppress modifier (Cmd/Ctrl) bypasses snapping for the current gesture.
|
|
2677
3269
|
*/
|
|
2678
3270
|
snapActive() {
|
|
2679
3271
|
const viewport = this._scene.viewport;
|
|
2680
|
-
|
|
2681
|
-
return !this.snapSuppressed && gridShown && isSnapToGridEnabled(viewport);
|
|
3272
|
+
return (!this.interaction.snapSuppressed && viewport.gridEnabled && isSnapToGridEnabled(viewport));
|
|
2682
3273
|
}
|
|
2683
3274
|
/** World-unit spacing the current gesture snaps to. */
|
|
2684
3275
|
snapSpacing() {
|
|
2685
|
-
return resolveSnapSpacing(
|
|
3276
|
+
return resolveSnapSpacing();
|
|
2686
3277
|
}
|
|
2687
3278
|
/**
|
|
2688
3279
|
* Replace the entire scene (e.g. after `parseScene`). Clears history,
|
|
@@ -2713,7 +3304,11 @@ export class Editor {
|
|
|
2713
3304
|
}
|
|
2714
3305
|
// Restore transient animationData (GIF bytes) from Scene.files
|
|
2715
3306
|
// before the tick so the animation adapter can decode frames.
|
|
2716
|
-
|
|
3307
|
+
animScene.rehydrateAnimatedImages(this);
|
|
3308
|
+
// Rebuild live handles for static images from Scene.files — their
|
|
3309
|
+
// serialised handle is gone and `src` is a dead `blob:` URL after a
|
|
3310
|
+
// reload. Async; repaints itself when the decode lands.
|
|
3311
|
+
void animScene.rehydrateStaticImages(this);
|
|
2717
3312
|
this.notify();
|
|
2718
3313
|
// Loaded scene may carry animated shapes (e.g. GIF re-imported
|
|
2719
3314
|
// from saved JSON). Re-arm the tick — `metadata.animated` survives
|
|
@@ -2731,20 +3326,14 @@ export class Editor {
|
|
|
2731
3326
|
this.cursorListeners.clear();
|
|
2732
3327
|
this.longPressListeners.clear();
|
|
2733
3328
|
this.announceListeners.clear();
|
|
2734
|
-
this.
|
|
3329
|
+
this.animation.detach();
|
|
2735
3330
|
this.animationContentOff?.();
|
|
2736
|
-
if (typeof document !== "undefined") {
|
|
2737
|
-
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
2738
|
-
}
|
|
2739
3331
|
if (this.renderRafId !== null && typeof cancelAnimationFrame !== "undefined") {
|
|
2740
3332
|
cancelAnimationFrame(this.renderRafId);
|
|
2741
3333
|
this.renderRafId = null;
|
|
2742
3334
|
}
|
|
2743
3335
|
}
|
|
2744
3336
|
// --- Internal ---
|
|
2745
|
-
// Body moved to `./editor/pointer-binding.ts` (~700 lines of
|
|
2746
|
-
// pointer / wheel / keyboard dispatch). The thin wrapper here
|
|
2747
|
-
// preserves the original constructor call site.
|
|
2748
3337
|
bindPointerEvents() {
|
|
2749
3338
|
return bindPointerEventsExternal(this);
|
|
2750
3339
|
}
|
|
@@ -2800,14 +3389,14 @@ export class Editor {
|
|
|
2800
3389
|
isDrawingPhase(ctx) {
|
|
2801
3390
|
return ctx.mode === "draw-rect" || ctx.mode === "draw-ellipse" || ctx.mode === "draw-edge";
|
|
2802
3391
|
}
|
|
2803
|
-
// --- Long-press ---
|
|
3392
|
+
// --- Long-press ---
|
|
2804
3393
|
startLongPress(screenPoint) {
|
|
2805
3394
|
this.longPress.start(screenPoint);
|
|
2806
3395
|
}
|
|
2807
3396
|
cancelLongPress() {
|
|
2808
3397
|
this.longPress.cancel();
|
|
2809
3398
|
}
|
|
2810
|
-
// --- Pinch gesture ---
|
|
3399
|
+
// --- Pinch gesture ---
|
|
2811
3400
|
beginPinch() {
|
|
2812
3401
|
this.pinch.begin([...this.activePointers.values()]);
|
|
2813
3402
|
}
|
|
@@ -2822,9 +3411,9 @@ export class Editor {
|
|
|
2822
3411
|
screenToWorld(point) {
|
|
2823
3412
|
return matrix.applyToPoint(getScreenToWorld(this._scene.viewport), point);
|
|
2824
3413
|
}
|
|
2825
|
-
//
|
|
2826
|
-
//
|
|
2827
|
-
//
|
|
3414
|
+
// Editor passes a narrow context bundle that closes over its
|
|
3415
|
+
// private state + accel helpers (acceleratedElementAt,
|
|
3416
|
+
// isElementInteractable, …).
|
|
2828
3417
|
/**
|
|
2829
3418
|
* Attach target under `worldPoint` for an endpoint-rebind drop: the topmost
|
|
2830
3419
|
* interactable ELEMENT (group-promoted), ignoring link bodies and the dragged
|
|
@@ -2867,87 +3456,10 @@ export class Editor {
|
|
|
2867
3456
|
* out of sync. `worldPoint` defaults to the last known pointer position.
|
|
2868
3457
|
*/
|
|
2869
3458
|
refreshCursor(worldPoint) {
|
|
2870
|
-
const next =
|
|
3459
|
+
const next = computeCursor(this, worldPoint ?? this.lastPointerWorld);
|
|
2871
3460
|
if (this.host.style.cursor !== next)
|
|
2872
3461
|
this.host.style.cursor = next;
|
|
2873
3462
|
}
|
|
2874
|
-
/**
|
|
2875
|
-
* The CSS cursor for the current state. Priority: active gesture → text edit → pan affordance →
|
|
2876
|
-
* draw tool → idle hover hit-test. Pure read of editor state; no side effects.
|
|
2877
|
-
*/
|
|
2878
|
-
computeCursor(p) {
|
|
2879
|
-
// Each outcome is a (role, fallback-keyword) pair; `resolveCursor` returns
|
|
2880
|
-
// a host-registered custom image for that role if one exists, else the
|
|
2881
|
-
// keyword. Roles are the stable override keys (see `setCursorOverride`).
|
|
2882
|
-
const r = (role, keyword) => this.resolveCursor(role, keyword);
|
|
2883
|
-
const resizeRole = (h) => r(RESIZE_ROLE[h], cursorForHandle(h));
|
|
2884
|
-
// 1. Active gestures (highest priority — what the pointer is doing now).
|
|
2885
|
-
if (this.panGesture)
|
|
2886
|
-
return r("pan-active", "grabbing");
|
|
2887
|
-
if (this.linkDragFromAnchor?.moved === true)
|
|
2888
|
-
return r("draw", "crosshair");
|
|
2889
|
-
if (this.isDraggingWaypoint || this.isDraggingSegment)
|
|
2890
|
-
return r("move", "grabbing");
|
|
2891
|
-
if (this.annotationDrag?.moved === true)
|
|
2892
|
-
return r("move", "grabbing");
|
|
2893
|
-
if (this.brushStroke)
|
|
2894
|
-
return r("draw", "crosshair");
|
|
2895
|
-
// Machine-driven drag past the threshold (`gestureTx` opens then): resize
|
|
2896
|
-
// shows the handle's arrow; element / link move shows grabbing.
|
|
2897
|
-
if (this.gestureTx) {
|
|
2898
|
-
const t = this.actor.getSnapshot().context.pressTarget;
|
|
2899
|
-
if (t && (t.kind === "handle" || t.kind === "group-handle"))
|
|
2900
|
-
return resizeRole(t.handle);
|
|
2901
|
-
if (t && (t.kind === "element" || t.kind === "link" || t.kind === "edge-endpoint")) {
|
|
2902
|
-
return r("move", "grabbing");
|
|
2903
|
-
}
|
|
2904
|
-
}
|
|
2905
|
-
// 2. In-canvas text editing → I-beam.
|
|
2906
|
-
if (this.editingTextElement !== null)
|
|
2907
|
-
return r("text", "text");
|
|
2908
|
-
// 3. Pan affordance (idle): Space held or hand tool.
|
|
2909
|
-
if (this.spaceHeld || this.mode === "hand")
|
|
2910
|
-
return r("pan-ready", "grab");
|
|
2911
|
-
// 4. Draw tools (idle, before a gesture starts).
|
|
2912
|
-
switch (this.mode) {
|
|
2913
|
-
case "draw-rect":
|
|
2914
|
-
case "draw-ellipse":
|
|
2915
|
-
case "draw-frame":
|
|
2916
|
-
case "draw-edge":
|
|
2917
|
-
case "brush":
|
|
2918
|
-
return r("draw", "crosshair");
|
|
2919
|
-
case "draw-text":
|
|
2920
|
-
return r("text", "text");
|
|
2921
|
-
default:
|
|
2922
|
-
break;
|
|
2923
|
-
}
|
|
2924
|
-
// 5. Idle hover in select mode — key off the hit-test target.
|
|
2925
|
-
if (p) {
|
|
2926
|
-
if (this.isOverLinkStartDot(p))
|
|
2927
|
-
return r("link-start", "crosshair");
|
|
2928
|
-
const t = this.hitTest(p);
|
|
2929
|
-
switch (t.kind) {
|
|
2930
|
-
case "handle":
|
|
2931
|
-
case "group-handle":
|
|
2932
|
-
return resizeRole(t.handle);
|
|
2933
|
-
case "edge-endpoint":
|
|
2934
|
-
return r("link-handle", "grab");
|
|
2935
|
-
case "annotation":
|
|
2936
|
-
return r("annotation", "pointer");
|
|
2937
|
-
default:
|
|
2938
|
-
return r("default", "default");
|
|
2939
|
-
}
|
|
2940
|
-
}
|
|
2941
|
-
return r("default", "default");
|
|
2942
|
-
}
|
|
2943
|
-
/**
|
|
2944
|
-
* Resolve a cursor role to a CSS `cursor` value: a host-registered custom
|
|
2945
|
-
* image (via {@link setCursorOverride}) if present, else `fallbackKeyword`.
|
|
2946
|
-
*/
|
|
2947
|
-
resolveCursor(role, fallbackKeyword) {
|
|
2948
|
-
const spec = this.cursorOverrides.get(role);
|
|
2949
|
-
return spec === undefined ? fallbackKeyword : cssCursor(spec, fallbackKeyword);
|
|
2950
|
-
}
|
|
2951
3463
|
/**
|
|
2952
3464
|
* Register (or clear, with `null`) a custom cursor image for a state role.
|
|
2953
3465
|
* The image is shown wherever `computeCursor` resolves that role; pass a
|
|
@@ -2963,31 +3475,12 @@ export class Editor {
|
|
|
2963
3475
|
this.refreshCursor();
|
|
2964
3476
|
}
|
|
2965
3477
|
/**
|
|
2966
|
-
*
|
|
2967
|
-
*
|
|
2968
|
-
*
|
|
2969
|
-
* exactly where a press would begin a link.
|
|
3478
|
+
* Read-only lookup of a host-registered cursor override. Used by the cursor
|
|
3479
|
+
* module to resolve a role without exposing the mutable override map (mutate
|
|
3480
|
+
* only via {@link setCursorOverride}, so `refreshCursor` stays in sync).
|
|
2970
3481
|
*/
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
return false;
|
|
2974
|
-
const id = [...this._selection][0];
|
|
2975
|
-
if (id === undefined)
|
|
2976
|
-
return false;
|
|
2977
|
-
const shape = getElement(this._scene, id);
|
|
2978
|
-
if (!shape)
|
|
2979
|
-
return false;
|
|
2980
|
-
const zoom = this._scene.viewport.zoom || 1;
|
|
2981
|
-
const { worldPoints } = anchorOverlayPoints(shape, LINK_START_ANCHOR_OUTSET / zoom);
|
|
2982
|
-
const grab = (ANCHOR_DOT_ACTIVE_RADIUS + this.anchorStartHitSlop) / zoom;
|
|
2983
|
-
const grab2 = grab * grab;
|
|
2984
|
-
for (const wp of worldPoints) {
|
|
2985
|
-
const dx = wp.x - p.x;
|
|
2986
|
-
const dy = wp.y - p.y;
|
|
2987
|
-
if (dx * dx + dy * dy <= grab2)
|
|
2988
|
-
return true;
|
|
2989
|
-
}
|
|
2990
|
-
return false;
|
|
3482
|
+
getCursorOverride(role) {
|
|
3483
|
+
return this.cursorOverrides.get(role);
|
|
2991
3484
|
}
|
|
2992
3485
|
/** True when the given layer exists and is marked `locked`. */
|
|
2993
3486
|
isLayerLocked(layerId) {
|
|
@@ -3034,6 +3527,8 @@ export class Editor {
|
|
|
3034
3527
|
* can't be moved or resized.
|
|
3035
3528
|
*/
|
|
3036
3529
|
toggleLockSelection() {
|
|
3530
|
+
if (this.readOnly)
|
|
3531
|
+
return;
|
|
3037
3532
|
if (this._selection.size === 0)
|
|
3038
3533
|
return;
|
|
3039
3534
|
const ids = [...this._selection];
|
|
@@ -3075,8 +3570,7 @@ export class Editor {
|
|
|
3075
3570
|
* Topmost group ancestor of `shape` (walks parentId chain, returns
|
|
3076
3571
|
* the highest `type === "group"` parent). `null` if `shape` has no
|
|
3077
3572
|
* group ancestor. Used by drill-down: a double-click on a shape
|
|
3078
|
-
* with a group ancestor enters that group.
|
|
3079
|
-
* `./group-helpers.ts`.
|
|
3573
|
+
* with a group ancestor enters that group.
|
|
3080
3574
|
*/
|
|
3081
3575
|
topGroupAncestor(shape) {
|
|
3082
3576
|
return topGroupAncestorHelper(this._scene, shape);
|
|
@@ -3103,13 +3597,44 @@ export class Editor {
|
|
|
3103
3597
|
* practice, but the guard keeps the contract simple — "what you've
|
|
3104
3598
|
* selected, you can see".
|
|
3105
3599
|
*/
|
|
3106
|
-
// Body moved to `./editor/shape-filters.ts`.
|
|
3107
3600
|
computeHiddenElements() {
|
|
3108
3601
|
return computeHiddenElementsPure(this._scene);
|
|
3109
3602
|
}
|
|
3603
|
+
/**
|
|
3604
|
+
* Live stroke-erase preview: while a Shift-held eraser gesture drags, the
|
|
3605
|
+
* fragments each touched brush WOULD become, plus the set of touched
|
|
3606
|
+
* originals to hide in the main pass. `null` outside a stroke-erase gesture
|
|
3607
|
+
* or when the path touches no brush. Recomputed each frame from the
|
|
3608
|
+
* path-so-far — never mutates the scene or history.
|
|
3609
|
+
*/
|
|
3610
|
+
computeStrokeErasePreview() {
|
|
3611
|
+
const stroke = this.eraseStroke;
|
|
3612
|
+
if (!stroke || !stroke.strokeMode || stroke.erased.size === 0)
|
|
3613
|
+
return null;
|
|
3614
|
+
const preview = computeStrokeErasePreviewFromMasks(this._scene, stroke.erased);
|
|
3615
|
+
if (!preview)
|
|
3616
|
+
return null;
|
|
3617
|
+
return { elements: preview.fragments, hidden: preview.hidden };
|
|
3618
|
+
}
|
|
3110
3619
|
computeDimElements(enteredGroupId) {
|
|
3111
3620
|
return computeDimElementsHelper(this._scene, this._selection, enteredGroupId);
|
|
3112
3621
|
}
|
|
3622
|
+
/**
|
|
3623
|
+
* Dim set fed to the renderer: group-isolation dim UNION the eraser's
|
|
3624
|
+
* pending-delete set (shapes swept by the current eraser stroke are shown
|
|
3625
|
+
* dimmed so the user sees what release will delete). `undefined` when neither
|
|
3626
|
+
* is active, keeping the fast tile-cache render path.
|
|
3627
|
+
*/
|
|
3628
|
+
computeDimSet() {
|
|
3629
|
+
const group = this._enteredGroup ? this.computeDimElements(this._enteredGroup) : undefined;
|
|
3630
|
+
const erase = this.interaction.eraseStroke?.pending;
|
|
3631
|
+
if (!erase || erase.size === 0)
|
|
3632
|
+
return group;
|
|
3633
|
+
const merged = new Set(group);
|
|
3634
|
+
for (const id of erase)
|
|
3635
|
+
merged.add(id);
|
|
3636
|
+
return merged;
|
|
3637
|
+
}
|
|
3113
3638
|
/**
|
|
3114
3639
|
* Enter a group — subsequent hits inside this group return children
|
|
3115
3640
|
* directly instead of the group root. `null` exits group-edit mode.
|
|
@@ -3133,7 +3658,7 @@ export class Editor {
|
|
|
3133
3658
|
const el = getElement(this._scene, id);
|
|
3134
3659
|
if (!el)
|
|
3135
3660
|
return;
|
|
3136
|
-
const isFrameEl = el
|
|
3661
|
+
const isFrameEl = isFrame(el);
|
|
3137
3662
|
const members = [];
|
|
3138
3663
|
for (const s of this._scene.elements.values()) {
|
|
3139
3664
|
if (s.parentId === id || (isFrameEl && s.frameId === id))
|
|
@@ -3141,7 +3666,7 @@ export class Editor {
|
|
|
3141
3666
|
}
|
|
3142
3667
|
if (members.length === 0)
|
|
3143
3668
|
return;
|
|
3144
|
-
if (el
|
|
3669
|
+
if (isGroup(el))
|
|
3145
3670
|
this._enteredGroup = id;
|
|
3146
3671
|
this.setSelection(members);
|
|
3147
3672
|
}
|
|
@@ -3233,11 +3758,11 @@ export class Editor {
|
|
|
3233
3758
|
*/
|
|
3234
3759
|
routeIsolationClick(clickEffect, worldPoint) {
|
|
3235
3760
|
const now = performance.now();
|
|
3236
|
-
const isDouble = now - this.lastClickAt < DOUBLE_CLICK_MS &&
|
|
3237
|
-
this.lastClickWorldPoint !== null &&
|
|
3238
|
-
|
|
3239
|
-
this.lastClickAt = now;
|
|
3240
|
-
this.lastClickWorldPoint = worldPoint;
|
|
3761
|
+
const isDouble = now - this.interaction.lastClickAt < DOUBLE_CLICK_MS &&
|
|
3762
|
+
this.interaction.lastClickWorldPoint !== null &&
|
|
3763
|
+
vec2.distance(this.interaction.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
|
|
3764
|
+
this.interaction.lastClickAt = now;
|
|
3765
|
+
this.interaction.lastClickWorldPoint = worldPoint;
|
|
3241
3766
|
// Double-click the frame HEADER (label strip above the body) → rename.
|
|
3242
3767
|
// Checked before the clickEffect gate because the header sits outside
|
|
3243
3768
|
// the frame's hit-test bounds, so the click produces SELECT_CLEAR (or
|
|
@@ -3282,11 +3807,11 @@ export class Editor {
|
|
|
3282
3807
|
if (isDouble &&
|
|
3283
3808
|
(clickEffect.type === "SELECT_REPLACE" || clickEffect.type === "SELECT_TOGGLE")) {
|
|
3284
3809
|
const raw = this.acceleratedElementAt(worldPoint);
|
|
3285
|
-
if (raw
|
|
3810
|
+
if (raw !== undefined && isText(raw)) {
|
|
3286
3811
|
this.beginTextEdit(raw.id);
|
|
3287
3812
|
return true;
|
|
3288
3813
|
}
|
|
3289
|
-
if (raw
|
|
3814
|
+
if (raw !== undefined && isFrame(raw)) {
|
|
3290
3815
|
this.beginFrameNameEdit(raw.id);
|
|
3291
3816
|
return true;
|
|
3292
3817
|
}
|
|
@@ -3320,6 +3845,12 @@ export class Editor {
|
|
|
3320
3845
|
return pickDrillTargetHelper(this._scene, raw, top, this._enteredGroup);
|
|
3321
3846
|
}
|
|
3322
3847
|
applyEmit(emit) {
|
|
3848
|
+
// Read-only gate: in view mode every scene-mutating emit is dropped
|
|
3849
|
+
// (create / move / resize / rotate / annotation / edge edits + their
|
|
3850
|
+
// live previews). Selection + lasso emits fall through so a viewer can
|
|
3851
|
+
// still click / marquee-select and pan / zoom the document.
|
|
3852
|
+
if (this._readOnly && READ_ONLY_BLOCKED_EMITS.has(emit.type))
|
|
3853
|
+
return;
|
|
3323
3854
|
switch (emit.type) {
|
|
3324
3855
|
case "SELECT_REPLACE":
|
|
3325
3856
|
// Plain element click replaces the whole selection (elements + links).
|
|
@@ -3363,19 +3894,19 @@ export class Editor {
|
|
|
3363
3894
|
case "LASSO_PROGRESS":
|
|
3364
3895
|
// Capture the pre-lasso selection on the first progress emit
|
|
3365
3896
|
// of a gesture; subsequent emits use it as the additive base.
|
|
3366
|
-
this.lassoBaseSelection ??= this._selection;
|
|
3367
|
-
this.lassoBaseLinks ??= this._selectedLinks;
|
|
3897
|
+
this.interaction.lassoBaseSelection ??= this._selection;
|
|
3898
|
+
this.interaction.lassoBaseLinks ??= this._selectedLinks;
|
|
3368
3899
|
this.lassoPreview = emit.bounds;
|
|
3369
3900
|
this.applyLassoLiveSelection(emit.bounds, emit.mode);
|
|
3370
3901
|
this.notify();
|
|
3371
3902
|
return;
|
|
3372
3903
|
case "LASSO_CLEAR":
|
|
3373
3904
|
if (this.lassoPreview !== null ||
|
|
3374
|
-
this.lassoBaseSelection !== null ||
|
|
3375
|
-
this.lassoBaseLinks !== null) {
|
|
3905
|
+
this.interaction.lassoBaseSelection !== null ||
|
|
3906
|
+
this.interaction.lassoBaseLinks !== null) {
|
|
3376
3907
|
this.lassoPreview = null;
|
|
3377
|
-
this.lassoBaseSelection = null;
|
|
3378
|
-
this.lassoBaseLinks = null;
|
|
3908
|
+
this.interaction.lassoBaseSelection = null;
|
|
3909
|
+
this.interaction.lassoBaseLinks = null;
|
|
3379
3910
|
this.notify();
|
|
3380
3911
|
}
|
|
3381
3912
|
return;
|
|
@@ -3383,8 +3914,8 @@ export class Editor {
|
|
|
3383
3914
|
// Final commit — uses the same logic as the live preview so
|
|
3384
3915
|
// the visible selection matches what lands. Reset the base
|
|
3385
3916
|
// snapshot so the next gesture re-captures it.
|
|
3386
|
-
this.lassoBaseSelection = null;
|
|
3387
|
-
this.lassoBaseLinks = null;
|
|
3917
|
+
this.interaction.lassoBaseSelection = null;
|
|
3918
|
+
this.interaction.lassoBaseLinks = null;
|
|
3388
3919
|
this.applySelectByBounds(emit.bounds, emit.mode);
|
|
3389
3920
|
return;
|
|
3390
3921
|
case "MOVE_SHAPE":
|
|
@@ -3401,6 +3932,9 @@ export class Editor {
|
|
|
3401
3932
|
case "RESIZE_SHAPE":
|
|
3402
3933
|
this.applyResize(emit.id, emit.handle, emit.delta, emit.originalBounds);
|
|
3403
3934
|
return;
|
|
3935
|
+
case "ROTATE":
|
|
3936
|
+
this.applyRotate(emit.deltaAngle);
|
|
3937
|
+
return;
|
|
3404
3938
|
case "CREATE_SHAPE":
|
|
3405
3939
|
this.applyCreate(emit.shapeType, emit.bounds);
|
|
3406
3940
|
return;
|
|
@@ -3447,7 +3981,6 @@ export class Editor {
|
|
|
3447
3981
|
* Wrapped in a single gestureTx so per-move updates collapse
|
|
3448
3982
|
* into one undo step.
|
|
3449
3983
|
*/
|
|
3450
|
-
// Pure body in `./editor/applies/move.ts`.
|
|
3451
3984
|
applyAnnotationMove(id, delta, origin) {
|
|
3452
3985
|
const result = computeAnnotationMovePatch(this._scene, id, delta, origin);
|
|
3453
3986
|
if (!result)
|
|
@@ -3483,13 +4016,14 @@ export class Editor {
|
|
|
3483
4016
|
dispatchTemplateDrop(emit) {
|
|
3484
4017
|
this.applyEmit(emit);
|
|
3485
4018
|
}
|
|
3486
|
-
// Pure body in `./editor/applies/move.ts`.
|
|
3487
4019
|
applyMove(id, delta, originalBounds) {
|
|
3488
4020
|
// Locked / layer-locked elements are selectable but don't move.
|
|
3489
4021
|
const el = getElement(this._scene, id);
|
|
3490
4022
|
if (el && !this.isElementManipulable(el))
|
|
3491
4023
|
return;
|
|
3492
|
-
|
|
4024
|
+
// Shift constrains the drag to one axis before snapping.
|
|
4025
|
+
const moved = this.interaction.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
|
|
4026
|
+
const d = this.snapActive() ? snapMoveDelta(originalBounds, moved, this.snapSpacing()) : moved;
|
|
3493
4027
|
const patch = computeElementMovePatch(this._scene, id, d, originalBounds);
|
|
3494
4028
|
if (!patch)
|
|
3495
4029
|
return;
|
|
@@ -3500,9 +4034,11 @@ export class Editor {
|
|
|
3500
4034
|
applyGroupMove(delta) {
|
|
3501
4035
|
if (!this.groupMoveOrigin)
|
|
3502
4036
|
return;
|
|
4037
|
+
// Shift constrains the drag to one axis before snapping.
|
|
4038
|
+
const moved = this.interaction.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
|
|
3503
4039
|
const d = this.snapActive()
|
|
3504
|
-
? snapGroupDelta(this.groupMoveOrigin,
|
|
3505
|
-
:
|
|
4040
|
+
? snapGroupDelta(this.groupMoveOrigin, moved, this.snapSpacing())
|
|
4041
|
+
: moved;
|
|
3506
4042
|
const patches = computeGroupMovePatches(this._scene, this.groupMoveOrigin, d);
|
|
3507
4043
|
for (const patch of patches) {
|
|
3508
4044
|
this._scene = apply(this._scene, patch);
|
|
@@ -3520,7 +4056,6 @@ export class Editor {
|
|
|
3520
4056
|
}
|
|
3521
4057
|
this.notify();
|
|
3522
4058
|
}
|
|
3523
|
-
// Body moved to `./editor/viewport-helpers.ts`.
|
|
3524
4059
|
computeViewportWorld() {
|
|
3525
4060
|
return computeViewportWorldPure(this._scene);
|
|
3526
4061
|
}
|
|
@@ -3575,7 +4110,14 @@ export class Editor {
|
|
|
3575
4110
|
this.drawingPreview !== null ||
|
|
3576
4111
|
this.edgePreview !== null ||
|
|
3577
4112
|
this.brushStroke !== null ||
|
|
3578
|
-
this.lassoPreview !== null
|
|
4113
|
+
this.lassoPreview !== null ||
|
|
4114
|
+
// Eraser sweep: marking / un-marking / cutting changes what's shown
|
|
4115
|
+
// WITHOUT mutating the scene, so the scene-diff dirty rect is empty and
|
|
4116
|
+
// the change would never repaint. Force a full repaint — but ONLY on the
|
|
4117
|
+
// frames that actually changed the marked / cut set (`eraseDirty`), so a
|
|
4118
|
+
// slowly-moving or stopped cursor doesn't re-render the whole scene every
|
|
4119
|
+
// frame (which froze big scenes).
|
|
4120
|
+
(this.interaction.eraseStroke !== null && this.eraseDirty)) {
|
|
3579
4121
|
return null;
|
|
3580
4122
|
}
|
|
3581
4123
|
// Anything that affects the global render — viewport (pan / zoom /
|
|
@@ -3589,125 +4131,22 @@ export class Editor {
|
|
|
3589
4131
|
// repaint when the entered-group identity changes between frames.
|
|
3590
4132
|
if (this.lastRenderedEnteredGroup !== this._enteredGroup)
|
|
3591
4133
|
return null;
|
|
3592
|
-
//
|
|
3593
|
-
//
|
|
3594
|
-
//
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
};
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
// through the shape's new position, but the old path stays on
|
|
3606
|
-
// screen as a "ghost" trail unless we explicitly invalidate it.
|
|
3607
|
-
const changedElementIds = new Set();
|
|
3608
|
-
for (const [id, shape] of next.elements) {
|
|
3609
|
-
const old = prev.elements.get(id);
|
|
3610
|
-
if (old === shape)
|
|
3611
|
-
continue;
|
|
3612
|
-
changedElementIds.add(id);
|
|
3613
|
-
// Render bounds (not geometric) so overpaint — a frame's header
|
|
3614
|
-
// strip, confetti particles — is cleared too, no ghost trail.
|
|
3615
|
-
const afterBounds = getElementRenderBounds(shape);
|
|
3616
|
-
const beforeBounds = old ? getElementRenderBounds(old) : null;
|
|
3617
|
-
add(afterBounds);
|
|
3618
|
-
if (beforeBounds)
|
|
3619
|
-
add(beforeBounds);
|
|
3620
|
-
// Stash for the tile-cache path — covers add + move via
|
|
3621
|
-
// before/after pair; pure mutation re-uses the single
|
|
3622
|
-
// afterBounds rect.
|
|
3623
|
-
if (this.tileComposeFn !== null) {
|
|
3624
|
-
this.tileDirtyElements.set(id, { before: beforeBounds, after: afterBounds });
|
|
3625
|
-
}
|
|
3626
|
-
}
|
|
3627
|
-
for (const [id, shape] of prev.elements) {
|
|
3628
|
-
if (!next.elements.has(id)) {
|
|
3629
|
-
changedElementIds.add(id);
|
|
3630
|
-
// Render bounds so a removed frame/confetti clears its overpaint.
|
|
3631
|
-
const beforeBounds = getElementRenderBounds(shape);
|
|
3632
|
-
add(beforeBounds);
|
|
3633
|
-
if (this.tileComposeFn !== null) {
|
|
3634
|
-
this.tileDirtyElements.set(id, { before: beforeBounds, after: null });
|
|
3635
|
-
}
|
|
3636
|
-
}
|
|
3637
|
-
}
|
|
3638
|
-
const linkTouchesChangedElement = (edge) => {
|
|
3639
|
-
for (const ep of [edge.from, edge.to]) {
|
|
3640
|
-
if (ep.kind !== "point") {
|
|
3641
|
-
if (changedElementIds.has(ep.elementId))
|
|
3642
|
-
return true;
|
|
3643
|
-
}
|
|
3644
|
-
}
|
|
3645
|
-
return false;
|
|
3646
|
-
};
|
|
3647
|
-
for (const [id, edge] of next.links) {
|
|
3648
|
-
const old = prev.links.get(id);
|
|
3649
|
-
// Refresh edge dirty-rect when: edge object changed, OR an
|
|
3650
|
-
// endpoint references a shape that moved this frame (path is
|
|
3651
|
-
// re-resolved every render but the old screen pixels persist).
|
|
3652
|
-
if (old === edge && !linkTouchesChangedElement(edge))
|
|
3653
|
-
continue;
|
|
3654
|
-
const b = computeLinkWorldBounds(next, edge);
|
|
3655
|
-
if (b)
|
|
3656
|
-
add(b);
|
|
3657
|
-
const oldLink = old ?? edge; // prev scene resolves with prev shapes for ghost-clear
|
|
3658
|
-
const ob = computeLinkWorldBounds(prev, oldLink);
|
|
3659
|
-
if (ob)
|
|
3660
|
-
add(ob);
|
|
3661
|
-
}
|
|
3662
|
-
for (const [id, edge] of prev.links) {
|
|
3663
|
-
if (!next.links.has(id)) {
|
|
3664
|
-
const b = computeLinkWorldBounds(prev, edge);
|
|
3665
|
-
if (b)
|
|
3666
|
-
add(b);
|
|
3667
|
-
}
|
|
3668
|
-
}
|
|
3669
|
-
// 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
|
|
3670
|
-
if (acc === null)
|
|
3671
|
-
return { x: -1e9, y: -1e9, width: 0, height: 0 };
|
|
3672
|
-
// Transitive expansion: any shape whose bounds intersect the
|
|
3673
|
-
// current dirty rect must be repainted, AND its bounds added
|
|
3674
|
-
// to the dirty rect so any shape ABOVE it that overlaps gets
|
|
3675
|
-
// included too. Repeat until the set stabilises.
|
|
3676
|
-
//
|
|
3677
|
-
// Without this, dragging A through a B/C stack produces
|
|
3678
|
-
// visual jitter: B intersects the dirty rect and gets
|
|
3679
|
-
// repainted, but C — sitting above B and partially overlapping
|
|
3680
|
-
// it — doesn't intersect the original dirty, so B re-emerges
|
|
3681
|
-
// on top of where C should still be drawn. Z-order is correct
|
|
3682
|
-
// in `getElementsInLayer`; the issue is missed shapes, not
|
|
3683
|
-
// wrong order.
|
|
3684
|
-
const visited = new Set();
|
|
3685
|
-
let expanded = acc;
|
|
3686
|
-
let grew = true;
|
|
3687
|
-
while (grew) {
|
|
3688
|
-
grew = false;
|
|
3689
|
-
for (const shape of next.elements.values()) {
|
|
3690
|
-
if (visited.has(shape.id))
|
|
3691
|
-
continue;
|
|
3692
|
-
const bb = getElementWorldBounds(shape);
|
|
3693
|
-
if (!B.intersects(bb, expanded))
|
|
3694
|
-
continue;
|
|
3695
|
-
visited.add(shape.id);
|
|
3696
|
-
const merged = B.union(expanded, bb);
|
|
3697
|
-
if (merged.x !== expanded.x ||
|
|
3698
|
-
merged.y !== expanded.y ||
|
|
3699
|
-
merged.width !== expanded.width ||
|
|
3700
|
-
merged.height !== expanded.height) {
|
|
3701
|
-
expanded = merged;
|
|
3702
|
-
grew = true;
|
|
3703
|
-
}
|
|
3704
|
-
}
|
|
4134
|
+
// Eraser just STOPPED (the active-stroke guard above already returned, so
|
|
4135
|
+
// `eraseStroke` is null here): on an Esc-cancel the marked shapes un-dim
|
|
4136
|
+
// without a scene change — commit changes the scene and is caught by the
|
|
4137
|
+
// diff — so force a full repaint that frame or the dim would linger.
|
|
4138
|
+
if (this.lastRenderedEraseActive)
|
|
4139
|
+
return null;
|
|
4140
|
+
// Diff the two scenes for the dirty rect + tile-cache invalidation. The
|
|
4141
|
+
// state-coupled guards above stay here; the pure scene diff lives in
|
|
4142
|
+
// `computeSceneDirtyRect`.
|
|
4143
|
+
const { world, tileDirty } = computeSceneDirtyRect(prev, next);
|
|
4144
|
+
if (this.tileComposeFn !== null) {
|
|
4145
|
+
for (const [id, entry] of tileDirty)
|
|
4146
|
+
this.tileDirtyElements.set(id, entry);
|
|
3705
4147
|
}
|
|
3706
|
-
|
|
3707
|
-
// around the geometry edges.
|
|
3708
|
-
return B.expand(expanded, 4);
|
|
4148
|
+
return world;
|
|
3709
4149
|
}
|
|
3710
|
-
// Bodies moved to `./editor/viewport-helpers.ts`.
|
|
3711
4150
|
combinedSelectionBounds() {
|
|
3712
4151
|
let acc = combinedSelectionBoundsPure(this._scene, this._selection);
|
|
3713
4152
|
// Selected links join the selection box (standard parity) — union in
|
|
@@ -3763,19 +4202,42 @@ export class Editor {
|
|
|
3763
4202
|
// Multi-selection: lock when every selected shape is an image — they
|
|
3764
4203
|
// must never be stretched out of ratio, only scaled together.
|
|
3765
4204
|
for (const id of this._selection) {
|
|
3766
|
-
|
|
4205
|
+
const el = getElement(this._scene, id);
|
|
4206
|
+
if (el === undefined || !isImage(el))
|
|
3767
4207
|
return false;
|
|
3768
4208
|
}
|
|
3769
4209
|
return true;
|
|
3770
4210
|
}
|
|
3771
|
-
|
|
4211
|
+
/**
|
|
4212
|
+
* Per-frame rotate during a grip drag: turn the press-time snapshot by
|
|
4213
|
+
* `deltaAngle` about its pivot. Shift snaps the swept angle to
|
|
4214
|
+
* {@link ROTATE_SNAP_RADIANS} steps. Recorded as gesture patches (one undo
|
|
4215
|
+
* step on commit).
|
|
4216
|
+
*/
|
|
4217
|
+
applyRotate(deltaAngle) {
|
|
4218
|
+
const gesture = this.rotateGestureOrigin;
|
|
4219
|
+
if (!gesture)
|
|
4220
|
+
return;
|
|
4221
|
+
const d = this.interaction.transformShiftKey
|
|
4222
|
+
? Math.round(deltaAngle / ROTATE_SNAP_RADIANS) * ROTATE_SNAP_RADIANS
|
|
4223
|
+
: deltaAngle;
|
|
4224
|
+
const patches = computeRotatePatches(this._scene, gesture.origin, gesture.pivot, d);
|
|
4225
|
+
for (const patch of patches) {
|
|
4226
|
+
this._scene = apply(this._scene, patch);
|
|
4227
|
+
this.recordGesturePatch(patch);
|
|
4228
|
+
}
|
|
4229
|
+
this.notify();
|
|
4230
|
+
}
|
|
3772
4231
|
applyGroupResize(handle, delta, originalBounds) {
|
|
3773
4232
|
if (!this.groupResizeOrigin)
|
|
3774
4233
|
return;
|
|
3775
4234
|
const d = this.snapActive()
|
|
3776
4235
|
? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
|
|
3777
4236
|
: delta;
|
|
3778
|
-
const result = computeGroupResizePatches(this._scene, this.groupResizeOrigin, handle, d, originalBounds,
|
|
4237
|
+
const result = computeGroupResizePatches(this._scene, this.groupResizeOrigin, handle, d, originalBounds,
|
|
4238
|
+
// Aspect-locked when the selection type demands it (images / groups) or
|
|
4239
|
+
// the user holds Shift for this gesture.
|
|
4240
|
+
this.selectionIsAspectLocked() || this.interaction.transformShiftKey, this.interaction.transformAltKey);
|
|
3779
4241
|
this._scene = result.scene;
|
|
3780
4242
|
for (const patch of result.patches)
|
|
3781
4243
|
this.recordGesturePatch(patch);
|
|
@@ -3783,16 +4245,36 @@ export class Editor {
|
|
|
3783
4245
|
}
|
|
3784
4246
|
applyResize(id, handle, delta, originalBounds) {
|
|
3785
4247
|
const shape = getElement(this._scene, id);
|
|
4248
|
+
// Rotated shape: the axis-aligned world resize below would corrupt it
|
|
4249
|
+
// (handles live on the tilted box). Resize in the shape's own frame,
|
|
4250
|
+
// keeping the opposite corner fixed in world. Snapshot the pristine pose
|
|
4251
|
+
// on the first tick so the closed-form never compounds. Grid-snap is
|
|
4252
|
+
// skipped — snapping a tilted box to the world grid is ill-defined.
|
|
4253
|
+
if (shape !== undefined && !isText(shape) && shape.rotation !== 0) {
|
|
4254
|
+
if (this.interaction.resizeOriginElement?.id !== id)
|
|
4255
|
+
this.interaction.resizeOriginElement = shape;
|
|
4256
|
+
const result = computeRotatedElementResize(this._scene, this.interaction.resizeOriginElement, handle, delta, this.interaction.transformShiftKey, this.interaction.transformAltKey);
|
|
4257
|
+
if (!result)
|
|
4258
|
+
return;
|
|
4259
|
+
this._scene = result.scene;
|
|
4260
|
+
this.recordGesturePatch(result.patch);
|
|
4261
|
+
this.notify();
|
|
4262
|
+
return;
|
|
4263
|
+
}
|
|
3786
4264
|
const d = this.snapActive()
|
|
3787
4265
|
? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
|
|
3788
4266
|
: delta;
|
|
3789
4267
|
// Text: aspect-locked font scaling. Snapshot the pristine shape on
|
|
3790
4268
|
// the gesture's first tick so the scale base never compounds.
|
|
3791
|
-
if (shape
|
|
3792
|
-
if (this.
|
|
3793
|
-
this.
|
|
4269
|
+
if (shape !== undefined && isText(shape)) {
|
|
4270
|
+
if (this.interaction.resizeOriginElement?.id !== id) {
|
|
4271
|
+
this.interaction.resizeOriginElement = shape;
|
|
3794
4272
|
}
|
|
3795
|
-
|
|
4273
|
+
// The snapshot was taken from a text shape above; fall back to the
|
|
4274
|
+
// live shape if a stale non-text snapshot ever leaks through.
|
|
4275
|
+
const origin = this.interaction.resizeOriginElement;
|
|
4276
|
+
const textOrigin = isText(origin) ? origin : shape;
|
|
4277
|
+
const result = computeTextResize(this._scene, textOrigin, handle, d, originalBounds, this.interaction.transformAltKey);
|
|
3796
4278
|
if (!result)
|
|
3797
4279
|
return;
|
|
3798
4280
|
this._scene = result.scene;
|
|
@@ -3800,14 +4282,13 @@ export class Editor {
|
|
|
3800
4282
|
this.notify();
|
|
3801
4283
|
return;
|
|
3802
4284
|
}
|
|
3803
|
-
const result = computeElementResize(this._scene, id, handle, d, originalBounds, (s, raw, h) => this.clampContainerToChildren(s, raw, h));
|
|
4285
|
+
const result = computeElementResize(this._scene, id, handle, d, originalBounds, (s, raw, h) => this.clampContainerToChildren(s, raw, h), this.interaction.transformShiftKey, this.interaction.transformAltKey);
|
|
3804
4286
|
if (!result)
|
|
3805
4287
|
return;
|
|
3806
4288
|
this._scene = result.scene;
|
|
3807
4289
|
this.recordGesturePatch(result.patch);
|
|
3808
4290
|
this.notify();
|
|
3809
4291
|
}
|
|
3810
|
-
// Pure body in `./editor/applies/create.ts`.
|
|
3811
4292
|
applyCreate(kind, bounds) {
|
|
3812
4293
|
const id = newElementId(++this.nextId);
|
|
3813
4294
|
const b = this.snapActive() ? snapCreateBounds(bounds, this.snapSpacing()) : bounds;
|
|
@@ -3847,8 +4328,7 @@ export class Editor {
|
|
|
3847
4328
|
reconcileFrameMembership() {
|
|
3848
4329
|
this._scene = reconcileFrameMembershipHelper(this._scene, this._history);
|
|
3849
4330
|
}
|
|
3850
|
-
//
|
|
3851
|
-
// stays here because it needs the snap engine.
|
|
4331
|
+
// Endpoint snapping stays here because it needs the snap engine.
|
|
3852
4332
|
applyCreateLink(emit) {
|
|
3853
4333
|
const from = this.snapLinkEndpoint(emit.fromElement, emit.fromPoint);
|
|
3854
4334
|
const to = this.snapLinkEndpoint(emit.toElement, emit.toPoint);
|
|
@@ -3862,13 +4342,13 @@ export class Editor {
|
|
|
3862
4342
|
// the drop point (standard). The free-ended link stays; picking re-points
|
|
3863
4343
|
// it, dismissing keeps it. Only the `to` end is user-dragged here.
|
|
3864
4344
|
if (to.kind === "point") {
|
|
3865
|
-
this.pendingLinkDropMenu = { linkId: id, side: "to", world: to.position };
|
|
4345
|
+
this.interaction.pendingLinkDropMenu = { linkId: id, side: "to", world: to.position };
|
|
3866
4346
|
}
|
|
3867
4347
|
this.notify();
|
|
3868
4348
|
}
|
|
3869
4349
|
/** Pending shape-picker after a link was dropped on empty canvas. */
|
|
3870
4350
|
get linkDropMenu() {
|
|
3871
|
-
return this.pendingLinkDropMenu;
|
|
4351
|
+
return this.interaction.pendingLinkDropMenu;
|
|
3872
4352
|
}
|
|
3873
4353
|
/**
|
|
3874
4354
|
* Resolve a pending link-drop shape-picker by creating an element from
|
|
@@ -3877,56 +4357,32 @@ export class Editor {
|
|
|
3877
4357
|
* new element becomes the selection. No-op when no menu is pending.
|
|
3878
4358
|
*/
|
|
3879
4359
|
placeShapeAtLinkDrop(factory) {
|
|
3880
|
-
const pending = this.pendingLinkDropMenu;
|
|
4360
|
+
const pending = this.interaction.pendingLinkDropMenu;
|
|
3881
4361
|
if (!pending)
|
|
3882
4362
|
return;
|
|
3883
4363
|
const link = getLink(this._scene, pending.linkId);
|
|
3884
4364
|
if (!link) {
|
|
3885
|
-
this.pendingLinkDropMenu = null;
|
|
4365
|
+
this.interaction.pendingLinkDropMenu = null;
|
|
3886
4366
|
this.notify();
|
|
3887
4367
|
return;
|
|
3888
4368
|
}
|
|
3889
4369
|
const newId = newElementId(++this.nextId);
|
|
3890
|
-
const
|
|
3891
|
-
.filter((sh) => sh.layerId === this._activeLayerId)
|
|
3892
|
-
.map((sh) => sh.order));
|
|
3893
|
-
const built = factory({
|
|
3894
|
-
id: newId,
|
|
3895
|
-
layerId: this._activeLayerId,
|
|
3896
|
-
position: pending.world,
|
|
3897
|
-
order,
|
|
3898
|
-
});
|
|
3899
|
-
// Centre the element on the drop point regardless of how the factory
|
|
3900
|
-
// anchored it at `position`.
|
|
3901
|
-
const wb = getElementWorldBounds(built);
|
|
3902
|
-
const shape = {
|
|
3903
|
-
...built,
|
|
3904
|
-
position: {
|
|
3905
|
-
x: built.position.x + (pending.world.x - (wb.x + wb.width / 2)),
|
|
3906
|
-
y: built.position.y + (pending.world.y - (wb.y + wb.height / 2)),
|
|
3907
|
-
},
|
|
3908
|
-
};
|
|
4370
|
+
const r = computeShapeAtLinkDrop(this._scene, this._activeLayerId, pending, newId, factory);
|
|
3909
4371
|
const tx = this._history.transaction();
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
tx.add(
|
|
3913
|
-
const upd = updateLink(this._scene, pending.linkId, (e) => ({
|
|
3914
|
-
...e,
|
|
3915
|
-
[pending.side]: { kind: "floating", elementId: newId },
|
|
3916
|
-
}));
|
|
3917
|
-
this._scene = upd.scene;
|
|
3918
|
-
tx.add(upd.patch);
|
|
4372
|
+
this._scene = r.scene;
|
|
4373
|
+
tx.add(r.addPatch);
|
|
4374
|
+
tx.add(r.linkPatch);
|
|
3919
4375
|
tx.commit();
|
|
3920
|
-
this.pendingLinkDropMenu = null;
|
|
4376
|
+
this.interaction.pendingLinkDropMenu = null;
|
|
3921
4377
|
this._selection = Selection.single(newId);
|
|
3922
4378
|
this._selectedLinks = LinkSelection.EMPTY;
|
|
3923
4379
|
this.notify();
|
|
3924
4380
|
}
|
|
3925
4381
|
/** Dismiss the link-drop shape-picker, leaving the free-ended link. */
|
|
3926
4382
|
dismissLinkDropMenu() {
|
|
3927
|
-
if (!this.pendingLinkDropMenu)
|
|
4383
|
+
if (!this.interaction.pendingLinkDropMenu)
|
|
3928
4384
|
return;
|
|
3929
|
-
this.pendingLinkDropMenu = null;
|
|
4385
|
+
this.interaction.pendingLinkDropMenu = null;
|
|
3930
4386
|
this.notify();
|
|
3931
4387
|
}
|
|
3932
4388
|
/**
|
|
@@ -3937,50 +4393,15 @@ export class Editor {
|
|
|
3937
4393
|
* selection. Element + link land in one undo step.
|
|
3938
4394
|
*/
|
|
3939
4395
|
createLinkedElementFromAnchor(fromElement, anchorName) {
|
|
3940
|
-
const src = getElement(this._scene, fromElement);
|
|
3941
|
-
if (!src)
|
|
3942
|
-
return;
|
|
3943
|
-
const anchor = { kind: "named", name: anchorName };
|
|
3944
|
-
const normal = getAnchorOutwardNormal(src, anchor);
|
|
3945
|
-
const bounds = getElementWorldBounds(src);
|
|
3946
|
-
const srcCenter = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
|
|
3947
|
-
// Same-size clone → centre-to-centre distance = the source's extent
|
|
3948
|
-
// along the normal + the gap, leaving exactly
|
|
3949
|
-
// ANCHOR_CLICK_NEW_ELEMENT_GAP between the facing edges. `extentAlong`
|
|
3950
|
-
// resolves to width for a horizontal normal, height for a vertical one
|
|
3951
|
-
// (link-start dots are the four edge midpoints).
|
|
3952
|
-
const extentAlong = Math.abs(normal.x) * bounds.width + Math.abs(normal.y) * bounds.height;
|
|
3953
|
-
const dist = extentAlong + ANCHOR_CLICK_NEW_ELEMENT_GAP;
|
|
3954
|
-
const delta = { x: normal.x * dist, y: normal.y * dist };
|
|
3955
4396
|
const newId = newElementId(++this.nextId);
|
|
3956
|
-
const order = orderForTop([...this._scene.elements.values()]
|
|
3957
|
-
.filter((sh) => sh.layerId === src.layerId)
|
|
3958
|
-
.map((sh) => sh.order));
|
|
3959
|
-
let clone = {
|
|
3960
|
-
...src,
|
|
3961
|
-
id: newId,
|
|
3962
|
-
position: { x: src.position.x + delta.x, y: src.position.y + delta.y },
|
|
3963
|
-
order,
|
|
3964
|
-
};
|
|
3965
|
-
// Blank user text — the new element is a fresh same-kind shape, not a
|
|
3966
|
-
// content copy (standard). Only `text` (TextElement) and `name`
|
|
3967
|
-
// (FrameElement) carry user-entered text. Cast through `Element` because
|
|
3968
|
-
// `exactOptionalPropertyTypes` rejects the bare object literal against
|
|
3969
|
-
// the union (TS2375), even though the narrowed branch is sound.
|
|
3970
|
-
if (clone.type === "text")
|
|
3971
|
-
clone = { ...clone, text: "" };
|
|
3972
|
-
else if (clone.type === "frame")
|
|
3973
|
-
clone = { ...clone, name: "" };
|
|
3974
|
-
const tx = this._history.transaction();
|
|
3975
|
-
const added = addElement(this._scene, clone);
|
|
3976
|
-
this._scene = added.scene;
|
|
3977
|
-
tx.add(added.patch);
|
|
3978
4397
|
const linkId = newLinkId(++this.nextId);
|
|
3979
|
-
const
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
4398
|
+
const r = computeLinkedElementFromAnchor(this._scene, this._activeLayerId, fromElement, anchorName, newId, linkId);
|
|
4399
|
+
if (!r)
|
|
4400
|
+
return;
|
|
4401
|
+
const tx = this._history.transaction();
|
|
4402
|
+
this._scene = r.scene;
|
|
4403
|
+
tx.add(r.addPatch);
|
|
4404
|
+
tx.add(r.linkPatch);
|
|
3984
4405
|
tx.commit();
|
|
3985
4406
|
this._selection = Selection.single(newId);
|
|
3986
4407
|
if (this._selectedLinks.size > 0)
|
|
@@ -3994,72 +4415,7 @@ export class Editor {
|
|
|
3994
4415
|
* `createLinkedElementFromAnchor`.
|
|
3995
4416
|
*/
|
|
3996
4417
|
previewClickCreate(fromElement, anchorName) {
|
|
3997
|
-
|
|
3998
|
-
if (!src)
|
|
3999
|
-
return null;
|
|
4000
|
-
const anchor = { kind: "named", name: anchorName };
|
|
4001
|
-
const normal = getAnchorOutwardNormal(src, anchor);
|
|
4002
|
-
const b = getElementWorldBounds(src);
|
|
4003
|
-
const extentAlong = Math.abs(normal.x) * b.width + Math.abs(normal.y) * b.height;
|
|
4004
|
-
const dist = extentAlong + ANCHOR_CLICK_NEW_ELEMENT_GAP;
|
|
4005
|
-
const delta = { x: normal.x * dist, y: normal.y * dist };
|
|
4006
|
-
const bounds = { x: b.x + delta.x, y: b.y + delta.y, width: b.width, height: b.height };
|
|
4007
|
-
const fromWorld = getAnchorWorld(src, anchor);
|
|
4008
|
-
// Facing edge of the ghost (toward the source) = its centre pulled back
|
|
4009
|
-
// along the normal by half its extent.
|
|
4010
|
-
const ghostCx = bounds.x + bounds.width / 2;
|
|
4011
|
-
const ghostCy = bounds.y + bounds.height / 2;
|
|
4012
|
-
const nearEdge = {
|
|
4013
|
-
x: ghostCx - normal.x * (extentAlong / 2),
|
|
4014
|
-
y: ghostCy - normal.y * (extentAlong / 2),
|
|
4015
|
-
};
|
|
4016
|
-
// The would-be element itself — a same-kind clone of the source shifted
|
|
4017
|
-
// to the ghost bounds, with blank user text (mirrors
|
|
4018
|
-
// `createLinkedElementFromAnchor`). The overlay renders THIS through the
|
|
4019
|
-
// real renderer so the ghost looks like the actual shape (an ellipse
|
|
4020
|
-
// ghosts as an ellipse), not a bounding rect. Throwaway id — never enters
|
|
4021
|
-
// the real scene.
|
|
4022
|
-
let element = {
|
|
4023
|
-
...src,
|
|
4024
|
-
id: PREVIEW_GHOST_ELEMENT_ID,
|
|
4025
|
-
position: { x: src.position.x + delta.x, y: src.position.y + delta.y },
|
|
4026
|
-
};
|
|
4027
|
-
if (element.type === "text")
|
|
4028
|
-
element = { ...element, text: "" };
|
|
4029
|
-
else if (element.type === "frame")
|
|
4030
|
-
element = { ...element, name: "" };
|
|
4031
|
-
// Build a throwaway scene holding the ghost element + the would-be link so
|
|
4032
|
-
// the connector can be drawn through the REAL link renderer (same routing,
|
|
4033
|
-
// arrowhead and style it'll have once created) — faded — instead of a
|
|
4034
|
-
// dashed preview line. Mirrors the link build in
|
|
4035
|
-
// `createLinkedElementFromAnchor` exactly. The `path` field stays for
|
|
4036
|
-
// callers that just want the straight from→to segment.
|
|
4037
|
-
const srcCenter = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
|
|
4038
|
-
const withGhost = addElement(this._scene, element).scene;
|
|
4039
|
-
const placed = req(getElement(withGhost, PREVIEW_GHOST_ELEMENT_ID));
|
|
4040
|
-
const { ref: toRef } = findNearestAnchor(placed, srcCenter, snapExcludedAnchors(placed));
|
|
4041
|
-
const linkResult = computeCreateLink(withGhost, { kind: "anchor", elementId: fromElement, anchor }, { kind: "anchor", elementId: PREVIEW_GHOST_ELEMENT_ID, anchor: toRef }, PREVIEW_GHOST_LINK_ID, this._activeLayerId);
|
|
4042
|
-
let ghostScene = linkResult.scene;
|
|
4043
|
-
const edge = req(getLink(ghostScene, PREVIEW_GHOST_LINK_ID));
|
|
4044
|
-
if ((edge.routing ?? "straight") === "orthogonal") {
|
|
4045
|
-
const routedPoints = routeElbowLink(ghostScene, edge);
|
|
4046
|
-
ghostScene = updateLink(ghostScene, PREVIEW_GHOST_LINK_ID, (e) => ({
|
|
4047
|
-
...e,
|
|
4048
|
-
routedPoints,
|
|
4049
|
-
})).scene;
|
|
4050
|
-
}
|
|
4051
|
-
// Render only the ghost link (the shapes stay for endpoint resolution).
|
|
4052
|
-
ghostScene = {
|
|
4053
|
-
...ghostScene,
|
|
4054
|
-
links: new Map([[PREVIEW_GHOST_LINK_ID, req(getLink(ghostScene, PREVIEW_GHOST_LINK_ID))]]),
|
|
4055
|
-
};
|
|
4056
|
-
return {
|
|
4057
|
-
bounds,
|
|
4058
|
-
path: [fromWorld, nearEdge],
|
|
4059
|
-
element,
|
|
4060
|
-
ghostScene,
|
|
4061
|
-
ghostLinkId: PREVIEW_GHOST_LINK_ID,
|
|
4062
|
-
};
|
|
4418
|
+
return previewClickCreatePure(this._scene, this._activeLayerId, fromElement, anchorName);
|
|
4063
4419
|
}
|
|
4064
4420
|
/**
|
|
4065
4421
|
* Build an `LinkEndpoint` for a draw-edge / re-bind gesture. Runs the
|
|
@@ -4073,61 +4429,10 @@ export class Editor {
|
|
|
4073
4429
|
* shapes when the user clearly aimed for this one).
|
|
4074
4430
|
*/
|
|
4075
4431
|
snapLinkEndpoint(pressTargetElement, worldPoint) {
|
|
4076
|
-
|
|
4077
|
-
scene: this._scene,
|
|
4078
|
-
probe: worldPoint,
|
|
4079
|
-
threshold: this.snapThreshold,
|
|
4080
|
-
gesture: "draw-edge",
|
|
4081
|
-
});
|
|
4082
|
-
// Attach contract: dropping on a port dot → *fixed* anchor; dropping
|
|
4083
|
-
// near an EDGE (not a dot) → *fixed* outline point (a ratio along the
|
|
4084
|
-
// perimeter — survives move/resize); dropping on the body interior (no
|
|
4085
|
-
// snap to a dot or edge) → *floating* against the whole shape, so the
|
|
4086
|
-
// connection re-aims at the partner as either shape moves; dropping on
|
|
4087
|
-
// empty canvas → a free point.
|
|
4088
|
-
//
|
|
4089
|
-
// Pick a candidate ON the pressed shape if there is one, otherwise the
|
|
4090
|
-
// nearest overall. The "nearest overall" branch matters because the
|
|
4091
|
-
// attach DOTS are drawn OUTSIDE the body — a release on a dot finds no
|
|
4092
|
-
// element under it (hit-test = empty), so `pressTargetElement` is null,
|
|
4093
|
-
// yet the snap engine still reports the dot's anchor within threshold.
|
|
4094
|
-
// Binding it makes the endpoint enter PERPENDICULAR to that edge instead
|
|
4095
|
-
// of staying a free point that aims at the partner (jumping between the
|
|
4096
|
-
// four sides).
|
|
4097
|
-
const pick = (kind) => {
|
|
4098
|
-
if (pressTargetElement !== null) {
|
|
4099
|
-
const onTarget = result.all.find((c) => c.kind === kind && c.metadata?.elementId === pressTargetElement);
|
|
4100
|
-
if (onTarget)
|
|
4101
|
-
return onTarget;
|
|
4102
|
-
}
|
|
4103
|
-
return result.all.find((c) => c.kind === kind);
|
|
4104
|
-
};
|
|
4105
|
-
const boundFrom = (cand, want) => {
|
|
4106
|
-
if (!cand)
|
|
4107
|
-
return null;
|
|
4108
|
-
const elId = cand.metadata?.elementId;
|
|
4109
|
-
if (elId === undefined)
|
|
4110
|
-
return null;
|
|
4111
|
-
const shp = getElement(this._scene, elId);
|
|
4112
|
-
if (!shp)
|
|
4113
|
-
return null;
|
|
4114
|
-
const ep = endpointFromSnap(elId, cand, shp);
|
|
4115
|
-
return ep.kind === want ? ep : null;
|
|
4116
|
-
};
|
|
4117
|
-
const anchorEp = boundFrom(pick("anchor"), "anchor");
|
|
4118
|
-
if (anchorEp)
|
|
4119
|
-
return anchorEp;
|
|
4120
|
-
const outlineEp = boundFrom(pick("outline"), "outline");
|
|
4121
|
-
if (outlineEp)
|
|
4122
|
-
return outlineEp;
|
|
4123
|
-
// No dot/edge snap. Over a shape body → floating; else a free point.
|
|
4124
|
-
if (pressTargetElement !== null && getElement(this._scene, pressTargetElement)) {
|
|
4125
|
-
return { kind: "floating", elementId: pressTargetElement };
|
|
4126
|
-
}
|
|
4127
|
-
return { kind: "point", position: worldPoint };
|
|
4432
|
+
return snapLinkEndpointPure(this._scene, this.snapEngine, this.snapThreshold, pressTargetElement, worldPoint);
|
|
4128
4433
|
}
|
|
4129
|
-
//
|
|
4130
|
-
//
|
|
4434
|
+
// The wrappers here own the side effects (`_selectedLink`
|
|
4435
|
+
// clearing, notify).
|
|
4131
4436
|
applySelectByBounds(bounds, mode) {
|
|
4132
4437
|
const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode);
|
|
4133
4438
|
const nextLinks = selectLinksByBoundsLivePure(this._scene, mode === "add" ? this._selectedLinks : LinkSelection.EMPTY, (id) => this.isLayerLocked(id), bounds, mode);
|
|
@@ -4141,9 +4446,9 @@ export class Editor {
|
|
|
4141
4446
|
this.notify();
|
|
4142
4447
|
}
|
|
4143
4448
|
applyLassoLiveSelection(bounds, mode) {
|
|
4144
|
-
const base = this.lassoBaseSelection ?? Selection.EMPTY;
|
|
4449
|
+
const base = this.interaction.lassoBaseSelection ?? Selection.EMPTY;
|
|
4145
4450
|
const next = selectByBoundsLivePure(this._scene, base, (id) => this.isLayerLocked(id), bounds, mode);
|
|
4146
|
-
const linkBase = this.lassoBaseLinks ?? LinkSelection.EMPTY;
|
|
4451
|
+
const linkBase = this.interaction.lassoBaseLinks ?? LinkSelection.EMPTY;
|
|
4147
4452
|
const nextLinks = selectLinksByBoundsLivePure(this._scene, linkBase, (id) => this.isLayerLocked(id), bounds, mode);
|
|
4148
4453
|
const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
|
|
4149
4454
|
if (Selection.equals(next, this._selection) && !linksChanged)
|
|
@@ -4151,9 +4456,8 @@ export class Editor {
|
|
|
4151
4456
|
this._selectedLinks = nextLinks;
|
|
4152
4457
|
this._selection = next;
|
|
4153
4458
|
}
|
|
4154
|
-
//
|
|
4155
|
-
//
|
|
4156
|
-
// notify).
|
|
4459
|
+
// The wrapper here owns the side effects (history push,
|
|
4460
|
+
// drag-state clearing, notify).
|
|
4157
4461
|
/**
|
|
4158
4462
|
* Live endpoint-rebind move: re-point the dragged end to the cursor in the
|
|
4159
4463
|
* scene (a free `point` endpoint), recorded in the gesture transaction so the
|
|
@@ -4163,57 +4467,14 @@ export class Editor {
|
|
|
4163
4467
|
* link snaps back to where it was. The handle dot follows via `linkEndpointDrag`.
|
|
4164
4468
|
*/
|
|
4165
4469
|
applyLinkEndpointMove(linkId, side, toPoint) {
|
|
4166
|
-
|
|
4167
|
-
if (!edge)
|
|
4168
|
-
return;
|
|
4169
|
-
// A real drag breaks the handle double-click chain (mirrors waypoint /
|
|
4170
|
-
// segment drags) so a quick click after dropping isn't read as a delete.
|
|
4171
|
-
this.lastHandleClickAt = 0;
|
|
4172
|
-
// Resolve the attach target under the cursor and snap the endpoint to it
|
|
4173
|
-
// with the SAME logic the drop uses, so the link attaches LIVE exactly as it
|
|
4174
|
-
// will commit — lands on the dot (fixed), floats on the body, or stays a
|
|
4175
|
-
// free point over empty space.
|
|
4176
|
-
const target = this.linkAttachTargetAt(toPoint);
|
|
4177
|
-
const targetId = target?.kind === "element" ? target.id : null;
|
|
4178
|
-
const ep = this.snapLinkEndpoint(targetId, toPoint);
|
|
4179
|
-
const r = updateLink(this._scene, linkId, (e) => side === "from" ? { ...e, from: ep } : { ...e, to: ep });
|
|
4180
|
-
this._scene = r.scene;
|
|
4181
|
-
this.recordGesturePatch(r.patch);
|
|
4182
|
-
this.linkEndpointDrag = { linkId, side, toPoint };
|
|
4183
|
-
// Attach-point highlight — the SAME feedback as drawing a new link
|
|
4184
|
-
// (candidate dots + float-element halo), driven by `hoveredLinkTarget`.
|
|
4185
|
-
this.updateHoveredLinkTarget(toPoint);
|
|
4186
|
-
this.notify();
|
|
4470
|
+
this.linkHandles.applyEndpointMove(linkId, side, toPoint);
|
|
4187
4471
|
}
|
|
4188
4472
|
applyLinkEndpointUpdate(emit) {
|
|
4189
|
-
|
|
4190
|
-
// snapped endpoint goes into the SAME transaction so the net history step is
|
|
4191
|
-
// original → final (one undo). A pure click (no move, no tx) that resolves
|
|
4192
|
-
// to a no-op change must not leave a junk undo entry.
|
|
4193
|
-
const moved = this.gestureTx !== null;
|
|
4194
|
-
const result = computeLinkEndpointUpdate(this._scene, emit, (toElement, toPoint) => this.snapLinkEndpoint(toElement, toPoint));
|
|
4195
|
-
if (result === null) {
|
|
4196
|
-
this.cancelGesture();
|
|
4197
|
-
this.linkEndpointDrag = null;
|
|
4198
|
-
this.hoveredLinkTarget = null;
|
|
4199
|
-
this.notify();
|
|
4200
|
-
return;
|
|
4201
|
-
}
|
|
4202
|
-
if (!moved && isNoop(result.patch)) {
|
|
4203
|
-
this.linkEndpointDrag = null;
|
|
4204
|
-
this.hoveredLinkTarget = null;
|
|
4205
|
-
this.notify();
|
|
4206
|
-
return;
|
|
4207
|
-
}
|
|
4208
|
-
this._scene = result.scene;
|
|
4209
|
-
this.recordGesturePatch(result.patch);
|
|
4210
|
-
this.commitGesture();
|
|
4211
|
-
this.linkEndpointDrag = null;
|
|
4212
|
-
this.hoveredLinkTarget = null;
|
|
4473
|
+
this.linkHandles.applyEndpointUpdate(emit);
|
|
4213
4474
|
}
|
|
4214
4475
|
/** True while a waypoint of the selected link is being dragged. */
|
|
4215
4476
|
get isDraggingWaypoint() {
|
|
4216
|
-
return this.
|
|
4477
|
+
return this.linkHandles.isDraggingWaypoint;
|
|
4217
4478
|
}
|
|
4218
4479
|
/**
|
|
4219
4480
|
* Begin a host-managed waypoint drag. `insert` splices a new waypoint at
|
|
@@ -4222,34 +4483,11 @@ export class Editor {
|
|
|
4222
4483
|
* transaction so the whole drag is one undo step.
|
|
4223
4484
|
*/
|
|
4224
4485
|
beginWaypointDrag(linkId, index, insert) {
|
|
4225
|
-
|
|
4226
|
-
return;
|
|
4227
|
-
this.linkWaypointDrag = { linkId, index, pendingInsert: insert };
|
|
4486
|
+
this.linkHandles.beginWaypointDrag(linkId, index, insert);
|
|
4228
4487
|
}
|
|
4229
4488
|
/** Live update of the dragged waypoint to `world`. */
|
|
4230
4489
|
updateWaypointDrag(world) {
|
|
4231
|
-
|
|
4232
|
-
if (!drag)
|
|
4233
|
-
return;
|
|
4234
|
-
// A real drag breaks the handle double-click chain (see updateSegmentDrag).
|
|
4235
|
-
this.lastHandleClickAt = 0;
|
|
4236
|
-
const edge = getLink(this._scene, drag.linkId);
|
|
4237
|
-
if (!edge)
|
|
4238
|
-
return;
|
|
4239
|
-
const wps = [...(edge.waypoints ?? [])];
|
|
4240
|
-
if (drag.pendingInsert) {
|
|
4241
|
-
wps.splice(drag.index, 0, world);
|
|
4242
|
-
drag.pendingInsert = false;
|
|
4243
|
-
}
|
|
4244
|
-
else {
|
|
4245
|
-
if (drag.index < 0 || drag.index >= wps.length)
|
|
4246
|
-
return;
|
|
4247
|
-
wps[drag.index] = world;
|
|
4248
|
-
}
|
|
4249
|
-
const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, waypoints: wps }));
|
|
4250
|
-
this._scene = r.scene;
|
|
4251
|
-
this.recordGesturePatch(r.patch);
|
|
4252
|
-
this.notify();
|
|
4490
|
+
this.linkHandles.updateWaypointDrag(world);
|
|
4253
4491
|
}
|
|
4254
4492
|
/**
|
|
4255
4493
|
* Finish the waypoint drag. If the dragged waypoint landed within
|
|
@@ -4257,38 +4495,11 @@ export class Editor {
|
|
|
4257
4495
|
* (drag-onto-the-line to delete). A no-move insert adds nothing.
|
|
4258
4496
|
*/
|
|
4259
4497
|
endWaypointDrag() {
|
|
4260
|
-
|
|
4261
|
-
this.linkWaypointDrag = null;
|
|
4262
|
-
if (!drag)
|
|
4263
|
-
return;
|
|
4264
|
-
if (drag.pendingInsert) {
|
|
4265
|
-
// Never moved → it was a click on a midpoint; nothing inserted.
|
|
4266
|
-
this.commitGesture();
|
|
4267
|
-
return;
|
|
4268
|
-
}
|
|
4269
|
-
const edge = getLink(this._scene, drag.linkId);
|
|
4270
|
-
if (edge?.waypoints && drag.index >= 0 && drag.index < edge.waypoints.length) {
|
|
4271
|
-
const path = getLinkPath(this._scene, edge);
|
|
4272
|
-
const wp = req(edge.waypoints[drag.index]);
|
|
4273
|
-
// Neighbours in the [from, ...waypoints, to] chain: path[index] and
|
|
4274
|
-
// path[index + 2] (path[0] = from, so waypoint i sits at path[i + 1]).
|
|
4275
|
-
// Dropping the waypoint back onto the straight segment between its
|
|
4276
|
-
// neighbours removes the bend ("drag onto the line to delete").
|
|
4277
|
-
const collapse = WAYPOINT_COLLAPSE_RADIUS / (this._scene.viewport.zoom || 1);
|
|
4278
|
-
const a = path?.[drag.index];
|
|
4279
|
-
const b = path?.[drag.index + 2];
|
|
4280
|
-
if (a && b && distanceToSegmentPt(wp, a, b) <= collapse) {
|
|
4281
|
-
const wps = edge.waypoints.filter((_, i) => i !== drag.index);
|
|
4282
|
-
const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, waypoints: wps }));
|
|
4283
|
-
this._scene = r.scene;
|
|
4284
|
-
this.recordGesturePatch(r.patch);
|
|
4285
|
-
}
|
|
4286
|
-
}
|
|
4287
|
-
this.commitGesture();
|
|
4498
|
+
this.linkHandles.endWaypointDrag();
|
|
4288
4499
|
}
|
|
4289
4500
|
/** True while an elbow segment is being dragged. */
|
|
4290
4501
|
get isDraggingSegment() {
|
|
4291
|
-
return this.
|
|
4502
|
+
return this.linkHandles.isDraggingSegment;
|
|
4292
4503
|
}
|
|
4293
4504
|
/**
|
|
4294
4505
|
* Begin a host-managed elbow segment drag. `axis` is the segment's
|
|
@@ -4296,9 +4507,7 @@ export class Editor {
|
|
|
4296
4507
|
* across re-routes).
|
|
4297
4508
|
*/
|
|
4298
4509
|
beginSegmentDrag(linkId, axis, at) {
|
|
4299
|
-
|
|
4300
|
-
return;
|
|
4301
|
-
this.linkSegmentDrag = { linkId, axis, at };
|
|
4510
|
+
this.linkHandles.beginSegmentDrag(linkId, axis, at);
|
|
4302
4511
|
}
|
|
4303
4512
|
/**
|
|
4304
4513
|
* Move the dragged elbow segment perpendicular to its axis: pin its
|
|
@@ -4306,34 +4515,11 @@ export class Editor {
|
|
|
4306
4515
|
* rest around the pin (one undo step via the gesture transaction).
|
|
4307
4516
|
*/
|
|
4308
4517
|
updateSegmentDrag(world) {
|
|
4309
|
-
|
|
4310
|
-
if (!drag)
|
|
4311
|
-
return;
|
|
4312
|
-
// A real drag breaks the handle double-click chain, so a single click
|
|
4313
|
-
// right after pinning can't be misread as a double-click (= delete).
|
|
4314
|
-
this.lastHandleClickAt = 0;
|
|
4315
|
-
const edge = getLink(this._scene, drag.linkId);
|
|
4316
|
-
if (!edge)
|
|
4317
|
-
return;
|
|
4318
|
-
const pos = drag.axis === "h" ? world.y : world.x;
|
|
4319
|
-
const fixed = [...(edge.fixedSegments ?? [])];
|
|
4320
|
-
const entry = { axis: drag.axis, pos, at: drag.at };
|
|
4321
|
-
const at = fixed.findIndex((f) => f.axis === drag.axis && Math.abs(f.at - drag.at) < 0.5);
|
|
4322
|
-
if (at >= 0)
|
|
4323
|
-
fixed[at] = entry;
|
|
4324
|
-
else
|
|
4325
|
-
fixed.push(entry);
|
|
4326
|
-
const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, fixedSegments: fixed }));
|
|
4327
|
-
this._scene = r.scene;
|
|
4328
|
-
this.recordGesturePatch(r.patch);
|
|
4329
|
-
this.notify();
|
|
4518
|
+
this.linkHandles.updateSegmentDrag(world);
|
|
4330
4519
|
}
|
|
4331
4520
|
/** Finish the elbow segment drag (commit the gesture as one undo step). */
|
|
4332
4521
|
endSegmentDrag() {
|
|
4333
|
-
|
|
4334
|
-
return;
|
|
4335
|
-
this.linkSegmentDrag = null;
|
|
4336
|
-
this.commitGesture();
|
|
4522
|
+
this.linkHandles.endSegmentDrag();
|
|
4337
4523
|
}
|
|
4338
4524
|
/**
|
|
4339
4525
|
* Double-click detector for link edit handles (waypoint / segment).
|
|
@@ -4343,27 +4529,14 @@ export class Editor {
|
|
|
4343
4529
|
* `onDown`, so that path never sees them).
|
|
4344
4530
|
*/
|
|
4345
4531
|
isHandleDoubleClick(world) {
|
|
4346
|
-
|
|
4347
|
-
const isDouble = now - this.lastHandleClickAt < DOUBLE_CLICK_MS &&
|
|
4348
|
-
this.lastHandleClickWorld !== null &&
|
|
4349
|
-
distanceTo(this.lastHandleClickWorld, world) <= DOUBLE_CLICK_TOLERANCE_PX;
|
|
4350
|
-
this.lastHandleClickAt = now;
|
|
4351
|
-
this.lastHandleClickWorld = world;
|
|
4352
|
-
return isDouble;
|
|
4532
|
+
return this.linkHandles.isHandleDoubleClick(world);
|
|
4353
4533
|
}
|
|
4354
4534
|
/**
|
|
4355
4535
|
* Delete a free bend point (waypoint) from a straight / bezier link by
|
|
4356
4536
|
* index — double-click a waypoint handle to remove it. One undo step.
|
|
4357
4537
|
*/
|
|
4358
4538
|
deleteWaypoint(linkId, index) {
|
|
4359
|
-
|
|
4360
|
-
if (!edge?.waypoints || index < 0 || index >= edge.waypoints.length)
|
|
4361
|
-
return;
|
|
4362
|
-
const wps = edge.waypoints.filter((_, i) => i !== index);
|
|
4363
|
-
const r = updateLink(this._scene, linkId, (e) => ({ ...e, waypoints: wps }));
|
|
4364
|
-
this._scene = r.scene;
|
|
4365
|
-
this._history.push(r.patch);
|
|
4366
|
-
this.notify();
|
|
4539
|
+
this.linkHandles.deleteWaypoint(linkId, index);
|
|
4367
4540
|
}
|
|
4368
4541
|
/**
|
|
4369
4542
|
* Remove the pinned (fixed) elbow segment that matches the given
|
|
@@ -4374,28 +4547,7 @@ export class Editor {
|
|
|
4374
4547
|
* step.
|
|
4375
4548
|
*/
|
|
4376
4549
|
resetSegmentPin(linkId, axis, pos, at) {
|
|
4377
|
-
|
|
4378
|
-
if (!edge?.fixedSegments || edge.fixedSegments.length === 0)
|
|
4379
|
-
return;
|
|
4380
|
-
let bestIdx = -1;
|
|
4381
|
-
let bestD = Infinity;
|
|
4382
|
-
for (let i = 0; i < edge.fixedSegments.length; i++) {
|
|
4383
|
-
const f = req(edge.fixedSegments[i]);
|
|
4384
|
-
if (f.axis !== axis)
|
|
4385
|
-
continue;
|
|
4386
|
-
const d = Math.abs(f.pos - pos) + Math.abs(f.at - at) * 0.001;
|
|
4387
|
-
if (d < bestD) {
|
|
4388
|
-
bestD = d;
|
|
4389
|
-
bestIdx = i;
|
|
4390
|
-
}
|
|
4391
|
-
}
|
|
4392
|
-
if (bestIdx < 0)
|
|
4393
|
-
return;
|
|
4394
|
-
const fixed = edge.fixedSegments.filter((_, i) => i !== bestIdx);
|
|
4395
|
-
const r = updateLink(this._scene, linkId, (e) => ({ ...e, fixedSegments: fixed }));
|
|
4396
|
-
this._scene = r.scene;
|
|
4397
|
-
this._history.push(r.patch);
|
|
4398
|
-
this.notify();
|
|
4550
|
+
this.linkHandles.resetSegmentPin(linkId, axis, pos, at);
|
|
4399
4551
|
}
|
|
4400
4552
|
/** Whether the selected link has obstacle-avoidance routing enabled. */
|
|
4401
4553
|
get selectedLinkAvoidsObstacles() {
|
|
@@ -4414,6 +4566,8 @@ export class Editor {
|
|
|
4414
4566
|
* derived (recomputed by `rerouteElbows`). No-op when no link is selected.
|
|
4415
4567
|
*/
|
|
4416
4568
|
setSelectedLinkAvoidObstacles(enabled) {
|
|
4569
|
+
if (this.readOnly)
|
|
4570
|
+
return;
|
|
4417
4571
|
const id = this.selectedLink;
|
|
4418
4572
|
if (id === null)
|
|
4419
4573
|
return;
|
|
@@ -4428,7 +4582,7 @@ export class Editor {
|
|
|
4428
4582
|
this._scene = r.scene;
|
|
4429
4583
|
this._history.push(r.patch);
|
|
4430
4584
|
// Force the next reroute to recompute with the new mode.
|
|
4431
|
-
this.
|
|
4585
|
+
this.elbowRoutes.delete(id);
|
|
4432
4586
|
this.notify();
|
|
4433
4587
|
}
|
|
4434
4588
|
updateHoveredLinkTarget(worldPoint) {
|
|
@@ -4451,7 +4605,8 @@ export class Editor {
|
|
|
4451
4605
|
const onTarget = result.all.filter((c) => c.metadata?.elementId === shape.id);
|
|
4452
4606
|
const anchor = onTarget.find((c) => c.kind === "anchor");
|
|
4453
4607
|
const outline = onTarget.find((c) => c.kind === "outline");
|
|
4454
|
-
const
|
|
4608
|
+
const refMeta = anchor?.metadata?.ref;
|
|
4609
|
+
const ref = isAnchorRef(refMeta) ? refMeta : undefined;
|
|
4455
4610
|
let activeName = null;
|
|
4456
4611
|
if (ref?.kind === "named") {
|
|
4457
4612
|
activeName = ref.name;
|
|
@@ -4474,7 +4629,6 @@ export class Editor {
|
|
|
4474
4629
|
this.hoveredLinkTarget = { elementId: shape.id, activeAnchor: activeName, outlinePoint, mode };
|
|
4475
4630
|
this.notify();
|
|
4476
4631
|
}
|
|
4477
|
-
// Pure body in `./editor/applies/edge.ts`.
|
|
4478
4632
|
applyLinkPreview(fromElement, fromPoint, toPoint) {
|
|
4479
4633
|
const ep = computeLinkPreviewEndpoints(this._scene, fromElement, fromPoint, toPoint);
|
|
4480
4634
|
// Match the preview to the connector that will be committed: when new
|
|
@@ -4491,9 +4645,7 @@ export class Editor {
|
|
|
4491
4645
|
this.notify();
|
|
4492
4646
|
}
|
|
4493
4647
|
// Gesture lifecycle — recordGesturePatch / commitGesture /
|
|
4494
|
-
// cancelGesture / finalizeOpenGestureTx / maybeRevertModeAfterCreate
|
|
4495
|
-
// live in `./editor/gesture-tx.ts`. The thin instance methods below
|
|
4496
|
-
// preserve the original call sites.
|
|
4648
|
+
// cancelGesture / finalizeOpenGestureTx / maybeRevertModeAfterCreate.
|
|
4497
4649
|
recordGesturePatch(patch) {
|
|
4498
4650
|
// Snapshot the pre-gesture scene the moment the transaction opens, so a
|
|
4499
4651
|
// later cancel/Escape can restore it (the history tx only records undo data,
|
|
@@ -4504,7 +4656,8 @@ export class Editor {
|
|
|
4504
4656
|
this.gestures.record(patch);
|
|
4505
4657
|
}
|
|
4506
4658
|
commitGesture() {
|
|
4507
|
-
this.
|
|
4659
|
+
this.interaction.resizeOriginElement = null;
|
|
4660
|
+
this.rotateGestureOrigin = null;
|
|
4508
4661
|
this.gestures.commit();
|
|
4509
4662
|
this.gestureStartScene = null;
|
|
4510
4663
|
}
|
|
@@ -4525,9 +4678,9 @@ export class Editor {
|
|
|
4525
4678
|
* - Cycles (a container inside its own descendant) are prevented by the
|
|
4526
4679
|
* `containerHover` pipeline above — the exclude set rules them out.
|
|
4527
4680
|
*/
|
|
4528
|
-
//
|
|
4529
|
-
//
|
|
4530
|
-
//
|
|
4681
|
+
// Editor exposes a small `ContainerOpsRef` bridge so the pure
|
|
4682
|
+
// helper can mutate scene + push patches into the running gesture
|
|
4683
|
+
// transaction.
|
|
4531
4684
|
applyContainerDrop(worldPoint) {
|
|
4532
4685
|
applyContainerDropPure(this.containerOpsRef, worldPoint);
|
|
4533
4686
|
}
|
|
@@ -4535,7 +4688,6 @@ export class Editor {
|
|
|
4535
4688
|
maybeGrowContainer(containerId, childId) {
|
|
4536
4689
|
maybeGrowContainerPure(this.containerOpsRef, containerId, childId);
|
|
4537
4690
|
}
|
|
4538
|
-
// Pure body in `./editor/container-ops.ts`.
|
|
4539
4691
|
clampContainerToChildren(shape, raw, handle) {
|
|
4540
4692
|
return clampContainerToChildrenPure(this._scene, shape, raw, handle);
|
|
4541
4693
|
}
|
|
@@ -4548,9 +4700,9 @@ export class Editor {
|
|
|
4548
4700
|
this.gestureTx ??= this._history.transaction();
|
|
4549
4701
|
return this.gestureTx;
|
|
4550
4702
|
}
|
|
4551
|
-
// Body moved to `./editor/gesture-tx.ts`.
|
|
4552
4703
|
cancelGesture() {
|
|
4553
|
-
this.
|
|
4704
|
+
this.interaction.resizeOriginElement = null;
|
|
4705
|
+
this.interaction.rotateGestureOrigin = null;
|
|
4554
4706
|
this.gestures.cancel();
|
|
4555
4707
|
// Roll the scene back to the pre-gesture snapshot — cancelling the history
|
|
4556
4708
|
// transaction alone leaves the live drag mutations in `_scene`.
|
|
@@ -4642,9 +4794,8 @@ export class Editor {
|
|
|
4642
4794
|
/**
|
|
4643
4795
|
* Typed event surface — subscribe to a specific slice (`mode`,
|
|
4644
4796
|
* `selection`, `scene`, `history`, `viewport`) or the umbrella
|
|
4645
|
-
* `change`.
|
|
4646
|
-
*
|
|
4647
|
-
* `subscribe()` still works and fires in lock-step.
|
|
4797
|
+
* `change`. For callers that only care about one dimension;
|
|
4798
|
+
* `subscribe()` fires in lock-step with these.
|
|
4648
4799
|
*/
|
|
4649
4800
|
on(event, fn) {
|
|
4650
4801
|
// Cast through `never`: TS can't prove that EditorEvents[K]
|
|
@@ -4678,85 +4829,179 @@ export class Editor {
|
|
|
4678
4829
|
return patch;
|
|
4679
4830
|
}
|
|
4680
4831
|
/**
|
|
4681
|
-
*
|
|
4682
|
-
*
|
|
4683
|
-
*
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
}
|
|
4709
|
-
return base + digest;
|
|
4710
|
-
}
|
|
4711
|
-
return base;
|
|
4712
|
-
}
|
|
4713
|
-
/**
|
|
4714
|
-
* Choke-point reroute (standard model): recompute `routedPoints` for
|
|
4715
|
-
* every orthogonal link whose inputs changed since the last pass, and
|
|
4716
|
-
* bake the result into `_scene`. Runs once per frame before paint —
|
|
4717
|
-
* derived state, so no history push / notify (would loop). Cheap when
|
|
4718
|
-
* nothing moved (signature short-circuit).
|
|
4832
|
+
* Derived elbow-route cache, keyed by link — the source of truth for
|
|
4833
|
+
* A*-routed corners, living OUTSIDE the immutable `Scene`. Each entry holds
|
|
4834
|
+
* the routed interior `points` (between from/to) plus the `sig` of the
|
|
4835
|
+
* inputs it was computed from (endpoint refs + bound-shape bounds +
|
|
4836
|
+
* fixedSegments), so an unchanged link short-circuits the reroute.
|
|
4837
|
+
*
|
|
4838
|
+
* `rerouteElbows` still MIRRORS `points` onto `Link.routedPoints` in
|
|
4839
|
+
* `_scene` because three readers still consume the baked field: the render
|
|
4840
|
+
* path (`getLinkPath` in `renderer-core`), the headless `getLinkPath`, and
|
|
4841
|
+
* serialization (`schema.ts`). The mirror is compat-only derived state — no
|
|
4842
|
+
* history push / notify.
|
|
4843
|
+
*
|
|
4844
|
+
* TODO(fable R7a): drop the `_scene` mirror once (a) the render path reads
|
|
4845
|
+
* routes from this cache via the `RenderSnapshot`, and (b) headless
|
|
4846
|
+
* `getLinkPath` / serialization stop depending on baked `routedPoints`.
|
|
4847
|
+
* That eviction changes headless geometry, serialized output, and
|
|
4848
|
+
* collab-synced fields, so it spans the scene / serialization / renderer /
|
|
4849
|
+
* headless goldens and must land as its own cross-package change — out of
|
|
4850
|
+
* scope for this state-only pass.
|
|
4851
|
+
*/
|
|
4852
|
+
elbowRoutes = new Map();
|
|
4853
|
+
/**
|
|
4854
|
+
* Choke-point reroute (standard model): recompute the route for every
|
|
4855
|
+
* orthogonal link whose inputs changed since the last pass, store it in the
|
|
4856
|
+
* derived {@link elbowRoutes} cache, and mirror it onto `_scene`. Runs once
|
|
4857
|
+
* per frame before paint — derived state, so no history push / notify (would
|
|
4858
|
+
* loop). Cheap when nothing moved (signature short-circuit).
|
|
4719
4859
|
*/
|
|
4720
4860
|
rerouteElbows() {
|
|
4721
4861
|
let next = this._scene;
|
|
4722
4862
|
for (const [id, edge] of this._scene.links) {
|
|
4723
4863
|
if ((edge.routing ?? "straight") !== "orthogonal")
|
|
4724
4864
|
continue;
|
|
4725
|
-
const sig = this.
|
|
4726
|
-
if (this.
|
|
4865
|
+
const sig = elbowSignature(this._scene, edge);
|
|
4866
|
+
if (this.elbowRoutes.get(id)?.sig === sig)
|
|
4727
4867
|
continue;
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4868
|
+
const points = routeElbowLink(next, edge);
|
|
4869
|
+
this.elbowRoutes.set(id, { sig, points });
|
|
4870
|
+
// Compat mirror onto the scene (see `elbowRoutes` doc).
|
|
4871
|
+
next = updateLink(next, id, (e) => ({ ...e, routedPoints: points })).scene;
|
|
4731
4872
|
}
|
|
4732
4873
|
this._scene = next;
|
|
4733
4874
|
}
|
|
4734
|
-
|
|
4875
|
+
/**
|
|
4876
|
+
* Collect everything {@link renderEditor} paints from into a flat
|
|
4877
|
+
* {@link RenderSnapshot}. Resolves the derived viewport / dirty-rect / dim /
|
|
4878
|
+
* hide inputs and the shared spatial index up front (same order the
|
|
4879
|
+
* orchestrator used to call them in), so the paint pass stays side-effect
|
|
4880
|
+
* free and the orchestrator stays decoupled from this class.
|
|
4881
|
+
*
|
|
4882
|
+
* `computeDirtyWorld` is order-sensitive (it diffs against
|
|
4883
|
+
* `lastRenderedScene` and populates `tileDirtyElements`); it runs here and
|
|
4884
|
+
* the `lastRendered*` bookkeeping is applied in `render()` after the paint.
|
|
4885
|
+
*/
|
|
4886
|
+
buildRenderSnapshot() {
|
|
4887
|
+
// Stroke-erase live preview: the touched originals are hidden in the main
|
|
4888
|
+
// pass and their would-be fragments drawn on the overlay.
|
|
4889
|
+
const strokeErasePreview = this.computeStrokeErasePreview();
|
|
4890
|
+
const baseHidden = this.computeHiddenElements();
|
|
4891
|
+
const hideElements = strokeErasePreview === null
|
|
4892
|
+
? baseHidden
|
|
4893
|
+
: new Set([...(baseHidden ?? []), ...strokeErasePreview.hidden]);
|
|
4894
|
+
return {
|
|
4895
|
+
mainTarget: this.mainTarget,
|
|
4896
|
+
overlayTarget: this.overlayTarget,
|
|
4897
|
+
backgroundTarget: this.backgroundTarget,
|
|
4898
|
+
scene: this._scene,
|
|
4899
|
+
selection: this._selection,
|
|
4900
|
+
selectedLinks: this._selectedLinks,
|
|
4901
|
+
selectedLink: this.selectedLink,
|
|
4902
|
+
selectedAnnotation: this._selectedAnnotation,
|
|
4903
|
+
enteredGroup: this._enteredGroup,
|
|
4904
|
+
gridEnabled: this.gridEnabled,
|
|
4905
|
+
viewportWorld: this.computeViewportWorld(),
|
|
4906
|
+
dirtyWorld: this.computeDirtyWorld(),
|
|
4907
|
+
dimElements: this.computeDimSet(),
|
|
4908
|
+
eraseActive: (this.interaction.eraseStroke?.pending.size ?? 0) > 0,
|
|
4909
|
+
hideElements,
|
|
4910
|
+
strokeErasePreview,
|
|
4911
|
+
sharedIndex: this._scene.elements.size >= LARGE_SCENE_HIT_THRESHOLD ? this.ensureSpatialIndex() : null,
|
|
4912
|
+
boundsCache: this.boundsCache,
|
|
4913
|
+
// Per-instance playback clock threaded through the render context (see
|
|
4914
|
+
// RenderSnapshot.animationClock). Feeds the renderer our per-shape
|
|
4915
|
+
// playback state so paused / reduced-motion GIFs freeze and resumed ones
|
|
4916
|
+
// continue from the right frame — without mutating the process-global
|
|
4917
|
+
// clock each frame, so two editors on one page don't interfere.
|
|
4918
|
+
// A non-string / missing id maps to an untracked key, for which
|
|
4919
|
+
// `clock` falls back to the wall clock.
|
|
4920
|
+
animationClock: (shape) => this.gifPlayback.clock(castElementId(typeof shape.id === "string" ? shape.id : "")),
|
|
4921
|
+
tileComposeFn: this.tileComposeFn,
|
|
4922
|
+
tileDirtyElements: this.tileDirtyElements,
|
|
4923
|
+
mode: this.mode,
|
|
4924
|
+
activeLayerId: this._activeLayerId,
|
|
4925
|
+
cropFrame: this.cropFrameCorners(),
|
|
4926
|
+
cropGhost: this.cropGhost(),
|
|
4927
|
+
flowchartPreview: this.flowchartPreview,
|
|
4928
|
+
lassoPreview: this.lassoPreview,
|
|
4929
|
+
drawingPreview: this.drawingPreview,
|
|
4930
|
+
edgePreview: this.edgePreview,
|
|
4931
|
+
linkDragFromAnchor: this.linkDragFromAnchor,
|
|
4932
|
+
hoveredLinkTarget: this.hoveredLinkTarget,
|
|
4933
|
+
panGesture: this.panGesture,
|
|
4934
|
+
// `pinch` is unset during the constructor's first render; type says
|
|
4935
|
+
// non-null but runtime can be undefined.
|
|
4936
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- see above
|
|
4937
|
+
pinchActive: this.pinch?.isActive() ?? false,
|
|
4938
|
+
gestureActive: this.gestureTx !== null,
|
|
4939
|
+
linkEndpointDrag: this.linkEndpointDrag,
|
|
4940
|
+
linkSegmentDrag: this.linkSegmentDrag,
|
|
4941
|
+
linkWaypointDrag: this.linkWaypointDrag,
|
|
4942
|
+
hoverCursorWorld: this.hoverCursorWorld,
|
|
4943
|
+
anchorStartHitSlop: this.anchorStartHitSlop,
|
|
4944
|
+
anchorClickRadius: this.anchorClickRadius,
|
|
4945
|
+
containerHover: this.containerHover,
|
|
4946
|
+
brushStroke: this.brushPreviewStroke,
|
|
4947
|
+
laserStrokes: this.interaction.laserStrokes,
|
|
4948
|
+
eraserTrail: this.interaction.eraserTrail,
|
|
4949
|
+
// Eraser cursor ring: a size-matched circle following the pointer while
|
|
4950
|
+
// the erase tool is active (not read-only). Sourced from `lastPointerWorld`
|
|
4951
|
+
// (updated on every hover AND drag move) — `hoverCursorWorld` is forced
|
|
4952
|
+
// null outside select mode. Radius is the panel's eraser width in SCREEN
|
|
4953
|
+
// px (matches the slider number).
|
|
4954
|
+
eraserCursor: this.mode === "erase" && !this._readOnly && this.lastPointerWorld !== null
|
|
4955
|
+
? { center: this.lastPointerWorld, radius: this._brushSettings.width }
|
|
4956
|
+
: null,
|
|
4957
|
+
peerCursors: this._peerCursors,
|
|
4958
|
+
peerSelections: this._peerSelections,
|
|
4959
|
+
debugHitZones: this.debugHitZones,
|
|
4960
|
+
readOnly: this._readOnly,
|
|
4961
|
+
groupMoveOrigin: this.groupMoveOrigin,
|
|
4962
|
+
aspectLocked: this.selectionIsAspectLocked(),
|
|
4963
|
+
combinedSelectionBounds: this.combinedSelectionBounds(),
|
|
4964
|
+
editingText: this.editingTextOverlay(),
|
|
4965
|
+
previewClickCreate: (fromElement, anchorName) => this.previewClickCreate(fromElement, anchorName),
|
|
4966
|
+
isPlaybackPaused: (id) => this.isPlaybackPaused(id),
|
|
4967
|
+
};
|
|
4968
|
+
}
|
|
4735
4969
|
render() {
|
|
4736
4970
|
this.rerouteElbows();
|
|
4737
|
-
//
|
|
4738
|
-
//
|
|
4739
|
-
|
|
4740
|
-
//
|
|
4741
|
-
|
|
4742
|
-
|
|
4971
|
+
// Age out expired laser-trail points before the snapshot so the fade
|
|
4972
|
+
// advances every frame and the tick self-terminates once all trails clear.
|
|
4973
|
+
this.pruneLaser();
|
|
4974
|
+
// The per-shape animation clock is threaded per-instance through the
|
|
4975
|
+
// RenderSnapshot / render context (see `buildRenderSnapshot`), not set on
|
|
4976
|
+
// the process-global module clock each frame — so concurrent editors keep
|
|
4977
|
+
// independent playback.
|
|
4978
|
+
const snapshot = this.buildRenderSnapshot();
|
|
4979
|
+
renderEditor(snapshot);
|
|
4980
|
+
// Bookkeeping the orchestrator used to do inline: record what we just
|
|
4981
|
+
// painted (for the next frame's dirty diff / isolation-transition check)
|
|
4982
|
+
// and, on the tile-cache path, clear the consumed dirty set.
|
|
4983
|
+
this.lastRenderedScene = this._scene;
|
|
4984
|
+
this.lastRenderedEnteredGroup = this._enteredGroup;
|
|
4985
|
+
this.lastRenderedEraseActive = snapshot.eraseActive;
|
|
4986
|
+
// The forced erase repaint (if any) has now happened — later idle frames
|
|
4987
|
+
// (cursor moving / trail fading) skip the full main pass until the next cut.
|
|
4988
|
+
this.eraseDirty = false;
|
|
4989
|
+
// Clear the accumulated tile-dirty set only when the tile path actually
|
|
4990
|
+
// composited this frame. Group isolation (dim) / per-element hide make the
|
|
4991
|
+
// orchestrator fall back to the full renderScene path (it can't reproduce
|
|
4992
|
+
// dim/hide on cached tiles), so on those frames the pending invalidations
|
|
4993
|
+
// must survive to be applied when the tile path resumes — mirror the same
|
|
4994
|
+
// condition here.
|
|
4995
|
+
const isolationActive = (snapshot.dimElements !== undefined && snapshot.dimElements.size > 0) ||
|
|
4996
|
+
(snapshot.hideElements !== undefined && snapshot.hideElements.size > 0);
|
|
4997
|
+
if (snapshot.tileComposeFn && snapshot.viewportWorld && !isolationActive) {
|
|
4998
|
+
this.tileDirtyElements = new Map();
|
|
4999
|
+
}
|
|
4743
5000
|
// Present AFTER the paint, on the same tick — deferred-submission
|
|
4744
5001
|
// surfaces (WebGL2 / OffscreenCanvas) would otherwise lag one frame.
|
|
4745
5002
|
this.onAfterRender?.();
|
|
4746
5003
|
}
|
|
4747
5004
|
}
|
|
4748
|
-
/**
|
|
4749
|
-
* Throwaway id for the transient click-create ghost preview element built by
|
|
4750
|
-
* `previewClickCreate`. Never enters the scene / history — it lives only for
|
|
4751
|
-
* the duration of one overlay paint, so any stable constant is fine.
|
|
4752
|
-
*/
|
|
4753
|
-
const PREVIEW_GHOST_ELEMENT_ID = "__ghost-preview__";
|
|
4754
|
-
/** Throwaway link id for the click-create ghost preview. See above. */
|
|
4755
|
-
const PREVIEW_GHOST_LINK_ID = "__ghost-preview-link__";
|
|
4756
|
-
const distanceTo = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
4757
|
-
// `coverageRatio` moved to `./editor/container-ops.ts`.
|
|
4758
|
-
// `hasWidthHeight` moved to `./editor/shape-traits.ts` for shared
|
|
4759
|
-
// use by container-ops and the future applies/resize module.
|
|
4760
5005
|
/**
|
|
4761
5006
|
* Type guard — `true` when the value already implements the
|
|
4762
5007
|
* `HistoryProvider` surface. Used to decide between "host supplied
|
|
@@ -4772,37 +5017,5 @@ const isHistoryProvider = (value) => {
|
|
|
4772
5017
|
typeof value.redo === "function" &&
|
|
4773
5018
|
typeof value.transaction === "function");
|
|
4774
5019
|
};
|
|
4775
|
-
// `describeNudge` moved to `./editor/public/selection-ops.ts`.
|
|
4776
5020
|
/** Distance from point `p` to the finite segment `a`–`b` (world space). */
|
|
4777
|
-
function distanceToSegmentPt(p, a, b) {
|
|
4778
|
-
const dx = b.x - a.x;
|
|
4779
|
-
const dy = b.y - a.y;
|
|
4780
|
-
const lenSq = dx * dx + dy * dy;
|
|
4781
|
-
if (lenSq === 0)
|
|
4782
|
-
return Math.hypot(p.x - a.x, p.y - a.y);
|
|
4783
|
-
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
|
|
4784
|
-
t = Math.max(0, Math.min(1, t));
|
|
4785
|
-
return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t));
|
|
4786
|
-
}
|
|
4787
|
-
/**
|
|
4788
|
-
* Convert a snap candidate into an `LinkEndpoint`. Anchor snap → named
|
|
4789
|
-
* anchor ref; outline snap → outline ref with the sampled ratio. Falls
|
|
4790
|
-
* back to a free point if the metadata isn't recognised.
|
|
4791
|
-
*/
|
|
4792
|
-
const endpointFromSnap = (elementId, candidate, shape) => {
|
|
4793
|
-
if (candidate.kind === "anchor") {
|
|
4794
|
-
const ref = candidate.metadata?.ref;
|
|
4795
|
-
if (ref)
|
|
4796
|
-
return { kind: "anchor", elementId, anchor: ref };
|
|
4797
|
-
}
|
|
4798
|
-
if (candidate.kind === "outline" && typeof candidate.metadata?.ratio === "number") {
|
|
4799
|
-
return { kind: "outline", elementId, ratio: candidate.metadata.ratio };
|
|
4800
|
-
}
|
|
4801
|
-
// Defensive fallback — should not happen with built-in contributors.
|
|
4802
|
-
void shape;
|
|
4803
|
-
return { kind: "point", position: candidate.snapped };
|
|
4804
|
-
};
|
|
4805
|
-
// `resizeFromHandle`, `applyResizeConstraints`, the four handle-
|
|
4806
|
-
// quadrant predicates moved to `./editor/resize-helpers.ts` so
|
|
4807
|
-
// they're shared between applies/resize and the container clamp.
|
|
4808
5021
|
//# sourceMappingURL=editor.js.map
|