@oh-just-another/state 0.59.0 → 0.61.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 +189 -0
- package/README.md +6 -4
- package/dist/.tsbuildinfo +1 -1
- package/dist/actions/actionArrange.js +4 -4
- package/dist/actions/actionArrange.js.map +1 -1
- package/dist/actions/actionClipboard.d.ts.map +1 -1
- package/dist/actions/actionClipboard.js +5 -0
- package/dist/actions/actionClipboard.js.map +1 -1
- package/dist/actions/actionKeyboard.d.ts.map +1 -1
- package/dist/actions/actionKeyboard.js +79 -11
- package/dist/actions/actionKeyboard.js.map +1 -1
- package/dist/actions/actionMode.d.ts +2 -0
- package/dist/actions/actionMode.d.ts.map +1 -1
- package/dist/actions/actionMode.js +81 -27
- 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/actionView.d.ts +6 -0
- package/dist/actions/actionView.d.ts.map +1 -1
- package/dist/actions/actionView.js +19 -1
- 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 +3 -3
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +3 -3
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/registry.d.ts.map +1 -1
- package/dist/actions/registry.js +13 -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/built-in-handlers.d.ts +8 -0
- package/dist/built-in-handlers.d.ts.map +1 -1
- package/dist/built-in-handlers.js +31 -16
- 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 +253 -18
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +255 -2
- package/dist/constants.js.map +1 -1
- package/dist/dom-events.d.ts +1 -2
- package/dist/dom-events.d.ts.map +1 -1
- package/dist/dom-events.js.map +1 -1
- package/dist/editor/animation-scene.d.ts +15 -0
- package/dist/editor/animation-scene.d.ts.map +1 -1
- package/dist/editor/animation-scene.js +159 -0
- package/dist/editor/animation-scene.js.map +1 -1
- 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/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/event-fanout.d.ts +6 -6
- package/dist/editor/event-fanout.d.ts.map +1 -1
- package/dist/editor/event-fanout.js +8 -8
- package/dist/editor/event-fanout.js.map +1 -1
- package/dist/editor/hit-test.d.ts +8 -0
- package/dist/editor/hit-test.d.ts.map +1 -1
- package/dist/editor/hit-test.js +31 -16
- 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 +172 -0
- package/dist/editor/link-handle-drag.d.ts.map +1 -0
- package/dist/editor/link-handle-drag.js +369 -0
- package/dist/editor/link-handle-drag.js.map +1 -0
- 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 +1247 -909
- package/dist/editor/pointer-binding.js.map +1 -1
- package/dist/editor/public/brush.d.ts +93 -13
- package/dist/editor/public/brush.d.ts.map +1 -1
- package/dist/editor/public/brush.js +240 -22
- package/dist/editor/public/brush.js.map +1 -1
- package/dist/editor/public/cursor.d.ts +1 -15
- package/dist/editor/public/cursor.d.ts.map +1 -1
- package/dist/editor/public/cursor.js +17 -7
- package/dist/editor/public/cursor.js.map +1 -1
- 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/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/placement.d.ts.map +1 -1
- package/dist/editor/public/placement.js +9 -1
- package/dist/editor/public/placement.js.map +1 -1
- package/dist/editor/public/selection-ops.d.ts +12 -0
- package/dist/editor/public/selection-ops.d.ts.map +1 -1
- package/dist/editor/public/selection-ops.js +19 -1
- 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/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 +1 -1
- package/dist/editor/public/z-order.js.map +1 -1
- package/dist/editor/public/zoom-pan.d.ts +9 -0
- package/dist/editor/public/zoom-pan.d.ts.map +1 -1
- package/dist/editor/public/zoom-pan.js +31 -0
- package/dist/editor/public/zoom-pan.js.map +1 -1
- package/dist/editor/render-orchestrator.d.ts +180 -5
- package/dist/editor/render-orchestrator.d.ts.map +1 -1
- package/dist/editor/render-orchestrator.js +516 -353
- package/dist/editor/render-orchestrator.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-events.d.ts +5 -5
- package/dist/editor-events.d.ts.map +1 -1
- package/dist/editor.d.ts +560 -250
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +1650 -743
- package/dist/editor.js.map +1 -1
- package/dist/handle.d.ts +0 -2
- package/dist/handle.d.ts.map +1 -1
- package/dist/handle.js +0 -6
- package/dist/handle.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/modes.d.ts +34 -1
- package/dist/modes.d.ts.map +1 -1
- package/dist/modes.js.map +1 -1
- package/dist/overlay.d.ts +98 -21
- package/dist/overlay.d.ts.map +1 -1
- package/dist/overlay.js +442 -116
- 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/package.json +5 -4
package/dist/editor.js
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
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, isElementHidden, isElementLocked, runAutoLayout, DEFAULT_LAYER_ID, routeElbowLink, routeElbowPreview, getLink, getLinkPath, getElement, getElementAt, getElementAtIndexed, isFrame, isGroup, isText, isImage, getElementWorldBounds, setTextMeasurer, getScreenToWorld, gridSnapper, outlineSnapper,
|
|
4
|
+
import { addElement, getLinkCurvePoints, linkLabelAnchor, 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, vec2
|
|
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 { DEFAULT_LINK_ROUTING
|
|
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
12
|
import { computeDimElements as computeDimElementsHelper, isDescendantOfGroup as isDescendantOfGroupHelper, pickDrillTarget as pickDrillTargetHelper, promoteToGroupRoot as promoteToGroupRootHelper, topGroupAncestor as topGroupAncestorHelper, } from "./group-helpers.js";
|
|
13
13
|
import { assignFrameMembers as assignFrameMembersHelper, nextFrameName as nextFrameNameHelper, reconcileFrameMembership as reconcileFrameMembershipHelper, } from "./frame-helpers.js";
|
|
14
14
|
import { AutoCompactScheduler } from "./auto-compact.js";
|
|
15
15
|
import { AutoLayoutScheduler } from "./auto-layout-scheduler.js";
|
|
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, } from "./constants.js";
|
|
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
17
|
import { HANDLE_HIT_SLOP } from "./handle.js";
|
|
18
18
|
import { req } from "./util.js";
|
|
19
19
|
import { interactionMachine, } from "./machine.js";
|
|
20
|
+
import { DEFAULT_MODE } from "./modes.js";
|
|
20
21
|
import { createEventCache, fanOutEvents, primeEventCache, } from "./editor/event-fanout.js";
|
|
21
22
|
import { AnimationController } from "./editor/animation.js";
|
|
22
|
-
import { CaretBlinkController } from "./editor/caret-blink.js";
|
|
23
23
|
import { GestureController } from "./editor/gesture-tx.js";
|
|
24
24
|
import { GifPlaybackController } from "./editor/gif-playback.js";
|
|
25
25
|
import * as animScene from "./editor/animation-scene.js";
|
|
@@ -30,25 +30,32 @@ import { PinchController } from "./editor/pinch.js";
|
|
|
30
30
|
import { applyContainerDrop as applyContainerDropPure, clampContainerToChildren as clampContainerToChildrenPure, maybeGrowContainer as maybeGrowContainerPure, } from "./editor/container-ops.js";
|
|
31
31
|
import { computeGroupResizePatches, computeElementResize, computeRotatedElementResize, computeTextResize, } from "./editor/applies/resize.js";
|
|
32
32
|
import { bindPointerEvents as bindPointerEventsExternal } from "./editor/pointer-binding.js";
|
|
33
|
-
import {
|
|
33
|
+
import { InteractionState, } from "./editor/interaction-state.js";
|
|
34
|
+
import { beginBrushStroke as beginBrushStrokePure, commitBrushStroke as commitBrushStrokePure, extendBrushStroke as extendBrushStrokePure, brushCommitPoints, brushStyleFromSettings, computeSetBrushWidth, DEFAULT_BRUSH_SETTINGS, newBrushId, } from "./editor/public/brush.js";
|
|
35
|
+
import { beginEraseStroke as beginEraseStrokePure, sampleErase as sampleErasePure, computeEraseCommit, } from "./editor/public/eraser.js";
|
|
36
|
+
import { computeEraseFromMasks, computeStrokeErasePreviewFromMasks, markErasedIntervals, } from "./editor/public/stroke-eraser.js";
|
|
37
|
+
import { coveredLength } from "./editor/public/stroke-eraser-coverage.js";
|
|
38
|
+
import { beginLaserStroke as beginLaserStrokePure, extendLaserStroke as extendLaserStrokePure, pruneLaserStrokes, } from "./editor/public/laser.js";
|
|
34
39
|
import { copySelected as copySelectedPure, pasteFromClipboard, selectionFromPasted, } from "./editor/public/clipboard.js";
|
|
35
40
|
import { computeCreateLayer, computeMoveSelectionToLayer, computeRemoveLayer, computeRenameLayer, computeToggleLayerLock, computeToggleLayerVisibility, newLayerId, } from "./editor/public/layers.js";
|
|
36
|
-
import { computePan, computeResetZoom, computeSetGrid, computeViewportResize, computeZoomAt, computeZoomToFit, computeZoomToBounds, } from "./editor/public/zoom-pan.js";
|
|
41
|
+
import { computePan, computeResetZoom, computeSetGrid, computeViewportResize, computeZoomAt, computeZoomToFit, computeZoomToBounds, computeRevealBounds, } from "./editor/public/zoom-pan.js";
|
|
37
42
|
import { computeAddAnnotation, computeAddComment, computeRemoveAnnotation, computeRemoveComment, computeToggleAnnotationResolved, hitAnnotation as hitAnnotationPure, } from "./editor/public/annotations.js";
|
|
38
|
-
import { canBeginTextEdit } from "./editor/public/text-edit.js";
|
|
39
43
|
import { frameHeaderAt as computeFrameHeaderAt, computeFrameNameCommit, } from "./editor/public/frame-name.js";
|
|
40
44
|
import { computeCursor } from "./editor/public/cursor.js";
|
|
41
45
|
import { compactLayerZOrderPatches, computeBringForward, computeBringToFront, computeSendBackward, computeSendToBack, } from "./editor/public/z-order.js";
|
|
42
46
|
import { computeArrangeAsGrid, computeArrangeAsStack, computeGroupSelected, computeUngroup, expandSelectionWithDescendants, newGroupElementId, pickFocusCycle, selectionRoots, } from "./editor/public/arrange-group.js";
|
|
43
47
|
import { buildImageElement, computeAddBinaryFile, hasAnimatedElement, } from "./editor/public/image-insert.js";
|
|
44
|
-
import { computeDeleteSelection, computeDuplicateSelection, computeMoveSelectionBy, computeSelectAll, computeSelectAllLinks, computeAdjustFontSize, computeSetSelection, computeUpdateStyle, computeUpdateTextProps, describeNudge as describeNudgePure, findClosestInDirection, selectionFromNewIds, } from "./editor/public/selection-ops.js";
|
|
48
|
+
import { computeDeleteSelection, computeDuplicateSelection, computeMoveSelectionBy, computeSelectAll, computeSelectAllLinks, computeAdjustFontSize, computeApplyTextRunStyle, computeSetSelection, computeUpdateStyle, computeUpdateTextProps, describeNudge as describeNudgePure, findClosestInDirection, selectionFromNewIds, } from "./editor/public/selection-ops.js";
|
|
45
49
|
import { computeSetLink, normalizeHref, safeHref, snapLinkEndpoint as snapLinkEndpointPure, } from "./editor/public/link.js";
|
|
46
50
|
import { beginPlacementState, buildElementAtCursor, buildTextElementAt, computePlacementCancel, computePlacementContainerDrop, computePlacementUpdate, computeLinkedElementFromAnchor, computeDuplicateInPlace, computeShapeAtLinkDrop, newElementIdAtCursor, previewClickCreate as previewClickCreatePure, } from "./editor/public/placement.js";
|
|
47
|
-
import {
|
|
51
|
+
import { computeConvertType, computeCommitImageCrop, computeCropBodyPan, computeCropHandleDrag, computeSpawnConnectedNode, computeSpawnConnectedNodes, cropFullImageLocalRect, cropHandleWorldPoints, CROP_HANDLES, FULL_CROP, pickColorAt, } from "./editor/public/tool-ops.js";
|
|
52
|
+
import { renderEditor, } from "./editor/render-orchestrator.js";
|
|
53
|
+
import { TextEditController } from "./editor/text-edit.js";
|
|
54
|
+
import { LinkHandleDragController } from "./editor/link-handle-drag.js";
|
|
48
55
|
import { combinedSelectionBounds as combinedSelectionBoundsPure, computeViewportWorld as computeViewportWorldPure, groupChildrenUnion as groupChildrenUnionPure, } from "./editor/viewport-helpers.js";
|
|
49
56
|
import { computeHiddenElements as computeHiddenElementsPure } from "./editor/shape-filters.js";
|
|
50
57
|
import { selectByBounds as selectByBoundsPure, selectByBoundsLive as selectByBoundsLivePure, selectLinksByBoundsLive as selectLinksByBoundsLivePure, } from "./editor/applies/selection.js";
|
|
51
|
-
import {
|
|
58
|
+
import { computeLinkPreviewEndpoints, elbowSignature } from "./editor/applies/edge.js";
|
|
52
59
|
import { computeAnnotationMovePatch, computeGroupMovePatches, computeElementMovePatch, constrainDeltaToAxis, } from "./editor/applies/move.js";
|
|
53
60
|
import { computeAlignPatches, computeDistributePatches, computeFlipPatches, computeRotatePatches, selectionCenter, } from "./editor/applies/arrange.js";
|
|
54
61
|
import { computeMovingLinkPatches, computeMovingLinkForNudge } from "./editor/applies/link-move.js";
|
|
@@ -57,6 +64,28 @@ import { snapCreateBounds, snapGroupDelta, snapMoveDelta, snapResizeDelta, } fro
|
|
|
57
64
|
import {} from "./overlay.js";
|
|
58
65
|
import * as Selection from "./selection.js";
|
|
59
66
|
import * as LinkSelection from "./link-selection.js";
|
|
67
|
+
/**
|
|
68
|
+
* Machine-emit types dropped while the editor is in read-only mode — every
|
|
69
|
+
* scene mutation the interaction machine can produce. Selection / lasso /
|
|
70
|
+
* preview-clear emits are absent so a viewer keeps click + marquee select.
|
|
71
|
+
*/
|
|
72
|
+
const READ_ONLY_BLOCKED_EMITS = new Set([
|
|
73
|
+
"MOVE_SHAPE",
|
|
74
|
+
"RESIZE_GROUP",
|
|
75
|
+
"RESIZE_SHAPE",
|
|
76
|
+
"ROTATE",
|
|
77
|
+
"CREATE_SHAPE",
|
|
78
|
+
"CREATE_EDGE",
|
|
79
|
+
"MOVE_ANNOTATION",
|
|
80
|
+
"COMMIT_ANNOTATION_DRAG",
|
|
81
|
+
"UPDATE_EDGE_ENDPOINT",
|
|
82
|
+
"UPDATE_EDGE_ENDPOINT_PREVIEW",
|
|
83
|
+
"DRAW_EDGE_PREVIEW",
|
|
84
|
+
]);
|
|
85
|
+
/** Shared empty id set — returned by `pendingErase` when no eraser stroke runs. */
|
|
86
|
+
const EMPTY_ELEMENT_SET = Object.freeze(new Set());
|
|
87
|
+
/** Monotonic wall-clock in ms, matching the domain used by the overlay fade. */
|
|
88
|
+
const nowMs = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
60
89
|
export class Editor {
|
|
61
90
|
host;
|
|
62
91
|
mainTarget;
|
|
@@ -70,6 +99,14 @@ export class Editor {
|
|
|
70
99
|
* View-only — never persisted or recorded in history.
|
|
71
100
|
*/
|
|
72
101
|
debugHitZones = false;
|
|
102
|
+
/**
|
|
103
|
+
* Read-only / view mode. When true the pointer paths that create,
|
|
104
|
+
* move, resize, rotate or delete are gated (pan / zoom / select stay
|
|
105
|
+
* live) and the action registry only runs actions flagged
|
|
106
|
+
* `viewMode`. View-only — never persisted or recorded in history.
|
|
107
|
+
* Read via {@link readOnly}; flip via {@link setReadOnly}.
|
|
108
|
+
*/
|
|
109
|
+
_readOnly = false;
|
|
73
110
|
actor;
|
|
74
111
|
listeners = new Set();
|
|
75
112
|
/**
|
|
@@ -93,45 +130,55 @@ export class Editor {
|
|
|
93
130
|
_scene;
|
|
94
131
|
_selection = Selection.EMPTY;
|
|
95
132
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
133
|
+
* Ephemeral interaction / gesture state (previews, gesture origins,
|
|
134
|
+
* transient modifiers). Single source of truth for the short-lived fields
|
|
135
|
+
* the pointer handlers, render orchestrator and container-ops read/write
|
|
136
|
+
* while a gesture is in flight. The public fields below delegate to it so
|
|
137
|
+
* external writers keep referencing `editor.<field>` unchanged.
|
|
100
138
|
*/
|
|
101
|
-
|
|
139
|
+
interaction = new InteractionState();
|
|
140
|
+
/** Snapshot of an in-progress annotation-pin drag. */
|
|
141
|
+
get annotationDrag() {
|
|
142
|
+
return this.interaction.annotationDrag;
|
|
143
|
+
}
|
|
144
|
+
set annotationDrag(v) {
|
|
145
|
+
this.interaction.annotationDrag = v;
|
|
146
|
+
}
|
|
102
147
|
/** Live preview while drawing a new shape; null when not drawing. */
|
|
103
|
-
drawingPreview
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
get drawingPreview() {
|
|
149
|
+
return this.interaction.drawingPreview;
|
|
150
|
+
}
|
|
151
|
+
set drawingPreview(v) {
|
|
152
|
+
this.interaction.drawingPreview = v;
|
|
153
|
+
}
|
|
154
|
+
/** Live preview of an edge being drawn. */
|
|
155
|
+
get edgePreview() {
|
|
156
|
+
return this.interaction.edgePreview;
|
|
157
|
+
}
|
|
158
|
+
set edgePreview(v) {
|
|
159
|
+
this.interaction.edgePreview = v;
|
|
160
|
+
}
|
|
161
|
+
/** Active "drag a link from a start-anchor" gesture. */
|
|
162
|
+
get linkDragFromAnchor() {
|
|
163
|
+
return this.interaction.linkDragFromAnchor;
|
|
164
|
+
}
|
|
165
|
+
set linkDragFromAnchor(v) {
|
|
166
|
+
this.interaction.linkDragFromAnchor = v;
|
|
167
|
+
}
|
|
168
|
+
/** Element hovered while draw-edge mode is active (drives the port overlay). */
|
|
169
|
+
get hoveredLinkTarget() {
|
|
170
|
+
return this.interaction.hoveredLinkTarget;
|
|
171
|
+
}
|
|
172
|
+
set hoveredLinkTarget(v) {
|
|
173
|
+
this.interaction.hoveredLinkTarget = v;
|
|
174
|
+
}
|
|
175
|
+
/** Last idle cursor position (world) in select mode — grows the nearest dot. */
|
|
176
|
+
get hoverCursorWorld() {
|
|
177
|
+
return this.interaction.hoverCursorWorld;
|
|
178
|
+
}
|
|
179
|
+
set hoverCursorWorld(v) {
|
|
180
|
+
this.interaction.hoverCursorWorld = v;
|
|
181
|
+
}
|
|
135
182
|
/**
|
|
136
183
|
* Currently selected links (connectors). Links are first-class members
|
|
137
184
|
* of the selection: they coexist with selected elements, join Cmd+A and
|
|
@@ -157,69 +204,67 @@ export class Editor {
|
|
|
157
204
|
/**
|
|
158
205
|
* Mid-drag preview state when the user is dragging an edge endpoint.
|
|
159
206
|
* Drawn as an overlay line + handle dot so the user sees the target.
|
|
207
|
+
* State lives in `LinkHandleDragController`; this is a delegate.
|
|
160
208
|
*/
|
|
161
|
-
linkEndpointDrag
|
|
209
|
+
get linkEndpointDrag() {
|
|
210
|
+
return this.linkHandles.endpointDrag;
|
|
211
|
+
}
|
|
162
212
|
/**
|
|
163
213
|
* Host-managed waypoint (bend-point) drag of the selected link. `index`
|
|
164
214
|
* is the position in `edge.waypoints`. `pendingInsert` means the gesture
|
|
165
215
|
* began on a segment midpoint and will splice a new waypoint on the
|
|
166
216
|
* first move (so a no-move click adds nothing). Live-mutated through the
|
|
167
217
|
* gesture transaction → one undo step per drag.
|
|
218
|
+
* State lives in `LinkHandleDragController`; this is a delegate.
|
|
168
219
|
*/
|
|
169
|
-
linkWaypointDrag
|
|
220
|
+
get linkWaypointDrag() {
|
|
221
|
+
return this.linkHandles.waypointDrag;
|
|
222
|
+
}
|
|
170
223
|
/**
|
|
171
|
-
* Host-managed elbow segment drag. `
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
224
|
+
* Host-managed elbow segment drag. `axis` is the segment's orientation.
|
|
225
|
+
* Dragging pins the segment's perpendicular coordinate into
|
|
226
|
+
* `Link.fixedSegments`; the reroute pass re-flows the rest. One undo
|
|
227
|
+
* step via the gesture tx.
|
|
228
|
+
* State lives in `LinkHandleDragController`; this is a delegate.
|
|
175
229
|
*/
|
|
176
|
-
linkSegmentDrag
|
|
230
|
+
get linkSegmentDrag() {
|
|
231
|
+
return this.linkHandles.segmentDrag;
|
|
232
|
+
}
|
|
177
233
|
/** Live lasso bounds during a rubber-band select gesture. */
|
|
178
|
-
lassoPreview
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
* from this baseline so the cumulative angle never drifts. Cleared on gesture
|
|
213
|
-
* end (commit / cancel).
|
|
214
|
-
*/
|
|
215
|
-
rotateGestureOrigin = null;
|
|
216
|
-
/**
|
|
217
|
-
* Pristine shape snapshot for a single-shape text resize, captured on
|
|
218
|
-
* the gesture's first tick. Font scaling is computed against this base
|
|
219
|
-
* so it never compounds across pointermove ticks. Cleared on gesture
|
|
220
|
-
* end (commit / cancel).
|
|
221
|
-
*/
|
|
222
|
-
_resizeOriginElement = null;
|
|
234
|
+
get lassoPreview() {
|
|
235
|
+
return this.interaction.lassoPreview;
|
|
236
|
+
}
|
|
237
|
+
set lassoPreview(v) {
|
|
238
|
+
this.interaction.lassoPreview = v;
|
|
239
|
+
}
|
|
240
|
+
/** Snapshot of every selected shape's `position` at press-down (multi-drag). */
|
|
241
|
+
get groupMoveOrigin() {
|
|
242
|
+
return this.interaction.groupMoveOrigin;
|
|
243
|
+
}
|
|
244
|
+
set groupMoveOrigin(v) {
|
|
245
|
+
this.interaction.groupMoveOrigin = v;
|
|
246
|
+
}
|
|
247
|
+
/** Press-time snapshot of connectors that follow a multi-element drag rigidly. */
|
|
248
|
+
get groupLinkMoveOrigin() {
|
|
249
|
+
return this.interaction.groupLinkMoveOrigin;
|
|
250
|
+
}
|
|
251
|
+
set groupLinkMoveOrigin(v) {
|
|
252
|
+
this.interaction.groupLinkMoveOrigin = v;
|
|
253
|
+
}
|
|
254
|
+
/** Per-shape snapshot for a group-resize gesture. */
|
|
255
|
+
get groupResizeOrigin() {
|
|
256
|
+
return this.interaction.groupResizeOrigin;
|
|
257
|
+
}
|
|
258
|
+
set groupResizeOrigin(v) {
|
|
259
|
+
this.interaction.groupResizeOrigin = v;
|
|
260
|
+
}
|
|
261
|
+
/** Press-time snapshot for a rotate gesture. */
|
|
262
|
+
get rotateGestureOrigin() {
|
|
263
|
+
return this.interaction.rotateGestureOrigin;
|
|
264
|
+
}
|
|
265
|
+
set rotateGestureOrigin(v) {
|
|
266
|
+
this.interaction.rotateGestureOrigin = v;
|
|
267
|
+
}
|
|
223
268
|
/**
|
|
224
269
|
* Active layer — new shapes created via `addElement` / `applyCreate` land
|
|
225
270
|
* here when their input doesn't specify a `layerId`. Defaults to the
|
|
@@ -241,21 +286,6 @@ export class Editor {
|
|
|
241
286
|
]);
|
|
242
287
|
/** Snap threshold in world units. */
|
|
243
288
|
snapThreshold = DEFAULT_SNAP_THRESHOLD;
|
|
244
|
-
/**
|
|
245
|
-
* Transient flag set by the host while a snap-suppress modifier
|
|
246
|
-
* (Cmd / Ctrl) is held during a drag — lets the user pull a shape off
|
|
247
|
-
* the grid for one gesture without toggling snap off. Read by the
|
|
248
|
-
* move / resize / create wrappers; never persisted.
|
|
249
|
-
*/
|
|
250
|
-
snapSuppressed = false;
|
|
251
|
-
/**
|
|
252
|
-
* Transient transform-modifier state mirrored from the host while a drag is
|
|
253
|
-
* in flight. `alt` resizes symmetrically about the centre; `shift` locks the
|
|
254
|
-
* resize aspect ratio or constrains a move to a single axis. Read by the
|
|
255
|
-
* move / resize wrappers; never persisted.
|
|
256
|
-
*/
|
|
257
|
-
transformAltKey = false;
|
|
258
|
-
transformShiftKey = false;
|
|
259
289
|
/**
|
|
260
290
|
* In-editor style memory for copy-style / paste-style. Holds the visual
|
|
261
291
|
* `style` (fill / stroke / dash / …) captured from a shape; `null` until a
|
|
@@ -283,36 +313,64 @@ export class Editor {
|
|
|
283
313
|
* escape, click outside the group's descendants, or `cancelInteraction`.
|
|
284
314
|
*/
|
|
285
315
|
_enteredGroup = null;
|
|
286
|
-
/**
|
|
287
|
-
* Double-click detection state. Updated on every non-drag pointer
|
|
288
|
-
* up; the next pointer-up within `DOUBLE_CLICK_MS` and within
|
|
289
|
-
* `DOUBLE_CLICK_TOLERANCE_PX` of `lastClickWorldPoint` counts as a
|
|
290
|
-
* double-click. Used to trigger group drill-down (enter isolation).
|
|
291
|
-
*/
|
|
292
|
-
lastClickAt = 0;
|
|
293
|
-
lastClickWorldPoint = null;
|
|
294
|
-
/**
|
|
295
|
-
* Separate double-click tracker for link edit handles (waypoint /
|
|
296
|
-
* segment). Kept apart from `lastClickAt` because a handle press
|
|
297
|
-
* returns early in `onDown` (begin-drag) and never reaches the up-side
|
|
298
|
-
* double-click path that updates `lastClickAt`. Updated by
|
|
299
|
-
* `isHandleDoubleClick` on each handle press.
|
|
300
|
-
*/
|
|
301
|
-
lastHandleClickAt = 0;
|
|
302
|
-
lastHandleClickWorld = null;
|
|
303
316
|
/**
|
|
304
317
|
* In-progress brush stroke. Hosts push points via
|
|
305
318
|
* `extendBrushStroke`; the overlay reads it through
|
|
306
319
|
* `pendingBrushStroke` to draw a live preview.
|
|
307
320
|
*/
|
|
308
|
-
brushStroke
|
|
321
|
+
get brushStroke() {
|
|
322
|
+
return this.interaction.brushStroke;
|
|
323
|
+
}
|
|
324
|
+
set brushStroke(v) {
|
|
325
|
+
this.interaction.brushStroke = v;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The in-progress brush stroke run through the SAME commit pipeline
|
|
329
|
+
* `commitBrushStroke` applies on release (see {@link brushCommitPoints}:
|
|
330
|
+
* raw catch-up point + Catmull-Rom resample), so the stroke reads exactly
|
|
331
|
+
* as it will land in the scene instead of snapping on release. A fresh
|
|
332
|
+
* object each call (points diverge from `brushStroke.points`), so the
|
|
333
|
+
* overlay memo repaints every move that grows the stroke.
|
|
334
|
+
*/
|
|
335
|
+
get brushPreviewStroke() {
|
|
336
|
+
const s = this.interaction.brushStroke;
|
|
337
|
+
if (!s)
|
|
338
|
+
return null;
|
|
339
|
+
const style = brushStyleFromSettings(this._brushSettings);
|
|
340
|
+
return {
|
|
341
|
+
origin: s.origin,
|
|
342
|
+
points: brushCommitPoints(s, style).points,
|
|
343
|
+
fill: brushBodyColor(style),
|
|
344
|
+
opacity: style.opacity ?? 1,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/** In-progress eraser stroke (pending-delete set), or null between strokes. */
|
|
348
|
+
get eraseStroke() {
|
|
349
|
+
return this.interaction.eraseStroke;
|
|
350
|
+
}
|
|
351
|
+
set eraseStroke(v) {
|
|
352
|
+
this.interaction.eraseStroke = v;
|
|
353
|
+
}
|
|
354
|
+
/** Ids swept by the current eraser stroke — previewed dimmed, deleted on release. */
|
|
355
|
+
get pendingErase() {
|
|
356
|
+
return this.interaction.eraseStroke?.pending ?? EMPTY_ELEMENT_SET;
|
|
357
|
+
}
|
|
358
|
+
/** Live laser-pointer trails (ephemeral, fading). Empty when none active. */
|
|
359
|
+
get laserStrokes() {
|
|
360
|
+
return this.interaction.laserStrokes;
|
|
361
|
+
}
|
|
309
362
|
/**
|
|
310
363
|
* Last world-space pointer position observed by the host's onMove
|
|
311
364
|
* handler. `paste()` uses it as the default drop target so a fresh
|
|
312
365
|
* paste lands under the cursor instead of overlapping the originals.
|
|
313
366
|
* `null` until the pointer first enters the host.
|
|
314
367
|
*/
|
|
315
|
-
lastPointerWorld
|
|
368
|
+
get lastPointerWorld() {
|
|
369
|
+
return this.interaction.lastPointerWorld;
|
|
370
|
+
}
|
|
371
|
+
set lastPointerWorld(v) {
|
|
372
|
+
this.interaction.lastPointerWorld = v;
|
|
373
|
+
}
|
|
316
374
|
/** Host-registered custom cursor images per role (see `setCursorOverride`). */
|
|
317
375
|
cursorOverrides = new Map();
|
|
318
376
|
/**
|
|
@@ -331,6 +389,23 @@ export class Editor {
|
|
|
331
389
|
* pass would never visibly apply.
|
|
332
390
|
*/
|
|
333
391
|
lastRenderedEnteredGroup = null;
|
|
392
|
+
/**
|
|
393
|
+
* Whether the last paint had eraser-dim active — paired with
|
|
394
|
+
* `lastRenderedScene` like {@link lastRenderedEnteredGroup}. When an eraser
|
|
395
|
+
* stroke ENDS by cancel (Esc), the marked shapes un-dim without a scene
|
|
396
|
+
* change, so the dirty-rect diff is empty and the dim would linger on screen;
|
|
397
|
+
* this lets that active→inactive transition force one full repaint.
|
|
398
|
+
*/
|
|
399
|
+
lastRenderedEraseActive = false;
|
|
400
|
+
/**
|
|
401
|
+
* Set whenever an eraser move actually CHANGES the marked / cut set (a new
|
|
402
|
+
* shape marked, un-marked, or a brush point cut). Gates the forced full
|
|
403
|
+
* repaint during erasing: only the frames that change the preview repaint the
|
|
404
|
+
* whole scene; a slowly-moving or stopped cursor over already-covered area
|
|
405
|
+
* skips the expensive main pass (only the overlay cursor / trail refresh).
|
|
406
|
+
* Cleared after each paint.
|
|
407
|
+
*/
|
|
408
|
+
eraseDirty = false;
|
|
334
409
|
/**
|
|
335
410
|
* Fractional-order compaction scheduler (microtask-coalesced).
|
|
336
411
|
* Triggered from every `notify()`; only does real work when at
|
|
@@ -375,7 +450,12 @@ export class Editor {
|
|
|
375
450
|
* gestures, set in onDown when press lands on a shape and cleared
|
|
376
451
|
* in onUp / cancel.
|
|
377
452
|
*/
|
|
378
|
-
dragElementId
|
|
453
|
+
get dragElementId() {
|
|
454
|
+
return this.interaction.dragElementId;
|
|
455
|
+
}
|
|
456
|
+
set dragElementId(v) {
|
|
457
|
+
this.interaction.dragElementId = v;
|
|
458
|
+
}
|
|
379
459
|
/**
|
|
380
460
|
* Element that the current press added to the selection additively
|
|
381
461
|
* (shift / meta click on an unselected shape). The press promotes it
|
|
@@ -383,14 +463,24 @@ export class Editor {
|
|
|
383
463
|
* otherwise `SELECT_TOGGLE` it straight back off, so it consults this
|
|
384
464
|
* to skip that redundant toggle. Reset at every press-down.
|
|
385
465
|
*/
|
|
386
|
-
additivePressAdded
|
|
466
|
+
get additivePressAdded() {
|
|
467
|
+
return this.interaction.additivePressAdded;
|
|
468
|
+
}
|
|
469
|
+
set additivePressAdded(v) {
|
|
470
|
+
this.interaction.additivePressAdded = v;
|
|
471
|
+
}
|
|
387
472
|
/**
|
|
388
473
|
* Live container highlight: the container shape the dragged item is
|
|
389
474
|
* currently hovering over. Drawn by the overlay as a dashed
|
|
390
475
|
* accent rect on the container's drop-zone so the user sees where the
|
|
391
476
|
* shape will land after release.
|
|
392
477
|
*/
|
|
393
|
-
containerHover
|
|
478
|
+
get containerHover() {
|
|
479
|
+
return this.interaction.containerHover;
|
|
480
|
+
}
|
|
481
|
+
set containerHover(v) {
|
|
482
|
+
this.interaction.containerHover = v;
|
|
483
|
+
}
|
|
394
484
|
/**
|
|
395
485
|
* Remote peer cursors / selections, pushed in by the host (typically
|
|
396
486
|
* a `bindAwareness(editor, awareness)` helper in `@collab`). The
|
|
@@ -410,7 +500,9 @@ export class Editor {
|
|
|
410
500
|
* two or more entries we enter a pinch / pan gesture and bypass the
|
|
411
501
|
* interaction machine — `pinchOrigin` holds the baseline.
|
|
412
502
|
*/
|
|
413
|
-
activePointers
|
|
503
|
+
get activePointers() {
|
|
504
|
+
return this.interaction.activePointers;
|
|
505
|
+
}
|
|
414
506
|
/**
|
|
415
507
|
* One-finger-pan candidate: set at pointer-down when a TOUCH press lands
|
|
416
508
|
* on empty canvas in select mode. A tap (no movement) still falls through
|
|
@@ -418,7 +510,12 @@ export class Editor {
|
|
|
418
510
|
* this to a real pan instead of a marquee lasso (mobile convention).
|
|
419
511
|
* Screen-space origin point.
|
|
420
512
|
*/
|
|
421
|
-
touchPanCandidate
|
|
513
|
+
get touchPanCandidate() {
|
|
514
|
+
return this.interaction.touchPanCandidate;
|
|
515
|
+
}
|
|
516
|
+
set touchPanCandidate(v) {
|
|
517
|
+
this.interaction.touchPanCandidate = v;
|
|
518
|
+
}
|
|
422
519
|
// Pinch gesture state lives in PinchController; `pinch.isActive()`
|
|
423
520
|
// reports whether a two-finger gesture is in flight.
|
|
424
521
|
pinch;
|
|
@@ -430,7 +527,12 @@ export class Editor {
|
|
|
430
527
|
* "grab" / "grabbing". Wires a window-level keydown/keyup listener
|
|
431
528
|
* in `bindPointerEvents`.
|
|
432
529
|
*/
|
|
433
|
-
spaceHeld
|
|
530
|
+
get spaceHeld() {
|
|
531
|
+
return this.interaction.spaceHeld;
|
|
532
|
+
}
|
|
533
|
+
set spaceHeld(v) {
|
|
534
|
+
this.interaction.spaceHeld = v;
|
|
535
|
+
}
|
|
434
536
|
/**
|
|
435
537
|
* Host-supplied tile compositor — when set (via
|
|
436
538
|
* `EditorOptions.useTileCache` + `tileCompose`), the per-frame
|
|
@@ -458,6 +560,14 @@ export class Editor {
|
|
|
458
560
|
* toolbar as a lock affordance next to the active tool.
|
|
459
561
|
*/
|
|
460
562
|
_toolLocked = false;
|
|
563
|
+
/** The tool active before the current one — `activeTool.lastActiveTool`. */
|
|
564
|
+
_lastActiveTool = null;
|
|
565
|
+
/** Cached `activeTool` value object; rebuilt only when a component changes. */
|
|
566
|
+
_activeToolCache = {
|
|
567
|
+
type: DEFAULT_MODE,
|
|
568
|
+
locked: false,
|
|
569
|
+
lastActiveTool: null,
|
|
570
|
+
};
|
|
461
571
|
/**
|
|
462
572
|
* Host-extensible file-drop dispatch. Built-ins (image / scene
|
|
463
573
|
* JSON) register themselves at editor construction; hosts add
|
|
@@ -477,14 +587,24 @@ export class Editor {
|
|
|
477
587
|
* only treat right-click releases as potential context-menu
|
|
478
588
|
* triggers (Space + left-drag never opens a menu).
|
|
479
589
|
*/
|
|
480
|
-
panGesture
|
|
590
|
+
get panGesture() {
|
|
591
|
+
return this.interaction.panGesture;
|
|
592
|
+
}
|
|
593
|
+
set panGesture(v) {
|
|
594
|
+
this.interaction.panGesture = v;
|
|
595
|
+
}
|
|
481
596
|
/**
|
|
482
597
|
* Set on a right-click pointerdown so the upcoming native
|
|
483
598
|
* `contextmenu` event can be unconditionally preventDefault'ed
|
|
484
599
|
* (the gesture decides whether to fire the menu manually on
|
|
485
600
|
* pointerup based on whether the user dragged).
|
|
486
601
|
*/
|
|
487
|
-
suppressNextContextMenu
|
|
602
|
+
get suppressNextContextMenu() {
|
|
603
|
+
return this.interaction.suppressNextContextMenu;
|
|
604
|
+
}
|
|
605
|
+
set suppressNextContextMenu(v) {
|
|
606
|
+
this.interaction.suppressNextContextMenu = v;
|
|
607
|
+
}
|
|
488
608
|
/**
|
|
489
609
|
* Long-press tracking. Starts on `pointerdown`; cancelled on
|
|
490
610
|
* `pointermove > LONG_PRESS_MAX_MOVEMENT_PX` or `pointerup` before
|
|
@@ -532,6 +652,18 @@ export class Editor {
|
|
|
532
652
|
* built lazily below.
|
|
533
653
|
*/
|
|
534
654
|
gestures;
|
|
655
|
+
/**
|
|
656
|
+
* Owns the inline text-edit session (edited shape, pending creation,
|
|
657
|
+
* origin snapshot, live selection, drag anchor and caret blink).
|
|
658
|
+
* Editor keeps thin delegate wrappers so the public API is unchanged.
|
|
659
|
+
*/
|
|
660
|
+
textEdit;
|
|
661
|
+
/**
|
|
662
|
+
* Owns the link edit-handle drags (waypoint / segment / endpoint) and
|
|
663
|
+
* the handle double-click detector. Editor keeps thin delegate
|
|
664
|
+
* wrappers so the public API is unchanged.
|
|
665
|
+
*/
|
|
666
|
+
linkHandles;
|
|
535
667
|
constructor(options) {
|
|
536
668
|
this.host = options.host;
|
|
537
669
|
this.mainTarget = options.mainTarget;
|
|
@@ -542,6 +674,55 @@ export class Editor {
|
|
|
542
674
|
this._history = isHistoryProvider(options.history)
|
|
543
675
|
? options.history
|
|
544
676
|
: new History(options.history ?? {});
|
|
677
|
+
this.tileComposeFn =
|
|
678
|
+
options.useTileCache === true && options.tileCompose ? options.tileCompose : null;
|
|
679
|
+
this.initControllers();
|
|
680
|
+
this.initGlobalHooks(options);
|
|
681
|
+
this.initInputMode(options);
|
|
682
|
+
this.initActor(options);
|
|
683
|
+
this.unbind = this.bindPointerEvents();
|
|
684
|
+
// Pause animation playback when the tab / window is hidden (browsers
|
|
685
|
+
// throttle rAF to ~1fps in background but don't stop it; an explicit
|
|
686
|
+
// stop saves the decode + render entirely). Resume when visible again,
|
|
687
|
+
// viewport permitting.
|
|
688
|
+
this.animation.attach();
|
|
689
|
+
// Restore GIF/video bytes onto animated image shapes loaded from
|
|
690
|
+
// an initial scene (e.g. localStorage), then arm the tick so the
|
|
691
|
+
// animation plays from first paint.
|
|
692
|
+
animScene.rehydrateAnimatedImages(this);
|
|
693
|
+
// Rebuild live handles for static images restored from storage — their
|
|
694
|
+
// `metadata.image` didn't survive serialisation and `src` is a dead
|
|
695
|
+
// `blob:` URL, so decode the bytes back from `Scene.files`. Async;
|
|
696
|
+
// repaints itself when the decode lands.
|
|
697
|
+
void animScene.rehydrateStaticImages(this);
|
|
698
|
+
this.maybeAnimate();
|
|
699
|
+
// An animated adapter (GIF) decodes asynchronously; when a decode
|
|
700
|
+
// completes it nudges us here. Re-render so a PAUSED animated shape
|
|
701
|
+
// (reduced-motion / auto-stopped / frozen) — which has no tick to
|
|
702
|
+
// pick the frames up — paints its decoded frame after reload.
|
|
703
|
+
this.animationContentOff = onAnimationContentReady(() => {
|
|
704
|
+
this.scheduleRender();
|
|
705
|
+
});
|
|
706
|
+
// First paint — synchronous so the canvas isn't blank for one
|
|
707
|
+
// frame on mount. Hosts that mount + immediately read the
|
|
708
|
+
// bitmap also get a consistent first frame.
|
|
709
|
+
this.forceRender();
|
|
710
|
+
// Prime the typed-event cache with the editor's initial state so
|
|
711
|
+
// the *first* user-driven update only emits on a real flip.
|
|
712
|
+
// Without this, an `editor.on("mode", fn)` listener installed
|
|
713
|
+
// before any change would fire on the very next `setMode(current)`
|
|
714
|
+
// call because every cached slice would still be `null`.
|
|
715
|
+
primeEventCache(this.eventCache, this.observableSnapshot());
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Build the interaction controllers (gestures, text edit, link-handle
|
|
719
|
+
* drag, long-press, pinch) and the container-ops bridge. Each wires to a
|
|
720
|
+
* narrow getter/setter surface over the editor's mutable fields, so the
|
|
721
|
+
* controllers live in their own modules without importing Editor. The
|
|
722
|
+
* getters/setters in the object literals rebind `this`, so a single
|
|
723
|
+
* `self` alias captures the Editor reference for all of them.
|
|
724
|
+
*/
|
|
725
|
+
initControllers() {
|
|
545
726
|
// Build the gesture controller against a narrow getter/setter
|
|
546
727
|
// bridge to the editor's mutable state. The bridge is a thin
|
|
547
728
|
// adapter — keeps `gestureTx`/`dragElementId` etc. as `private`
|
|
@@ -594,17 +775,107 @@ export class Editor {
|
|
|
594
775
|
return self._toolLocked;
|
|
595
776
|
},
|
|
596
777
|
get mode() {
|
|
597
|
-
return self.
|
|
778
|
+
return self.activeTool.type;
|
|
598
779
|
},
|
|
599
780
|
setMode: (m) => {
|
|
600
|
-
self.
|
|
781
|
+
self.setActiveTool(m);
|
|
601
782
|
},
|
|
602
783
|
notify: () => {
|
|
603
784
|
self.notify();
|
|
604
785
|
},
|
|
605
786
|
});
|
|
606
|
-
|
|
607
|
-
|
|
787
|
+
// Same bridge pattern for the text-edit controller: scene access goes
|
|
788
|
+
// through get/set so live edits replace `_scene` without history.
|
|
789
|
+
this.textEdit = new TextEditController({
|
|
790
|
+
get scene() {
|
|
791
|
+
return self._scene;
|
|
792
|
+
},
|
|
793
|
+
set scene(s) {
|
|
794
|
+
self._scene = s;
|
|
795
|
+
},
|
|
796
|
+
pushHistory: (patch) => {
|
|
797
|
+
self._history.push(patch);
|
|
798
|
+
},
|
|
799
|
+
notify: () => {
|
|
800
|
+
self.notify();
|
|
801
|
+
},
|
|
802
|
+
isLayerLocked: (id) => self.isLayerLocked(id),
|
|
803
|
+
clearSelectionFor: (id) => {
|
|
804
|
+
if (self._selection.has(id))
|
|
805
|
+
self._selection = Selection.EMPTY;
|
|
806
|
+
},
|
|
807
|
+
mainTarget: this.mainTarget,
|
|
808
|
+
});
|
|
809
|
+
// Same bridge pattern for the link handle-drag controller.
|
|
810
|
+
this.linkHandles = new LinkHandleDragController({
|
|
811
|
+
get scene() {
|
|
812
|
+
return self._scene;
|
|
813
|
+
},
|
|
814
|
+
set scene(s) {
|
|
815
|
+
self._scene = s;
|
|
816
|
+
},
|
|
817
|
+
pushHistory: (patch) => {
|
|
818
|
+
self._history.push(patch);
|
|
819
|
+
},
|
|
820
|
+
recordGesturePatch: (patch) => {
|
|
821
|
+
self.recordGesturePatch(patch);
|
|
822
|
+
},
|
|
823
|
+
commitGesture: () => {
|
|
824
|
+
self.commitGesture();
|
|
825
|
+
},
|
|
826
|
+
cancelGesture: () => {
|
|
827
|
+
self.cancelGesture();
|
|
828
|
+
},
|
|
829
|
+
hasGestureTx: () => self.gestureTx !== null,
|
|
830
|
+
notify: () => {
|
|
831
|
+
self.notify();
|
|
832
|
+
},
|
|
833
|
+
linkAttachTargetAt: (worldPoint) => self.linkAttachTargetAt(worldPoint),
|
|
834
|
+
snapLinkEndpoint: (targetId, worldPoint) => self.snapLinkEndpoint(targetId, worldPoint),
|
|
835
|
+
updateHoveredLinkTarget: (worldPoint) => {
|
|
836
|
+
self.updateHoveredLinkTarget(worldPoint);
|
|
837
|
+
},
|
|
838
|
+
clearHoveredLinkTarget: () => {
|
|
839
|
+
self.hoveredLinkTarget = null;
|
|
840
|
+
},
|
|
841
|
+
});
|
|
842
|
+
// Long-press controller — fired on touch-hold; fans out to
|
|
843
|
+
// host-registered listeners (mobile alt to right-click).
|
|
844
|
+
this.longPress = new LongPressController((p) => this.screenToWorld(p), (payload) => {
|
|
845
|
+
for (const fn of this.longPressListeners)
|
|
846
|
+
fn(payload);
|
|
847
|
+
});
|
|
848
|
+
// Pinch gesture controller — two-finger pan + zoom. Hooks into
|
|
849
|
+
// the editor's own zoomAt / panBy / screenToWorld.
|
|
850
|
+
this.pinch = new PinchController((p) => this.screenToWorld(p), (factor, anchorWorld) => {
|
|
851
|
+
this.zoomAt(factor, anchorWorld);
|
|
852
|
+
}, (delta) => {
|
|
853
|
+
this.panBy(delta);
|
|
854
|
+
});
|
|
855
|
+
// Bridge for the container-ops helpers — narrow surface that the
|
|
856
|
+
// pure functions call back into.
|
|
857
|
+
this.containerOpsRef = {
|
|
858
|
+
get scene() {
|
|
859
|
+
return self._scene;
|
|
860
|
+
},
|
|
861
|
+
get dragElementId() {
|
|
862
|
+
return self.dragElementId;
|
|
863
|
+
},
|
|
864
|
+
get containerHover() {
|
|
865
|
+
return self.containerHover;
|
|
866
|
+
},
|
|
867
|
+
applyPatch(patch, nextScene) {
|
|
868
|
+
self._scene = nextScene;
|
|
869
|
+
self.beginOrAttachGesture().add(patch);
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Install process-global hooks the host opted into: a custom text shaper
|
|
875
|
+
* and rasterizer for the WebGL2 backend, plus the scene text measurer that
|
|
876
|
+
* routes through the renderer's own metrics so selection boxes hug text.
|
|
877
|
+
*/
|
|
878
|
+
initGlobalHooks(options) {
|
|
608
879
|
// If the host plugged a TextShaper, install it process-globally so the
|
|
609
880
|
// built-in text renderer's wrap path uses it instead of
|
|
610
881
|
// Canvas2D.measureText. Hosts that don't care leave the field unset and
|
|
@@ -629,6 +900,12 @@ export class Editor {
|
|
|
629
900
|
});
|
|
630
901
|
return this.mainTarget.measureText(text).width;
|
|
631
902
|
});
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Resolve the input mode (`touch` vs `mouse`, `auto` reads the coarse-
|
|
906
|
+
* pointer media query) and the derived hit slops / thresholds once.
|
|
907
|
+
*/
|
|
908
|
+
initInputMode(options) {
|
|
632
909
|
// Resolve input mode + derived hit slops once. `auto` reads
|
|
633
910
|
// `matchMedia('(pointer: coarse)')` when available; SSR falls
|
|
634
911
|
// back to `mouse`.
|
|
@@ -656,6 +933,13 @@ export class Editor {
|
|
|
656
933
|
this.inputMode === "touch" ? TOUCH_ANCHOR_START_HIT_SLOP : ANCHOR_START_HIT_SLOP;
|
|
657
934
|
this.anchorClickRadius =
|
|
658
935
|
this.inputMode === "touch" ? TOUCH_ANCHOR_DOT_CLICK_RADIUS : ANCHOR_DOT_CLICK_RADIUS;
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Create and start the interaction state-machine actor, wire its render /
|
|
939
|
+
* emit subscriptions, register the built-in file-drop handlers, and apply
|
|
940
|
+
* the initial mode.
|
|
941
|
+
*/
|
|
942
|
+
initActor(options) {
|
|
659
943
|
this.actor = createActor(interactionMachine);
|
|
660
944
|
this.actor.subscribe({
|
|
661
945
|
next: () => {
|
|
@@ -672,80 +956,55 @@ export class Editor {
|
|
|
672
956
|
// *after* and only fire for files we don't already handle.
|
|
673
957
|
this.fileDropRegistry.register(imageFileDropHandler);
|
|
674
958
|
this.fileDropRegistry.register(videoFileDropHandler);
|
|
675
|
-
|
|
676
|
-
|
|
959
|
+
this._readOnly = options.readOnly ?? false;
|
|
960
|
+
if (options.initialTool) {
|
|
961
|
+
this.actor.send({ type: "SET_MODE", mode: options.initialTool });
|
|
677
962
|
}
|
|
678
|
-
// Long-press controller — fired on touch-hold; fans out to
|
|
679
|
-
// host-registered listeners (mobile alt to right-click).
|
|
680
|
-
this.longPress = new LongPressController((p) => this.screenToWorld(p), (payload) => {
|
|
681
|
-
for (const fn of this.longPressListeners)
|
|
682
|
-
fn(payload);
|
|
683
|
-
});
|
|
684
|
-
// Pinch gesture controller — two-finger pan + zoom. Hooks into
|
|
685
|
-
// the editor's own zoomAt / panBy / screenToWorld.
|
|
686
|
-
this.pinch = new PinchController((p) => this.screenToWorld(p), (factor, anchorWorld) => {
|
|
687
|
-
this.zoomAt(factor, anchorWorld);
|
|
688
|
-
}, (delta) => {
|
|
689
|
-
this.panBy(delta);
|
|
690
|
-
});
|
|
691
|
-
// Bridge for the container-ops helpers — narrow surface that the
|
|
692
|
-
// pure functions call back into.
|
|
693
|
-
// eslint-disable-next-line @typescript-eslint/no-this-alias -- bridge literal rebinds `this`; alias keeps Editor reference
|
|
694
|
-
const self2 = this;
|
|
695
|
-
this.containerOpsRef = {
|
|
696
|
-
get scene() {
|
|
697
|
-
return self2._scene;
|
|
698
|
-
},
|
|
699
|
-
get dragElementId() {
|
|
700
|
-
return self2.dragElementId;
|
|
701
|
-
},
|
|
702
|
-
get containerHover() {
|
|
703
|
-
return self2.containerHover;
|
|
704
|
-
},
|
|
705
|
-
applyPatch(patch, nextScene) {
|
|
706
|
-
self2._scene = nextScene;
|
|
707
|
-
self2.beginOrAttachGesture().add(patch);
|
|
708
|
-
},
|
|
709
|
-
};
|
|
710
|
-
this.unbind = this.bindPointerEvents();
|
|
711
|
-
// Pause animation playback when the tab / window is hidden (browsers
|
|
712
|
-
// throttle rAF to ~1fps in background but don't stop it; an explicit
|
|
713
|
-
// stop saves the decode + render entirely). Resume when visible again,
|
|
714
|
-
// viewport permitting.
|
|
715
|
-
this.animation.attach();
|
|
716
|
-
// Restore GIF/video bytes onto animated image shapes loaded from
|
|
717
|
-
// an initial scene (e.g. localStorage), then arm the tick so the
|
|
718
|
-
// animation plays from first paint.
|
|
719
|
-
animScene.rehydrateAnimatedImages(this);
|
|
720
|
-
this.maybeAnimate();
|
|
721
|
-
// An animated adapter (GIF) decodes asynchronously; when a decode
|
|
722
|
-
// completes it nudges us here. Re-render so a PAUSED animated shape
|
|
723
|
-
// (reduced-motion / auto-stopped / frozen) — which has no tick to
|
|
724
|
-
// pick the frames up — paints its decoded frame after reload.
|
|
725
|
-
this.animationContentOff = onAnimationContentReady(() => {
|
|
726
|
-
this.scheduleRender();
|
|
727
|
-
});
|
|
728
|
-
// First paint — synchronous so the canvas isn't blank for one
|
|
729
|
-
// frame on mount. Hosts that mount + immediately read the
|
|
730
|
-
// bitmap also get a consistent first frame.
|
|
731
|
-
this.forceRender();
|
|
732
|
-
// Prime the typed-event cache with the editor's initial state so
|
|
733
|
-
// the *first* user-driven update only emits on a real flip.
|
|
734
|
-
// Without this, an `editor.on("mode", fn)` listener installed
|
|
735
|
-
// before any change would fire on the very next `setMode(current)`
|
|
736
|
-
// call because every cached slice would still be `null`.
|
|
737
|
-
primeEventCache(this.eventCache, this.observableSnapshot());
|
|
738
963
|
}
|
|
739
|
-
/**
|
|
964
|
+
/**
|
|
965
|
+
* Last {@link observableSnapshot} object, reused while none of its slices
|
|
966
|
+
* have flipped. `null` until the first snapshot is built.
|
|
967
|
+
*/
|
|
968
|
+
snapshotCache = null;
|
|
969
|
+
/**
|
|
970
|
+
* Snapshot used by event-fanout. Kept private — internal API.
|
|
971
|
+
*
|
|
972
|
+
* Memoized by slice identity: `notify()` fires on many mutations that touch
|
|
973
|
+
* no observable slice (annotation focus, cursor pushes, viewport-only re-arm),
|
|
974
|
+
* so rebuilding the object every call is pure churn on the hot drag path.
|
|
975
|
+
* We reuse the cached object whenever all six slices compare equal (refs for
|
|
976
|
+
* mode/selection/selectedLinks/scene — scene uses structural sharing so a new
|
|
977
|
+
* ref iff something changed — plus the two history booleans), and only
|
|
978
|
+
* allocate a fresh one on a real flip. `fanOutEvents` sees identical values
|
|
979
|
+
* either way, so emitted events are unchanged.
|
|
980
|
+
*/
|
|
740
981
|
observableSnapshot() {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
982
|
+
const activeTool = this.activeTool;
|
|
983
|
+
const selection = this._selection;
|
|
984
|
+
const selectedLinks = this._selectedLinks;
|
|
985
|
+
const scene = this._scene;
|
|
986
|
+
const canUndo = this.canUndo;
|
|
987
|
+
const canRedo = this.canRedo;
|
|
988
|
+
const cached = this.snapshotCache;
|
|
989
|
+
if (cached !== null &&
|
|
990
|
+
cached.activeTool === activeTool &&
|
|
991
|
+
cached.selection === selection &&
|
|
992
|
+
cached.selectedLinks === selectedLinks &&
|
|
993
|
+
cached.scene === scene &&
|
|
994
|
+
cached.canUndo === canUndo &&
|
|
995
|
+
cached.canRedo === canRedo) {
|
|
996
|
+
return cached;
|
|
997
|
+
}
|
|
998
|
+
const snapshot = {
|
|
999
|
+
activeTool,
|
|
1000
|
+
selection,
|
|
1001
|
+
selectedLinks,
|
|
1002
|
+
scene,
|
|
1003
|
+
canUndo,
|
|
1004
|
+
canRedo,
|
|
748
1005
|
};
|
|
1006
|
+
this.snapshotCache = snapshot;
|
|
1007
|
+
return snapshot;
|
|
749
1008
|
}
|
|
750
1009
|
// --- Public state ---
|
|
751
1010
|
get scene() {
|
|
@@ -754,8 +1013,24 @@ export class Editor {
|
|
|
754
1013
|
get selection() {
|
|
755
1014
|
return this._selection;
|
|
756
1015
|
}
|
|
757
|
-
|
|
758
|
-
|
|
1016
|
+
/**
|
|
1017
|
+
* The active tool as a single value object (`{ type, locked,
|
|
1018
|
+
* lastActiveTool }`) — the one source of truth for the current tool.
|
|
1019
|
+
* The reference is stable between changes (safe for React deps).
|
|
1020
|
+
*/
|
|
1021
|
+
get activeTool() {
|
|
1022
|
+
const type = this.actor.getSnapshot().context.mode;
|
|
1023
|
+
const c = this._activeToolCache;
|
|
1024
|
+
if (c.type !== type ||
|
|
1025
|
+
c.locked !== this._toolLocked ||
|
|
1026
|
+
c.lastActiveTool !== this._lastActiveTool) {
|
|
1027
|
+
this._activeToolCache = {
|
|
1028
|
+
type,
|
|
1029
|
+
locked: this._toolLocked,
|
|
1030
|
+
lastActiveTool: this._lastActiveTool,
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
return this._activeToolCache;
|
|
759
1034
|
}
|
|
760
1035
|
get history() {
|
|
761
1036
|
return this._history;
|
|
@@ -920,6 +1195,29 @@ export class Editor {
|
|
|
920
1195
|
this.debugHitZones = on;
|
|
921
1196
|
this.scheduleRender();
|
|
922
1197
|
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Read-only / view mode flag. `true` gates pointer edits and
|
|
1200
|
+
* non-`viewMode` actions while leaving pan / zoom / select live.
|
|
1201
|
+
*/
|
|
1202
|
+
get readOnly() {
|
|
1203
|
+
return this._readOnly;
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Enter / leave read-only (view) mode. Notifies subscribers so the UI
|
|
1207
|
+
* can re-render disabled chrome, and repaints (no visual diff today, but
|
|
1208
|
+
* keeps the contract symmetric with other view toggles). Idempotent.
|
|
1209
|
+
*/
|
|
1210
|
+
setReadOnly(on) {
|
|
1211
|
+
if (this._readOnly === on)
|
|
1212
|
+
return;
|
|
1213
|
+
this._readOnly = on;
|
|
1214
|
+
this.notify();
|
|
1215
|
+
this.scheduleRender();
|
|
1216
|
+
}
|
|
1217
|
+
/** Toggle read-only (view) mode. */
|
|
1218
|
+
toggleReadOnly() {
|
|
1219
|
+
this.setReadOnly(!this._readOnly);
|
|
1220
|
+
}
|
|
923
1221
|
/** Whether the background grid is enabled for the scene. */
|
|
924
1222
|
get gridEnabled() {
|
|
925
1223
|
return this._scene.viewport.gridEnabled;
|
|
@@ -932,10 +1230,6 @@ export class Editor {
|
|
|
932
1230
|
toggleGrid() {
|
|
933
1231
|
this.setGrid({ enabled: !this.gridEnabled });
|
|
934
1232
|
}
|
|
935
|
-
/** Whether the active draw-mode sticks after a create (toolbar lock). */
|
|
936
|
-
get toolLocked() {
|
|
937
|
-
return this._toolLocked;
|
|
938
|
-
}
|
|
939
1233
|
/** All currently-selected link (connector) ids. */
|
|
940
1234
|
get selectedLinks() {
|
|
941
1235
|
return this._selectedLinks;
|
|
@@ -958,6 +1252,8 @@ export class Editor {
|
|
|
958
1252
|
* object — Link is readonly). No-op when no edge is selected.
|
|
959
1253
|
*/
|
|
960
1254
|
updateSelectedLink(updater) {
|
|
1255
|
+
if (this.readOnly)
|
|
1256
|
+
return;
|
|
961
1257
|
const id = this.selectedLink;
|
|
962
1258
|
if (id === null)
|
|
963
1259
|
return;
|
|
@@ -992,8 +1288,8 @@ export class Editor {
|
|
|
992
1288
|
return this.fileDropRegistry.dispatch(file, ctx);
|
|
993
1289
|
}
|
|
994
1290
|
/**
|
|
995
|
-
* Toggle the tool
|
|
996
|
-
* after each successful shape create — the user keeps drawing
|
|
1291
|
+
* Toggle the tool lock (`activeTool.locked`). With `true`, draw tools
|
|
1292
|
+
* persist after each successful shape create — the user keeps drawing
|
|
997
1293
|
* rectangles without re-pressing R. With `false` (default), the
|
|
998
1294
|
* editor reverts to `select` after each create.
|
|
999
1295
|
*/
|
|
@@ -1003,13 +1299,23 @@ export class Editor {
|
|
|
1003
1299
|
this._toolLocked = locked;
|
|
1004
1300
|
this.notify();
|
|
1005
1301
|
}
|
|
1006
|
-
|
|
1302
|
+
maybeRevertToolAfterCreate() {
|
|
1007
1303
|
this.gestures.maybeRevertModeAfterCreate();
|
|
1008
1304
|
}
|
|
1009
|
-
|
|
1305
|
+
/**
|
|
1306
|
+
* Switch the active tool. The single entry point for tool changes —
|
|
1307
|
+
* toolbar buttons and hotkeys reach it through the action registry.
|
|
1308
|
+
* Records the outgoing tool in `activeTool.lastActiveTool`.
|
|
1309
|
+
*/
|
|
1310
|
+
setActiveTool(mode) {
|
|
1311
|
+
const prev = this.actor.getSnapshot().context.mode;
|
|
1312
|
+
if (prev !== mode)
|
|
1313
|
+
this._lastActiveTool = prev;
|
|
1314
|
+
// A tool switch cancels any armed colour-picker pipette.
|
|
1315
|
+
this.pendingEyedropperPick = null;
|
|
1010
1316
|
// Switching tools commits any in-flight text edit (standard: leaving the
|
|
1011
1317
|
// editing context ends it, keeping the typed text).
|
|
1012
|
-
if (this.
|
|
1318
|
+
if (this.editingTextElement !== null)
|
|
1013
1319
|
this.commitTextEdit();
|
|
1014
1320
|
// Cancel any in-progress drag gesture so the partial state is not recorded.
|
|
1015
1321
|
if (this.gestureTx) {
|
|
@@ -1100,7 +1406,9 @@ export class Editor {
|
|
|
1100
1406
|
* `dispose()` stops it.
|
|
1101
1407
|
*/
|
|
1102
1408
|
animation = new AnimationController({
|
|
1103
|
-
|
|
1409
|
+
// Laser trails also need a per-frame repaint to animate their fade — OR
|
|
1410
|
+
// them into the tick predicate so the same rAF loop drives both.
|
|
1411
|
+
hasVisibleAnimatedElement: () => animScene.hasVisibleAnimatedElement(this) || this.hasActiveLaser(),
|
|
1104
1412
|
autoStopHeavyGifs: () => {
|
|
1105
1413
|
animScene.autoStopHeavyGifs(this);
|
|
1106
1414
|
},
|
|
@@ -1230,6 +1538,8 @@ export class Editor {
|
|
|
1230
1538
|
};
|
|
1231
1539
|
}
|
|
1232
1540
|
deleteSelected() {
|
|
1541
|
+
if (this.readOnly)
|
|
1542
|
+
return;
|
|
1233
1543
|
const result = computeDeleteSelection(this._scene, this._selection, this._selectedLinks);
|
|
1234
1544
|
if (!result)
|
|
1235
1545
|
return;
|
|
@@ -1248,15 +1558,14 @@ export class Editor {
|
|
|
1248
1558
|
* cleared by `commitTextEdit` / `cancelTextEdit`. The host overlay
|
|
1249
1559
|
* (`<TextEditorOverlay>` in `@react-ui`) subscribes via `editor`
|
|
1250
1560
|
* and renders a `<textarea>` positioned over the shape.
|
|
1561
|
+
* State lives in `TextEditController`; this is a delegate.
|
|
1251
1562
|
*/
|
|
1252
|
-
_editingTextElement = null;
|
|
1253
1563
|
get editingTextElement() {
|
|
1254
|
-
return this.
|
|
1564
|
+
return this.textEdit.editingElement;
|
|
1255
1565
|
}
|
|
1256
1566
|
/** Link whose caption is being edited inline (double-click), or null. */
|
|
1257
|
-
_editingLinkCaption = null;
|
|
1258
1567
|
get editingLinkCaption() {
|
|
1259
|
-
return this.
|
|
1568
|
+
return this.interaction.editingLinkCaption;
|
|
1260
1569
|
}
|
|
1261
1570
|
/**
|
|
1262
1571
|
* Frame whose NAME (header label) is being edited inline (double-click
|
|
@@ -1267,46 +1576,19 @@ export class Editor {
|
|
|
1267
1576
|
get editingFrameName() {
|
|
1268
1577
|
return this._editingFrameName;
|
|
1269
1578
|
}
|
|
1270
|
-
/**
|
|
1271
|
-
* When the `draw-text` tool just placed a shape and opened its
|
|
1272
|
-
* editor, this holds that shape's id until the first commit. A
|
|
1273
|
-
* pending creation isn't in history yet: committing non-empty text
|
|
1274
|
-
* records a single add patch (whole shape = one undo); committing
|
|
1275
|
-
* empty / cancelling removes it with no history entry at all.
|
|
1276
|
-
*/
|
|
1277
|
-
_pendingTextCreate = null;
|
|
1278
|
-
/**
|
|
1279
|
-
* Snapshot of the shape at edit start. Used to revert on cancel and
|
|
1280
|
-
* as the `before` of the single commit patch. `null` for a pending
|
|
1281
|
-
* creation (the shape didn't exist yet).
|
|
1282
|
-
*/
|
|
1283
|
-
_textEditOrigin = null;
|
|
1284
|
-
/**
|
|
1285
|
-
* Live selection inside the edited text, mirrored from the hidden
|
|
1286
|
-
* `<textarea>` (`start`/`end` are source offsets, `dir` is the
|
|
1287
|
-
* anchored end). The caret is `dir === "backward" ? start : end`.
|
|
1288
|
-
*/
|
|
1289
|
-
_textSel = null;
|
|
1290
|
-
/** Anchor offset for a canvas drag-select inside the edited text. */
|
|
1291
|
-
_textDragAnchor = null;
|
|
1292
|
-
caretBlink = new CaretBlinkController(() => {
|
|
1293
|
-
this.notify();
|
|
1294
|
-
});
|
|
1295
1579
|
get editingTextSelection() {
|
|
1296
|
-
return this.
|
|
1580
|
+
return this.textEdit.selection;
|
|
1297
1581
|
}
|
|
1298
1582
|
/** Caret offset = the moving end of the selection. */
|
|
1299
1583
|
get editingTextCaret() {
|
|
1300
|
-
|
|
1301
|
-
return null;
|
|
1302
|
-
return this._textSel.dir === "backward" ? this._textSel.start : this._textSel.end;
|
|
1584
|
+
return this.textEdit.caret;
|
|
1303
1585
|
}
|
|
1304
1586
|
get caretBlinkOn() {
|
|
1305
|
-
return this.
|
|
1587
|
+
return this.textEdit.caretBlinkOn;
|
|
1306
1588
|
}
|
|
1307
1589
|
/** `true` while a canvas drag-select inside the edited text is active. */
|
|
1308
1590
|
get isTextDragging() {
|
|
1309
|
-
return this.
|
|
1591
|
+
return this.textEdit.isDragging;
|
|
1310
1592
|
}
|
|
1311
1593
|
/**
|
|
1312
1594
|
* Begin editing a text shape's body. No-op when the shape doesn't
|
|
@@ -1317,9 +1599,9 @@ export class Editor {
|
|
|
1317
1599
|
beginLinkCaptionEdit(id) {
|
|
1318
1600
|
if (!getLink(this._scene, id))
|
|
1319
1601
|
return;
|
|
1320
|
-
if (this.
|
|
1602
|
+
if (this.editingTextElement !== null)
|
|
1321
1603
|
this.commitTextEdit();
|
|
1322
|
-
this.
|
|
1604
|
+
this.interaction.editingLinkCaption = id;
|
|
1323
1605
|
this.notify();
|
|
1324
1606
|
}
|
|
1325
1607
|
/**
|
|
@@ -1328,8 +1610,8 @@ export class Editor {
|
|
|
1328
1610
|
* styling. One undo step. Clears caption-edit mode.
|
|
1329
1611
|
*/
|
|
1330
1612
|
commitLinkCaptionEdit(text) {
|
|
1331
|
-
const id = this.
|
|
1332
|
-
this.
|
|
1613
|
+
const id = this.interaction.editingLinkCaption;
|
|
1614
|
+
this.interaction.editingLinkCaption = null;
|
|
1333
1615
|
if (id === null) {
|
|
1334
1616
|
this.notify();
|
|
1335
1617
|
return;
|
|
@@ -1356,50 +1638,27 @@ export class Editor {
|
|
|
1356
1638
|
}
|
|
1357
1639
|
/** Cancel link caption editing without changing the label. */
|
|
1358
1640
|
cancelLinkCaptionEdit() {
|
|
1359
|
-
if (this.
|
|
1641
|
+
if (this.interaction.editingLinkCaption === null)
|
|
1360
1642
|
return;
|
|
1361
|
-
this.
|
|
1643
|
+
this.interaction.editingLinkCaption = null;
|
|
1362
1644
|
this.notify();
|
|
1363
1645
|
}
|
|
1364
|
-
/**
|
|
1646
|
+
/**
|
|
1647
|
+
* World-space anchor point for a link's caption — the same shared geometry
|
|
1648
|
+
* the renderer places the pill at (`linkLabelAnchor` over the drawn
|
|
1649
|
+
* polyline), so the inline editor opens exactly over the label.
|
|
1650
|
+
*/
|
|
1365
1651
|
linkLabelWorld(id) {
|
|
1366
1652
|
const edge = getLink(this._scene, id);
|
|
1367
1653
|
if (!edge)
|
|
1368
1654
|
return null;
|
|
1369
|
-
const path =
|
|
1655
|
+
const path = getLinkCurvePoints(this._scene, edge);
|
|
1370
1656
|
if (!path || path.length < 2)
|
|
1371
1657
|
return null;
|
|
1372
|
-
|
|
1373
|
-
let total = 0;
|
|
1374
|
-
for (let i = 1; i < path.length; i++)
|
|
1375
|
-
total += vec2.distance(req(path[i - 1]), req(path[i]));
|
|
1376
|
-
let remaining = total * t;
|
|
1377
|
-
for (let i = 1; i < path.length; i++) {
|
|
1378
|
-
const a = req(path[i - 1]);
|
|
1379
|
-
const b = req(path[i]);
|
|
1380
|
-
const seg = vec2.distance(a, b);
|
|
1381
|
-
if (remaining <= seg) {
|
|
1382
|
-
const r = seg === 0 ? 0 : remaining / seg;
|
|
1383
|
-
return { x: a.x + (b.x - a.x) * r, y: a.y + (b.y - a.y) * r };
|
|
1384
|
-
}
|
|
1385
|
-
remaining -= seg;
|
|
1386
|
-
}
|
|
1387
|
-
return req(path[path.length - 1]);
|
|
1658
|
+
return linkLabelAnchor(path, edge);
|
|
1388
1659
|
}
|
|
1389
1660
|
beginTextEdit(id) {
|
|
1390
|
-
|
|
1391
|
-
return;
|
|
1392
|
-
// Commit any in-flight edit on a different shape first.
|
|
1393
|
-
if (this._editingTextElement !== null && this._editingTextElement !== id)
|
|
1394
|
-
this.commitTextEdit();
|
|
1395
|
-
this._editingTextElement = id;
|
|
1396
|
-
this._textEditOrigin =
|
|
1397
|
-
this._pendingTextCreate === id ? null : (getElement(this._scene, id) ?? null);
|
|
1398
|
-
const shape = getElement(this._scene, id);
|
|
1399
|
-
const len = shape?.text.length ?? 0;
|
|
1400
|
-
this._textSel = { start: len, end: len, dir: "forward" };
|
|
1401
|
-
this.caretBlink.start();
|
|
1402
|
-
this.notify();
|
|
1661
|
+
this.textEdit.begin(id);
|
|
1403
1662
|
}
|
|
1404
1663
|
// --- Frame name inline editing (double-click the header) ---
|
|
1405
1664
|
/**
|
|
@@ -1412,7 +1671,7 @@ export class Editor {
|
|
|
1412
1671
|
return;
|
|
1413
1672
|
if (this.isLayerLocked(shape.layerId))
|
|
1414
1673
|
return;
|
|
1415
|
-
if (this.
|
|
1674
|
+
if (this.editingTextElement !== null)
|
|
1416
1675
|
this.commitTextEdit();
|
|
1417
1676
|
this._editingFrameName = id;
|
|
1418
1677
|
this.notify();
|
|
@@ -1457,22 +1716,11 @@ export class Editor {
|
|
|
1457
1716
|
* once on commit. No-op when not editing.
|
|
1458
1717
|
*/
|
|
1459
1718
|
setEditingText(value, selStart, selEnd, dir = "forward") {
|
|
1460
|
-
|
|
1461
|
-
if (!id)
|
|
1462
|
-
return;
|
|
1463
|
-
const r = updateElement(this._scene, id, (s) => ({ ...s, text: value }));
|
|
1464
|
-
this._scene = r.scene;
|
|
1465
|
-
this._textSel = { start: selStart, end: selEnd, dir };
|
|
1466
|
-
this.caretBlink.wake();
|
|
1467
|
-
this.notify();
|
|
1719
|
+
this.textEdit.setText(value, selStart, selEnd, dir);
|
|
1468
1720
|
}
|
|
1469
1721
|
/** Selection-only update (arrows / shift-select / click) — no text change. */
|
|
1470
1722
|
setEditingSelection(selStart, selEnd, dir = "forward") {
|
|
1471
|
-
|
|
1472
|
-
return;
|
|
1473
|
-
this._textSel = { start: selStart, end: selEnd, dir };
|
|
1474
|
-
this.caretBlink.wake();
|
|
1475
|
-
this.notify();
|
|
1723
|
+
this.textEdit.setSelection(selStart, selEnd, dir);
|
|
1476
1724
|
}
|
|
1477
1725
|
/**
|
|
1478
1726
|
* Map a world-space point to a caret offset in the edited text. Used
|
|
@@ -1480,26 +1728,7 @@ export class Editor {
|
|
|
1480
1728
|
* not editing or the shape is gone.
|
|
1481
1729
|
*/
|
|
1482
1730
|
caretIndexAtWorldPoint(worldPoint) {
|
|
1483
|
-
|
|
1484
|
-
if (!id)
|
|
1485
|
-
return null;
|
|
1486
|
-
const shape = getElement(this._scene, id);
|
|
1487
|
-
if (shape?.type !== "text")
|
|
1488
|
-
return null;
|
|
1489
|
-
const layout = this.editingTextLayout(shape);
|
|
1490
|
-
if (!layout)
|
|
1491
|
-
return null;
|
|
1492
|
-
// World → shape-local: undo the element transform so the hit lands on the
|
|
1493
|
-
// right glyph. Translate by position, then divide out scale (rotation
|
|
1494
|
-
// while editing text is not handled — an uncommon case).
|
|
1495
|
-
const sx = shape.scale.x || 1;
|
|
1496
|
-
const sy = shape.scale.y || 1;
|
|
1497
|
-
const local = {
|
|
1498
|
-
x: (worldPoint.x - shape.position.x) / sx,
|
|
1499
|
-
y: (worldPoint.y - shape.position.y) / sy,
|
|
1500
|
-
};
|
|
1501
|
-
const align = shape.style.textAlign ?? "left";
|
|
1502
|
-
return pointToCaretIndex(layout, local, this.measureFor(shape), align);
|
|
1731
|
+
return this.textEdit.caretIndexAtWorldPoint(worldPoint);
|
|
1503
1732
|
}
|
|
1504
1733
|
/**
|
|
1505
1734
|
* `true` when a point is inside the currently-edited text shape's
|
|
@@ -1507,65 +1736,19 @@ export class Editor {
|
|
|
1507
1736
|
* repositioning the caret (inside) and committing (outside).
|
|
1508
1737
|
*/
|
|
1509
1738
|
editedElementContainsPoint(worldPoint) {
|
|
1510
|
-
|
|
1511
|
-
if (!id)
|
|
1512
|
-
return false;
|
|
1513
|
-
const shape = getElement(this._scene, id);
|
|
1514
|
-
if (!shape)
|
|
1515
|
-
return false;
|
|
1516
|
-
const b = getElementWorldBounds(shape);
|
|
1517
|
-
return (worldPoint.x >= b.x &&
|
|
1518
|
-
worldPoint.x <= b.x + b.width &&
|
|
1519
|
-
worldPoint.y >= b.y &&
|
|
1520
|
-
worldPoint.y <= b.y + b.height);
|
|
1739
|
+
return this.textEdit.editedElementContainsPoint(worldPoint);
|
|
1521
1740
|
}
|
|
1522
1741
|
/** Place a collapsed caret at the clicked point and start a drag-select. */
|
|
1523
1742
|
setTextCaretFromPoint(worldPoint) {
|
|
1524
|
-
|
|
1525
|
-
if (idx === null)
|
|
1526
|
-
return;
|
|
1527
|
-
this._textDragAnchor = idx;
|
|
1528
|
-
this.setEditingSelection(idx, idx, "forward");
|
|
1743
|
+
this.textEdit.setCaretFromPoint(worldPoint);
|
|
1529
1744
|
}
|
|
1530
1745
|
/** Extend the selection from the drag anchor to the current point. */
|
|
1531
1746
|
extendTextSelectionToPoint(worldPoint) {
|
|
1532
|
-
|
|
1533
|
-
return;
|
|
1534
|
-
const idx = this.caretIndexAtWorldPoint(worldPoint);
|
|
1535
|
-
if (idx === null)
|
|
1536
|
-
return;
|
|
1537
|
-
const anchor = this._textDragAnchor;
|
|
1538
|
-
if (idx >= anchor)
|
|
1539
|
-
this.setEditingSelection(anchor, idx, "forward");
|
|
1540
|
-
else
|
|
1541
|
-
this.setEditingSelection(idx, anchor, "backward");
|
|
1747
|
+
this.textEdit.extendSelectionToPoint(worldPoint);
|
|
1542
1748
|
}
|
|
1543
1749
|
/** End a canvas drag-select (clears the drag anchor). */
|
|
1544
1750
|
endTextDragSelect() {
|
|
1545
|
-
this.
|
|
1546
|
-
}
|
|
1547
|
-
/** Build the editable layout for a text shape using the main target's metrics. */
|
|
1548
|
-
editingTextLayout(shape) {
|
|
1549
|
-
return layoutText(shape.text, this.measureFor(shape), {
|
|
1550
|
-
fontSize: shape.fontSize,
|
|
1551
|
-
...(shape.maxWidth !== undefined ? { maxWidth: shape.maxWidth } : {}),
|
|
1552
|
-
});
|
|
1553
|
-
}
|
|
1554
|
-
/**
|
|
1555
|
-
* A measure callback bound to a shape's font, using the main target's
|
|
1556
|
-
* `measureText` — the SAME source the renderer draws with (WebGL2
|
|
1557
|
-
* reports MSDF advances) and the bounder measures with. Caret /
|
|
1558
|
-
* selection geometry therefore lines up exactly with the glyphs.
|
|
1559
|
-
*/
|
|
1560
|
-
measureFor(shape) {
|
|
1561
|
-
const target = this.mainTarget;
|
|
1562
|
-
// Match the rendered weight/style so caret / selection geometry lines
|
|
1563
|
-
// up with bold / italic glyphs (which have different advances).
|
|
1564
|
-
target.setFont(shape.fontFamily, shape.fontSize, {
|
|
1565
|
-
...(shape.style.fontWeight === "bold" ? { weight: "bold" } : {}),
|
|
1566
|
-
...(shape.style.fontStyle === "italic" ? { style: "italic" } : {}),
|
|
1567
|
-
});
|
|
1568
|
-
return (s) => target.measureText(s).width;
|
|
1751
|
+
this.textEdit.endDragSelect();
|
|
1569
1752
|
}
|
|
1570
1753
|
/**
|
|
1571
1754
|
* World-space caret + selection geometry for the overlay pass.
|
|
@@ -1573,116 +1756,13 @@ export class Editor {
|
|
|
1573
1756
|
* off so the overlay can simply skip drawing it.
|
|
1574
1757
|
*/
|
|
1575
1758
|
editingTextOverlay() {
|
|
1576
|
-
|
|
1577
|
-
if (!id || !this._textSel)
|
|
1578
|
-
return null;
|
|
1579
|
-
const shape = getElement(this._scene, id);
|
|
1580
|
-
if (shape?.type !== "text")
|
|
1581
|
-
return null;
|
|
1582
|
-
const layout = this.editingTextLayout(shape);
|
|
1583
|
-
if (!layout)
|
|
1584
|
-
return null;
|
|
1585
|
-
const align = shape.style.textAlign ?? "left";
|
|
1586
|
-
const measure = this.measureFor(shape);
|
|
1587
|
-
const { x: px, y: py } = shape.position;
|
|
1588
|
-
// The layout is in the shape's own (unscaled) space; the renderer draws it
|
|
1589
|
-
// through the element transform, so caret + selection geometry must scale
|
|
1590
|
-
// too or they trail the rendered text on a scaled element. (Rotation while
|
|
1591
|
-
// editing text is not handled — an uncommon case.)
|
|
1592
|
-
const sx = shape.scale.x;
|
|
1593
|
-
const sy = shape.scale.y;
|
|
1594
|
-
const local = textSelectionRects(layout, this._textSel.start, this._textSel.end, measure, align);
|
|
1595
|
-
const selectionRects = local.map((r) => ({
|
|
1596
|
-
x: px + Math.min(r.x * sx, (r.x + r.width) * sx),
|
|
1597
|
-
y: py + Math.min(r.y * sy, (r.y + r.height) * sy),
|
|
1598
|
-
width: Math.abs(r.width * sx),
|
|
1599
|
-
height: Math.abs(r.height * sy),
|
|
1600
|
-
}));
|
|
1601
|
-
let caret = null;
|
|
1602
|
-
if (this.caretBlink.on) {
|
|
1603
|
-
const cIdx = this._textSel.dir === "backward" ? this._textSel.start : this._textSel.end;
|
|
1604
|
-
const g = caretGeometry(layout, cIdx, measure, shape.fontSize, align);
|
|
1605
|
-
caret = { x: px + g.x * sx, y: py + g.y * sy, height: g.height * Math.abs(sy) };
|
|
1606
|
-
}
|
|
1607
|
-
return { caret, caretColor: shape.style.fill ?? "#1a1a1a", selectionRects };
|
|
1759
|
+
return this.textEdit.overlay();
|
|
1608
1760
|
}
|
|
1609
1761
|
commitTextEdit(next) {
|
|
1610
|
-
|
|
1611
|
-
if (!id)
|
|
1612
|
-
return;
|
|
1613
|
-
const pending = this._pendingTextCreate === id;
|
|
1614
|
-
const origin = this._textEditOrigin;
|
|
1615
|
-
// Optional explicit text (keyboard / test callers); the live path
|
|
1616
|
-
// passes nothing because the scene already holds the typed text.
|
|
1617
|
-
if (next !== undefined) {
|
|
1618
|
-
this._scene = updateElement(this._scene, id, (s) => ({ ...s, text: next })).scene;
|
|
1619
|
-
}
|
|
1620
|
-
this._editingTextElement = null;
|
|
1621
|
-
this._pendingTextCreate = null;
|
|
1622
|
-
this._textEditOrigin = null;
|
|
1623
|
-
this._textSel = null;
|
|
1624
|
-
this.caretBlink.stop();
|
|
1625
|
-
const finalElement = getElement(this._scene, id);
|
|
1626
|
-
const text = finalElement?.text ?? "";
|
|
1627
|
-
// Empty (whitespace-only) text removes the shape. Pending = silent
|
|
1628
|
-
// (never recorded); existing = recorded so undo restores the origin.
|
|
1629
|
-
if (text.trim() === "") {
|
|
1630
|
-
if (finalElement) {
|
|
1631
|
-
this._scene = removeElement(this._scene, id).scene;
|
|
1632
|
-
if (!pending && origin) {
|
|
1633
|
-
this._history.push({ kind: "element", id, before: origin, after: null });
|
|
1634
|
-
}
|
|
1635
|
-
if (this._selection.has(id))
|
|
1636
|
-
this._selection = Selection.EMPTY;
|
|
1637
|
-
}
|
|
1638
|
-
this.notify();
|
|
1639
|
-
return;
|
|
1640
|
-
}
|
|
1641
|
-
if (pending) {
|
|
1642
|
-
// Record the whole creation as one add patch.
|
|
1643
|
-
if (finalElement)
|
|
1644
|
-
this._history.push({ kind: "element", id, before: null, after: finalElement });
|
|
1645
|
-
}
|
|
1646
|
-
else if (origin && finalElement) {
|
|
1647
|
-
// Existing edit: record ONLY the text delta. Other fields (font
|
|
1648
|
-
// size etc.) changed via the panel push their own history during
|
|
1649
|
-
// the edit, so the commit's `before` keeps the final non-text
|
|
1650
|
-
// state and rewinds just the text.
|
|
1651
|
-
const originText = origin.text;
|
|
1652
|
-
if (originText !== finalElement.text) {
|
|
1653
|
-
const before = { ...finalElement, text: originText };
|
|
1654
|
-
this._history.push({ kind: "element", id, before, after: finalElement });
|
|
1655
|
-
}
|
|
1656
|
-
}
|
|
1657
|
-
this.notify();
|
|
1762
|
+
this.textEdit.commit(next);
|
|
1658
1763
|
}
|
|
1659
1764
|
cancelTextEdit() {
|
|
1660
|
-
|
|
1661
|
-
if (id === null)
|
|
1662
|
-
return;
|
|
1663
|
-
const pending = this._pendingTextCreate === id;
|
|
1664
|
-
const origin = this._textEditOrigin;
|
|
1665
|
-
this._editingTextElement = null;
|
|
1666
|
-
this._pendingTextCreate = null;
|
|
1667
|
-
this._textEditOrigin = null;
|
|
1668
|
-
this._textSel = null;
|
|
1669
|
-
this.caretBlink.stop();
|
|
1670
|
-
// Revert live edits with no history entry. Pending creations are
|
|
1671
|
-
// removed entirely; existing shapes have only their TEXT restored
|
|
1672
|
-
// (panel-driven field changes during the edit keep their own
|
|
1673
|
-
// committed history and must survive the cancel).
|
|
1674
|
-
if (pending) {
|
|
1675
|
-
if (getElement(this._scene, id)) {
|
|
1676
|
-
this._scene = removeElement(this._scene, id).scene;
|
|
1677
|
-
if (this._selection.has(id))
|
|
1678
|
-
this._selection = Selection.EMPTY;
|
|
1679
|
-
}
|
|
1680
|
-
}
|
|
1681
|
-
else if (origin) {
|
|
1682
|
-
const originText = origin.text;
|
|
1683
|
-
this._scene = updateElement(this._scene, id, (s) => ({ ...s, text: originText })).scene;
|
|
1684
|
-
}
|
|
1685
|
-
this.notify();
|
|
1765
|
+
this.textEdit.cancel();
|
|
1686
1766
|
}
|
|
1687
1767
|
/**
|
|
1688
1768
|
* Translate every selected shape by the given world-space delta.
|
|
@@ -1691,6 +1771,8 @@ export class Editor {
|
|
|
1691
1771
|
* and `{ x: 10, y: 0 }` for shift-arrow.
|
|
1692
1772
|
*/
|
|
1693
1773
|
moveSelectionBy(delta) {
|
|
1774
|
+
if (this.readOnly)
|
|
1775
|
+
return;
|
|
1694
1776
|
if (this._selection.size === 0 && this._selectedLinks.size === 0)
|
|
1695
1777
|
return;
|
|
1696
1778
|
// Locked / layer-locked elements don't move (they're still selectable).
|
|
@@ -1735,7 +1817,7 @@ export class Editor {
|
|
|
1735
1817
|
y: (vp.size.height || 200) / 2,
|
|
1736
1818
|
});
|
|
1737
1819
|
const id = newElementIdAtCursor(++this.nextId);
|
|
1738
|
-
const shape = buildElementAtCursor(this._scene, this.
|
|
1820
|
+
const shape = buildElementAtCursor(this._scene, this.activeTool.type, world, this._activeLayerId, id);
|
|
1739
1821
|
const r = addElement(this._scene, shape);
|
|
1740
1822
|
this._scene = r.scene;
|
|
1741
1823
|
this._history.push(r.patch);
|
|
@@ -1755,30 +1837,51 @@ export class Editor {
|
|
|
1755
1837
|
const id = newElementIdAtCursor(++this.nextId);
|
|
1756
1838
|
const shape = buildTextElementAt(this._scene, worldPoint, this._activeLayerId, id);
|
|
1757
1839
|
// No history push here — the placeholder is "pending" until the
|
|
1758
|
-
// first commit (see `
|
|
1840
|
+
// first commit (see `TextEditController.markPendingCreate`). This way an abandoned
|
|
1759
1841
|
// text never pollutes the undo stack.
|
|
1760
1842
|
const r = addElement(this._scene, shape);
|
|
1761
1843
|
this._scene = r.scene;
|
|
1762
|
-
this.
|
|
1844
|
+
this.textEdit.markPendingCreate(id);
|
|
1763
1845
|
this._selection = Selection.single(id);
|
|
1764
|
-
this.
|
|
1846
|
+
this.maybeRevertToolAfterCreate();
|
|
1765
1847
|
this.notify();
|
|
1766
1848
|
this.announce(`Created text ${id}`);
|
|
1767
1849
|
this.beginTextEdit(id);
|
|
1768
1850
|
return id;
|
|
1769
1851
|
}
|
|
1770
|
-
|
|
1771
|
-
|
|
1852
|
+
/** Current brush paint settings (line colour, fill, opacity, width). */
|
|
1853
|
+
_brushSettings = DEFAULT_BRUSH_SETTINGS;
|
|
1854
|
+
get brushSettings() {
|
|
1855
|
+
return this._brushSettings;
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Update one or more brush paint settings (e.g. from the drawing panel). New
|
|
1859
|
+
* strokes pick them up on commit; the width also drives the pressure curve and
|
|
1860
|
+
* the eraser radius. Merges over the current settings.
|
|
1861
|
+
*/
|
|
1862
|
+
setBrushSettings(patch) {
|
|
1863
|
+
this._brushSettings = { ...this._brushSettings, ...patch };
|
|
1864
|
+
this.notify();
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Start a brush stroke. `pointerType` (a `PointerEvent.pointerType`) decides
|
|
1868
|
+
* the pressure source: a pen has a real pressure channel and is honoured
|
|
1869
|
+
* verbatim (the default, which also keeps programmatic callers exact); mouse
|
|
1870
|
+
* and touch have none, so pressure is simulated from pointer speed.
|
|
1871
|
+
*/
|
|
1872
|
+
beginBrushStroke(world, pressure = 0.5, pointerType = "pen") {
|
|
1873
|
+
this.brushStroke = beginBrushStrokePure(world, pressure, this._brushSettings.width, pointerType !== "pen");
|
|
1772
1874
|
this.notify();
|
|
1773
1875
|
}
|
|
1774
1876
|
extendBrushStroke(world, pressure = 0.5) {
|
|
1775
1877
|
if (!this.brushStroke)
|
|
1776
1878
|
return;
|
|
1777
|
-
|
|
1879
|
+
// Zoom feeds the speed-based pressure simulation (screen-px speed).
|
|
1880
|
+
extendBrushStrokePure(this.brushStroke, world, pressure, this._scene.viewport.zoom || 1);
|
|
1778
1881
|
this.notify();
|
|
1779
1882
|
}
|
|
1780
1883
|
commitBrushStroke() {
|
|
1781
|
-
const result = commitBrushStrokePure(this._scene, this.brushStroke, this._activeLayerId, newBrushId(++this.nextId));
|
|
1884
|
+
const result = commitBrushStrokePure(this._scene, this.brushStroke, this._activeLayerId, newBrushId(++this.nextId), brushStyleFromSettings(this._brushSettings));
|
|
1782
1885
|
if (!result) {
|
|
1783
1886
|
this.brushStroke = null;
|
|
1784
1887
|
this.notify();
|
|
@@ -1800,6 +1903,223 @@ export class Editor {
|
|
|
1800
1903
|
get pendingBrushStroke() {
|
|
1801
1904
|
return this.brushStroke;
|
|
1802
1905
|
}
|
|
1906
|
+
// --- Eraser tool ---
|
|
1907
|
+
/**
|
|
1908
|
+
* Start an eraser stroke at `world`, seeding it with the shape under it so a
|
|
1909
|
+
* plain click erases. With `restore` (Alt held at press) it seeds nothing —
|
|
1910
|
+
* the gesture is in un-mark mode, and there's nothing marked yet to rescue.
|
|
1911
|
+
*/
|
|
1912
|
+
beginEraseStroke(world, restore = false, strokeErase = false) {
|
|
1913
|
+
const stroke = beginEraseStrokePure(world, strokeErase);
|
|
1914
|
+
if (!restore) {
|
|
1915
|
+
const hit = this.acceleratedElementAt(world);
|
|
1916
|
+
// In stroke-erase mode brushes are cut by the path, not object-deleted —
|
|
1917
|
+
// don't seed a brush into `pending`.
|
|
1918
|
+
if (hit && !(strokeErase && hit.type === "brush"))
|
|
1919
|
+
stroke.pending.add(hit.id);
|
|
1920
|
+
}
|
|
1921
|
+
this.eraseStroke = stroke;
|
|
1922
|
+
// The initial seed / cut changes the marked set → the first frame must fully
|
|
1923
|
+
// repaint so the dim / cut preview shows.
|
|
1924
|
+
this.eraseDirty = true;
|
|
1925
|
+
// Stroke mode: mark brush points under the press point (degenerate segment)
|
|
1926
|
+
// so a click still cuts.
|
|
1927
|
+
if (strokeErase)
|
|
1928
|
+
this.markStrokeEraseSegment(stroke, world, world);
|
|
1929
|
+
// Start a fading eraser trail (a fresh array so the render-overlay memo
|
|
1930
|
+
// rebuilds this frame — same reasoning as `beginLaserStroke`).
|
|
1931
|
+
this.interaction.eraserTrail = [beginLaserStrokePure(world, nowMs())];
|
|
1932
|
+
this.maybeAnimate();
|
|
1933
|
+
this.notify();
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* Incrementally mark the brush points erased by the eraser segment `a → b`
|
|
1937
|
+
* (world). Iterates the current brushes and grows `stroke.erased` in place —
|
|
1938
|
+
* O(points) per move (each already-erased point is skipped), so a long drag
|
|
1939
|
+
* no longer costs O(points × path length) per frame. Radius is the on-screen
|
|
1940
|
+
* eraser ring converted to world units.
|
|
1941
|
+
*/
|
|
1942
|
+
markStrokeEraseSegment(stroke, a, b) {
|
|
1943
|
+
const zoom = this._scene.viewport.zoom || 1;
|
|
1944
|
+
const radius = this._brushSettings.width / zoom;
|
|
1945
|
+
let changed = false;
|
|
1946
|
+
for (const el of this._scene.elements.values()) {
|
|
1947
|
+
if (!isBrush(el))
|
|
1948
|
+
continue;
|
|
1949
|
+
const existing = stroke.erased.get(el.id) ?? [];
|
|
1950
|
+
const merged = markErasedIntervals(el, existing, a, b, radius);
|
|
1951
|
+
// Grew the covered span (or first coverage of a single-point brush).
|
|
1952
|
+
if (merged.length > existing.length ||
|
|
1953
|
+
coveredLength(merged) > coveredLength(existing) + 1e-6) {
|
|
1954
|
+
stroke.erased.set(el.id, merged);
|
|
1955
|
+
changed = true;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
return changed;
|
|
1959
|
+
}
|
|
1960
|
+
/**
|
|
1961
|
+
* Extend the eraser stroke to `world`, sweeping shapes along the segment.
|
|
1962
|
+
* `restore` (Alt held) un-marks swept shapes instead of marking them.
|
|
1963
|
+
*/
|
|
1964
|
+
extendEraseStroke(world, restore = false) {
|
|
1965
|
+
const stroke = this.eraseStroke;
|
|
1966
|
+
if (!stroke)
|
|
1967
|
+
return;
|
|
1968
|
+
const changed = sampleErasePure(stroke.last, world, (p) => this.acceleratedElementAt(p), stroke.pending, restore, stroke.strokeMode);
|
|
1969
|
+
// Incrementally cut brush points along the new segment (stroke mode only).
|
|
1970
|
+
const cut = stroke.strokeMode ? this.markStrokeEraseSegment(stroke, stroke.last, world) : false;
|
|
1971
|
+
// Only a frame that actually changed the marked / cut set needs the forced
|
|
1972
|
+
// full repaint (see `eraseDirty`); a move over already-covered area doesn't.
|
|
1973
|
+
if (changed || cut)
|
|
1974
|
+
this.eraseDirty = true;
|
|
1975
|
+
stroke.last = world;
|
|
1976
|
+
// Grow the fading trail alongside the sweep. Reassign the array reference
|
|
1977
|
+
// (like the laser) so the overlay memo repaints the trail on this move. If
|
|
1978
|
+
// the trail had faded to empty (a pause with the button held), start a fresh
|
|
1979
|
+
// one — otherwise resuming the drag would leave `eraserTrail` empty.
|
|
1980
|
+
const trail = this.interaction.eraserTrail;
|
|
1981
|
+
const active = trail[trail.length - 1];
|
|
1982
|
+
if (active) {
|
|
1983
|
+
extendLaserStrokePure(active, world, nowMs());
|
|
1984
|
+
this.interaction.eraserTrail = trail.slice();
|
|
1985
|
+
}
|
|
1986
|
+
else {
|
|
1987
|
+
this.interaction.eraserTrail = [beginLaserStrokePure(world, nowMs())];
|
|
1988
|
+
}
|
|
1989
|
+
this.maybeAnimate();
|
|
1990
|
+
// Always repaint: the cursor ring follows the pointer every move regardless
|
|
1991
|
+
// of whether anything was marked / cut. Cheap now — a frame that changes
|
|
1992
|
+
// nothing skips the full main pass (see `eraseDirty`) and only redraws the
|
|
1993
|
+
// overlay cursor / trail.
|
|
1994
|
+
this.notify();
|
|
1995
|
+
}
|
|
1996
|
+
/**
|
|
1997
|
+
* Commit the eraser stroke — delete every swept shape in ONE undo step (with
|
|
1998
|
+
* their attached links). No-op delete when nothing was swept. Returns the
|
|
1999
|
+
* count removed.
|
|
2000
|
+
*/
|
|
2001
|
+
commitEraseStroke() {
|
|
2002
|
+
const stroke = this.eraseStroke;
|
|
2003
|
+
if (!stroke)
|
|
2004
|
+
return 0;
|
|
2005
|
+
this.eraseStroke = null;
|
|
2006
|
+
// Object-erase part: delete every swept (non-brush, in stroke mode) shape.
|
|
2007
|
+
const objectResult = computeEraseCommit(this._scene, stroke.pending);
|
|
2008
|
+
let scene = objectResult ? objectResult.scene : this._scene;
|
|
2009
|
+
const patches = objectResult ? [...objectResult.patches] : [];
|
|
2010
|
+
// Stroke-erase part (Shift): cut every brush with erased points (accumulated
|
|
2011
|
+
// incrementally during the drag) into fragments.
|
|
2012
|
+
const removedBrushIds = [];
|
|
2013
|
+
if (stroke.strokeMode) {
|
|
2014
|
+
const strokeResult = computeEraseFromMasks(scene, stroke.erased, () => newBrushId(++this.nextId));
|
|
2015
|
+
if (strokeResult) {
|
|
2016
|
+
scene = strokeResult.scene;
|
|
2017
|
+
patches.push(...strokeResult.patches);
|
|
2018
|
+
removedBrushIds.push(...strokeResult.removedIds);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
if (patches.length === 0) {
|
|
2022
|
+
this.notify();
|
|
2023
|
+
return 0;
|
|
2024
|
+
}
|
|
2025
|
+
// Fold the object-deletes and brush cuts into ONE undo step.
|
|
2026
|
+
const tx = this._history.transaction();
|
|
2027
|
+
this._scene = scene;
|
|
2028
|
+
for (const patch of patches)
|
|
2029
|
+
tx.add(patch);
|
|
2030
|
+
tx.commit();
|
|
2031
|
+
// Drop any erased ids from the live selection so no stale handle lingers.
|
|
2032
|
+
let sel = this._selection;
|
|
2033
|
+
for (const id of stroke.pending)
|
|
2034
|
+
sel = Selection.remove(sel, id);
|
|
2035
|
+
for (const id of removedBrushIds)
|
|
2036
|
+
sel = Selection.remove(sel, id);
|
|
2037
|
+
this._selection = sel;
|
|
2038
|
+
this.notify();
|
|
2039
|
+
return stroke.pending.size + removedBrushIds.length;
|
|
2040
|
+
}
|
|
2041
|
+
/** Abort the eraser stroke without deleting anything. */
|
|
2042
|
+
cancelEraseStroke() {
|
|
2043
|
+
if (!this.eraseStroke)
|
|
2044
|
+
return;
|
|
2045
|
+
this.eraseStroke = null;
|
|
2046
|
+
this.notify();
|
|
2047
|
+
}
|
|
2048
|
+
// --- Laser pointer ---
|
|
2049
|
+
/** True only while the pointer is down in laser mode (a trail is being laid). */
|
|
2050
|
+
get laserDrawing() {
|
|
2051
|
+
return this.interaction.laserDrawing;
|
|
2052
|
+
}
|
|
2053
|
+
/** Start a laser trail at `world` (ephemeral — never enters the scene). */
|
|
2054
|
+
beginLaserStroke(world) {
|
|
2055
|
+
// Reassign the array (not just `.push`) so its identity changes: the
|
|
2056
|
+
// render-overlay memo keys on the `laserStrokes` reference, so an in-place
|
|
2057
|
+
// mutation would leave the signature unchanged and the memo would reuse a
|
|
2058
|
+
// stale options bag that omits the trail — the trail then wouldn't paint
|
|
2059
|
+
// until a later prune reallocated the array (~TTL later). A fresh reference
|
|
2060
|
+
// forces the memo to rebuild and the trail to render on this very frame.
|
|
2061
|
+
this.interaction.laserStrokes = [
|
|
2062
|
+
...this.interaction.laserStrokes,
|
|
2063
|
+
beginLaserStrokePure(world, nowMs()),
|
|
2064
|
+
];
|
|
2065
|
+
this.interaction.laserDrawing = true;
|
|
2066
|
+
this.maybeAnimate();
|
|
2067
|
+
this.notify();
|
|
2068
|
+
}
|
|
2069
|
+
/** Append a point to the active laser trail (no-op unless drawing). */
|
|
2070
|
+
extendLaserStroke(world) {
|
|
2071
|
+
if (!this.interaction.laserDrawing)
|
|
2072
|
+
return;
|
|
2073
|
+
const strokes = this.interaction.laserStrokes;
|
|
2074
|
+
const active = strokes[strokes.length - 1];
|
|
2075
|
+
if (!active)
|
|
2076
|
+
return;
|
|
2077
|
+
extendLaserStrokePure(active, world, nowMs());
|
|
2078
|
+
// Fresh array reference (same reasoning as `beginLaserStroke`) so the
|
|
2079
|
+
// overlay memo rebuilds and repaints the growing trail on THIS move,
|
|
2080
|
+
// instead of waiting for the animation loop to happen to reallocate it.
|
|
2081
|
+
this.interaction.laserStrokes = strokes.slice();
|
|
2082
|
+
this.maybeAnimate();
|
|
2083
|
+
this.notify();
|
|
2084
|
+
}
|
|
2085
|
+
/** End the active laser trail — it keeps fading via the animation tick. */
|
|
2086
|
+
endLaserStroke() {
|
|
2087
|
+
this.interaction.laserDrawing = false;
|
|
2088
|
+
// The stroke is already ephemeral and the tick prunes it; re-arm in case
|
|
2089
|
+
// the tick wasn't running.
|
|
2090
|
+
this.maybeAnimate();
|
|
2091
|
+
}
|
|
2092
|
+
/**
|
|
2093
|
+
* True while any laser trail OR eraser trail still has visible points (drives
|
|
2094
|
+
* the fade tick — so the trail keeps melting after the pointer stops).
|
|
2095
|
+
*/
|
|
2096
|
+
hasActiveLaser() {
|
|
2097
|
+
return this.interaction.laserStrokes.length > 0 || this.interaction.eraserTrail.length > 0;
|
|
2098
|
+
}
|
|
2099
|
+
/** Live eraser drag trail (ephemeral, fading). Empty when none active. */
|
|
2100
|
+
get eraserTrail() {
|
|
2101
|
+
return this.interaction.eraserTrail;
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* Drop expired laser/eraser trail points (called once per frame before paint).
|
|
2105
|
+
* Self-terminating: once both arrays empty the animation tick stops.
|
|
2106
|
+
*/
|
|
2107
|
+
pruneLaser() {
|
|
2108
|
+
const strokes = this.interaction.laserStrokes;
|
|
2109
|
+
if (strokes.length > 0) {
|
|
2110
|
+
const r = pruneLaserStrokes(strokes, nowMs());
|
|
2111
|
+
if (r.changed)
|
|
2112
|
+
this.interaction.laserStrokes = r.strokes;
|
|
2113
|
+
}
|
|
2114
|
+
const trail = this.interaction.eraserTrail;
|
|
2115
|
+
if (trail.length > 0) {
|
|
2116
|
+
// Prune at the eraser's own (shorter) TTL so points don't linger in the
|
|
2117
|
+
// array long after they've faded to invisible.
|
|
2118
|
+
const r = pruneLaserStrokes(trail, nowMs(), ERASER_TRAIL_TTL_MS);
|
|
2119
|
+
if (r.changed)
|
|
2120
|
+
this.interaction.eraserTrail = r.strokes;
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
1803
2123
|
arrangeAsGrid(opts = {}) {
|
|
1804
2124
|
const origin = this.combinedSelectionBounds() ?? { x: 0, y: 0 };
|
|
1805
2125
|
const result = computeArrangeAsGrid(this._scene, this._selection, opts, origin);
|
|
@@ -1821,6 +2141,8 @@ export class Editor {
|
|
|
1821
2141
|
this.announce(`Stacked ${result.count} shapes ${result.direction}`);
|
|
1822
2142
|
}
|
|
1823
2143
|
groupSelected() {
|
|
2144
|
+
if (this.readOnly)
|
|
2145
|
+
return { kind: "noop" };
|
|
1824
2146
|
const result = computeGroupSelected(this._scene, this._selection, newGroupElementId(++this.nextId));
|
|
1825
2147
|
if (!result)
|
|
1826
2148
|
return { kind: "noop" };
|
|
@@ -1834,6 +2156,8 @@ export class Editor {
|
|
|
1834
2156
|
return { kind: "grouped", groupId: result.groupId };
|
|
1835
2157
|
}
|
|
1836
2158
|
ungroup() {
|
|
2159
|
+
if (this.readOnly)
|
|
2160
|
+
return;
|
|
1837
2161
|
const result = computeUngroup(this._scene, this._selection);
|
|
1838
2162
|
if (!result)
|
|
1839
2163
|
return;
|
|
@@ -1870,22 +2194,24 @@ export class Editor {
|
|
|
1870
2194
|
// scene to exactly where it was (cancelling the history tx alone wouldn't).
|
|
1871
2195
|
this.cancelGesture();
|
|
1872
2196
|
this.actor.send({ type: "POINTER_CANCEL" });
|
|
1873
|
-
this.
|
|
1874
|
-
this.edgePreview = null;
|
|
1875
|
-
this.lassoPreview = null;
|
|
2197
|
+
this.interaction.resetPreviews();
|
|
1876
2198
|
// Abort a host-managed link-from-anchor gesture too — it lives outside
|
|
1877
2199
|
// the machine, so POINTER_CANCEL above doesn't touch it. Without this a
|
|
1878
2200
|
// gesture left mid-flight would keep its preview after Escape.
|
|
1879
|
-
this.linkDragFromAnchor = null;
|
|
1880
|
-
this.
|
|
1881
|
-
this.
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
this.
|
|
1886
|
-
|
|
1887
|
-
//
|
|
1888
|
-
this.
|
|
2201
|
+
this.interaction.linkDragFromAnchor = null;
|
|
2202
|
+
this.interaction.editingLinkCaption = null;
|
|
2203
|
+
this.interaction.pendingLinkDropMenu = null;
|
|
2204
|
+
// Abort an in-progress eraser stroke (nothing deleted) and stop laying a
|
|
2205
|
+
// laser trail — both live outside the machine. Existing laser and eraser
|
|
2206
|
+
// trails keep fading via the tick (not hard-cleared here).
|
|
2207
|
+
this.interaction.eraseStroke = null;
|
|
2208
|
+
this.interaction.laserDrawing = false;
|
|
2209
|
+
// Drop any pending flowchart-create preview (Esc / global cancel abandons it).
|
|
2210
|
+
this.flowchartSession = null;
|
|
2211
|
+
// Waypoint / segment / endpoint-rebind drags: gestureTx.cancel above
|
|
2212
|
+
// already reverted the live re-point; just drop the handle-drag state so
|
|
2213
|
+
// the dots stop tracking.
|
|
2214
|
+
this.linkHandles.reset();
|
|
1889
2215
|
// Esc exits group-isolation if active. The selection that was
|
|
1890
2216
|
// active inside the group is dropped (Esc reads as a full
|
|
1891
2217
|
// "back out" — selecting the group is a separate gesture).
|
|
@@ -1902,6 +2228,8 @@ export class Editor {
|
|
|
1902
2228
|
* Links between selected shapes are NOT cloned. Single undo step.
|
|
1903
2229
|
*/
|
|
1904
2230
|
duplicateSelected() {
|
|
2231
|
+
if (this.readOnly)
|
|
2232
|
+
return;
|
|
1905
2233
|
const result = computeDuplicateSelection(this._scene, this._selection, () => ++this.nextId);
|
|
1906
2234
|
if (!result)
|
|
1907
2235
|
return;
|
|
@@ -1924,6 +2252,8 @@ export class Editor {
|
|
|
1924
2252
|
* originals. One undo step.
|
|
1925
2253
|
*/
|
|
1926
2254
|
duplicateSelectedInPlace(anchorId = null) {
|
|
2255
|
+
if (this.readOnly)
|
|
2256
|
+
return null;
|
|
1927
2257
|
if (this._selection.size === 0)
|
|
1928
2258
|
return null;
|
|
1929
2259
|
// Expand: selection + group descendants (parentId) + frame members (frameId).
|
|
@@ -1970,6 +2300,23 @@ export class Editor {
|
|
|
1970
2300
|
this._selectedLinks = LinkSelection.EMPTY;
|
|
1971
2301
|
this.notify();
|
|
1972
2302
|
}
|
|
2303
|
+
/**
|
|
2304
|
+
* Programmatically select a single link by id (or clear the link
|
|
2305
|
+
* selection with `null`), clearing the element selection so the link
|
|
2306
|
+
* becomes the sole selection. Used by host navigation (search / jump-to)
|
|
2307
|
+
* to frame an edge with {@link zoomToSelection}. No-op when nothing
|
|
2308
|
+
* would change.
|
|
2309
|
+
*/
|
|
2310
|
+
selectLink(id) {
|
|
2311
|
+
const nextLinks = id === null ? LinkSelection.EMPTY : LinkSelection.single(id);
|
|
2312
|
+
const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
|
|
2313
|
+
const elementsChanged = this._selection.size > 0;
|
|
2314
|
+
if (!linksChanged && !elementsChanged)
|
|
2315
|
+
return;
|
|
2316
|
+
this._selection = Selection.EMPTY;
|
|
2317
|
+
this._selectedLinks = nextLinks;
|
|
2318
|
+
this.notify();
|
|
2319
|
+
}
|
|
1973
2320
|
selectAll() {
|
|
1974
2321
|
const next = computeSelectAll(this._scene, this._selection);
|
|
1975
2322
|
const nextLinks = computeSelectAllLinks(this._scene);
|
|
@@ -2000,6 +2347,8 @@ export class Editor {
|
|
|
2000
2347
|
this.announce(`Copied ${out.length} shapes`);
|
|
2001
2348
|
}
|
|
2002
2349
|
cutSelected() {
|
|
2350
|
+
if (this.readOnly)
|
|
2351
|
+
return;
|
|
2003
2352
|
this.copySelected();
|
|
2004
2353
|
this.deleteSelected();
|
|
2005
2354
|
}
|
|
@@ -2061,6 +2410,8 @@ export class Editor {
|
|
|
2061
2410
|
return this.styleClipboard !== null;
|
|
2062
2411
|
}
|
|
2063
2412
|
updateStyle(ids, partial) {
|
|
2413
|
+
if (this.readOnly)
|
|
2414
|
+
return;
|
|
2064
2415
|
const result = computeUpdateStyle(this._scene, ids, partial);
|
|
2065
2416
|
if (!result)
|
|
2066
2417
|
return;
|
|
@@ -2068,12 +2419,471 @@ export class Editor {
|
|
|
2068
2419
|
this._history.push(result.patch);
|
|
2069
2420
|
this.notify();
|
|
2070
2421
|
}
|
|
2422
|
+
/**
|
|
2423
|
+
* Re-base the width of committed brush strokes (`style.strokeWidth` has no
|
|
2424
|
+
* effect on brushes — their widths are baked per point). Scales every baked
|
|
2425
|
+
* point width proportionally and records the new `baseWidth`, keeping the
|
|
2426
|
+
* stroke's pressure profile. One undo step. Read-only editors ignore it.
|
|
2427
|
+
*/
|
|
2428
|
+
setBrushWidth(ids, width) {
|
|
2429
|
+
if (this.readOnly)
|
|
2430
|
+
return;
|
|
2431
|
+
const result = computeSetBrushWidth(this._scene, ids, width);
|
|
2432
|
+
if (!result)
|
|
2433
|
+
return;
|
|
2434
|
+
this._scene = result.scene;
|
|
2435
|
+
this._history.push(result.patch);
|
|
2436
|
+
this.notify();
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2439
|
+
* Apply a partial text style (bold / italic / colour / decoration) to the
|
|
2440
|
+
* character range `[from, to)` of a single text element, producing styled
|
|
2441
|
+
* runs (rich text). One undo step. No-op when the id isn't a text shape or
|
|
2442
|
+
* the range is empty. Read-only editors ignore it. Use this — rather than
|
|
2443
|
+
* `updateStyle` — to style only PART of a text block (e.g. the current
|
|
2444
|
+
* inline-edit selection).
|
|
2445
|
+
*/
|
|
2446
|
+
applyTextStyleToRange(id, from, to, partial) {
|
|
2447
|
+
if (this.readOnly)
|
|
2448
|
+
return;
|
|
2449
|
+
const result = computeApplyTextRunStyle(this._scene, id, from, to, partial);
|
|
2450
|
+
if (!result)
|
|
2451
|
+
return;
|
|
2452
|
+
this._scene = result.scene;
|
|
2453
|
+
this._history.push(result.patch);
|
|
2454
|
+
this.notify();
|
|
2455
|
+
}
|
|
2456
|
+
// --- F8: Eyedropper ---------------------------------------------------
|
|
2457
|
+
/**
|
|
2458
|
+
* The fill (or stroke, per `role`) colour of the top-most shape under the
|
|
2459
|
+
* world point, or `null` on empty canvas. Pure read — no mutation.
|
|
2460
|
+
*/
|
|
2461
|
+
pickColorAt(worldPoint, role = "fill") {
|
|
2462
|
+
return pickColorAt(this._scene, worldPoint, role);
|
|
2463
|
+
}
|
|
2464
|
+
/** One-shot callback armed by {@link beginEyedropperPick}; consumes the next canvas click. */
|
|
2465
|
+
pendingEyedropperPick = null;
|
|
2466
|
+
/** `true` while a colour-picker pipette is armed and waiting for a canvas click. */
|
|
2467
|
+
get isEyedropperArmed() {
|
|
2468
|
+
return this.pendingEyedropperPick !== null;
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* Arm the eyedropper for a one-shot canvas pick that routes the sampled colour
|
|
2472
|
+
* to `onPick` (e.g. a colour-picker swatch) instead of the selection fill. Does
|
|
2473
|
+
* NOT change the tool mode — the next canvas press is intercepted by
|
|
2474
|
+
* {@link applyEyedropperAt}. Cancelled by a mode switch or an empty-canvas click.
|
|
2475
|
+
*/
|
|
2476
|
+
beginEyedropperPick(onPick) {
|
|
2477
|
+
this.pendingEyedropperPick = onPick;
|
|
2478
|
+
this.refreshCursor();
|
|
2479
|
+
this.notify();
|
|
2480
|
+
}
|
|
2481
|
+
/**
|
|
2482
|
+
* Sample the colour under `worldPoint`. When a pipette pick is armed (see
|
|
2483
|
+
* {@link beginEyedropperPick}), route the colour to that callback and disarm.
|
|
2484
|
+
* Otherwise apply it as the current selection's fill (programmatic path).
|
|
2485
|
+
* Returns the sampled colour, or `null` on empty canvas. Read-only editors
|
|
2486
|
+
* sample but don't mutate.
|
|
2487
|
+
*/
|
|
2488
|
+
applyEyedropperAt(worldPoint) {
|
|
2489
|
+
const color = pickColorAt(this._scene, worldPoint, "fill");
|
|
2490
|
+
const pick = this.pendingEyedropperPick;
|
|
2491
|
+
if (pick !== null) {
|
|
2492
|
+
this.pendingEyedropperPick = null;
|
|
2493
|
+
if (color !== null)
|
|
2494
|
+
pick(color);
|
|
2495
|
+
this.refreshCursor();
|
|
2496
|
+
this.notify();
|
|
2497
|
+
return color;
|
|
2498
|
+
}
|
|
2499
|
+
if (color === null)
|
|
2500
|
+
return null;
|
|
2501
|
+
if (!this.readOnly && this._selection.size > 0) {
|
|
2502
|
+
this.updateStyle(this._selection, { fill: color });
|
|
2503
|
+
}
|
|
2504
|
+
return color;
|
|
2505
|
+
}
|
|
2506
|
+
// --- F9: Convert element type ----------------------------------------
|
|
2507
|
+
/**
|
|
2508
|
+
* Convert every convertible selected shape (rectangle / ellipse / diamond)
|
|
2509
|
+
* to `target`, preserving position, size and style. One undo step; no-op
|
|
2510
|
+
* when nothing applies. See {@link ConvertTarget}.
|
|
2511
|
+
*/
|
|
2512
|
+
convertSelection(target) {
|
|
2513
|
+
if (this.readOnly)
|
|
2514
|
+
return;
|
|
2515
|
+
const result = computeConvertType(this._scene, this._selection, target);
|
|
2516
|
+
if (!result)
|
|
2517
|
+
return;
|
|
2518
|
+
this._scene = result.scene;
|
|
2519
|
+
this._history.push(result.patch);
|
|
2520
|
+
this.notify();
|
|
2521
|
+
}
|
|
2522
|
+
// --- F11: Spawn connected node ---------------------------------------
|
|
2523
|
+
/**
|
|
2524
|
+
* Flowchart auto-generate: clone the single selected node offset in
|
|
2525
|
+
* `direction` and connect the two with a fresh link. Selects the new node.
|
|
2526
|
+
* No-op unless exactly one element is selected. One undo step.
|
|
2527
|
+
*/
|
|
2528
|
+
spawnConnectedNode(direction) {
|
|
2529
|
+
if (this.readOnly)
|
|
2530
|
+
return;
|
|
2531
|
+
const ids = [...this._selection];
|
|
2532
|
+
const sourceId = ids.length === 1 ? ids[0] : undefined;
|
|
2533
|
+
if (sourceId === undefined)
|
|
2534
|
+
return;
|
|
2535
|
+
const result = computeSpawnConnectedNode(this._scene, sourceId, direction, newElementId(++this.nextId), newLinkId(++this.nextId));
|
|
2536
|
+
if (!result)
|
|
2537
|
+
return;
|
|
2538
|
+
this._scene = result.scene;
|
|
2539
|
+
this._history.push({ kind: "batch", patches: [...result.patches] });
|
|
2540
|
+
this.setSelection([result.newElementId]);
|
|
2541
|
+
this.notify();
|
|
2542
|
+
}
|
|
2543
|
+
// --- Flowchart CREATE session (Cmd/Ctrl+Arrow, Excalidraw-style) ------
|
|
2544
|
+
/**
|
|
2545
|
+
* Pending flowchart-create session, or `null` when idle. Holds the ORIGINAL
|
|
2546
|
+
* source id + direction, the current sibling `count`, and the pending
|
|
2547
|
+
* `elements` + `links` (a PREVIEW — not yet in the scene / history). Grown by
|
|
2548
|
+
* {@link growFlowchart}, committed by {@link commitFlowchart}, discarded by
|
|
2549
|
+
* {@link cancelFlowchart}.
|
|
2550
|
+
*/
|
|
2551
|
+
flowchartSession = null;
|
|
2552
|
+
/**
|
|
2553
|
+
* Grow the flowchart-create preview one step in `direction`. Starts a session
|
|
2554
|
+
* (count = 1) when idle or when the direction changes; otherwise bumps the
|
|
2555
|
+
* sibling count up to {@link FLOWCHART_MAX_SIBLINGS}. Recomputes the pending
|
|
2556
|
+
* nodes/links from the ORIGINAL source each call. PREVIEW ONLY — never
|
|
2557
|
+
* touches the scene or history until {@link commitFlowchart}. No-op in
|
|
2558
|
+
* read-only mode or unless exactly one element is selected.
|
|
2559
|
+
*/
|
|
2560
|
+
growFlowchart(direction) {
|
|
2561
|
+
if (this._readOnly)
|
|
2562
|
+
return;
|
|
2563
|
+
if (this._selection.size !== 1)
|
|
2564
|
+
return;
|
|
2565
|
+
const ids = [...this._selection];
|
|
2566
|
+
const sourceId = ids[0];
|
|
2567
|
+
if (sourceId === undefined || getElement(this._scene, sourceId) === undefined)
|
|
2568
|
+
return;
|
|
2569
|
+
const session = this.flowchartSession;
|
|
2570
|
+
const count = session?.direction === direction && session.sourceId === sourceId
|
|
2571
|
+
? Math.min(session.count + 1, FLOWCHART_MAX_SIBLINGS)
|
|
2572
|
+
: 1;
|
|
2573
|
+
const { elements, links } = computeSpawnConnectedNodes(this._scene, sourceId, direction, count, () => newElementId(++this.nextId), () => newLinkId(++this.nextId));
|
|
2574
|
+
this.flowchartSession = { sourceId, direction, count, elements, links };
|
|
2575
|
+
this.notify();
|
|
2576
|
+
}
|
|
2577
|
+
/**
|
|
2578
|
+
* Commit the pending flowchart-create preview: add every pending node + link
|
|
2579
|
+
* to the scene as ONE undo step, select the first new node, clear the
|
|
2580
|
+
* session. Returns the first new node's id, or `null` when no session is
|
|
2581
|
+
* active.
|
|
2582
|
+
*/
|
|
2583
|
+
commitFlowchart() {
|
|
2584
|
+
const session = this.flowchartSession;
|
|
2585
|
+
if (session === null)
|
|
2586
|
+
return null;
|
|
2587
|
+
let s = this._scene;
|
|
2588
|
+
const patches = [];
|
|
2589
|
+
for (const el of session.elements) {
|
|
2590
|
+
const r = addElement(s, el);
|
|
2591
|
+
s = r.scene;
|
|
2592
|
+
patches.push(r.patch);
|
|
2593
|
+
}
|
|
2594
|
+
for (const link of session.links) {
|
|
2595
|
+
const r = addLink(s, link);
|
|
2596
|
+
s = r.scene;
|
|
2597
|
+
patches.push(r.patch);
|
|
2598
|
+
}
|
|
2599
|
+
this.flowchartSession = null;
|
|
2600
|
+
const first = session.elements[0]?.id ?? null;
|
|
2601
|
+
if (patches.length === 0) {
|
|
2602
|
+
this.notify();
|
|
2603
|
+
return first;
|
|
2604
|
+
}
|
|
2605
|
+
this._scene = s;
|
|
2606
|
+
this._history.push({ kind: "batch", patches });
|
|
2607
|
+
if (first !== null)
|
|
2608
|
+
this.setSelection([first]);
|
|
2609
|
+
this.notify();
|
|
2610
|
+
return first;
|
|
2611
|
+
}
|
|
2612
|
+
/** Discard the pending flowchart-create preview without committing. */
|
|
2613
|
+
cancelFlowchart() {
|
|
2614
|
+
if (this.flowchartSession === null)
|
|
2615
|
+
return;
|
|
2616
|
+
this.flowchartSession = null;
|
|
2617
|
+
this.notify();
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* The pending flowchart-create preview (nodes + links), or `null` when no
|
|
2621
|
+
* session is active. Read by the render snapshot to paint the preview on the
|
|
2622
|
+
* overlay. Reference-stable between renders (only changes on grow / commit /
|
|
2623
|
+
* cancel) so the overlay memo doesn't thrash.
|
|
2624
|
+
*/
|
|
2625
|
+
get flowchartPreview() {
|
|
2626
|
+
const session = this.flowchartSession;
|
|
2627
|
+
if (session === null)
|
|
2628
|
+
return null;
|
|
2629
|
+
return { elements: session.elements, links: session.links };
|
|
2630
|
+
}
|
|
2631
|
+
/**
|
|
2632
|
+
* Move the selection to an adjacent node. With exactly one element selected,
|
|
2633
|
+
* prefers a graph neighbour (linked node) best aligned with `direction`;
|
|
2634
|
+
* falls back to the spatial {@link selectClosest} when no neighbour lies that
|
|
2635
|
+
* way. No-op unless exactly one element is selected.
|
|
2636
|
+
*/
|
|
2637
|
+
navigateFlowchart(direction) {
|
|
2638
|
+
if (this._selection.size !== 1)
|
|
2639
|
+
return;
|
|
2640
|
+
const ids = [...this._selection];
|
|
2641
|
+
const sourceId = ids[0];
|
|
2642
|
+
if (sourceId === undefined)
|
|
2643
|
+
return;
|
|
2644
|
+
// Graph neighbours: every element linked to the source by any edge.
|
|
2645
|
+
const neighbours = new Set();
|
|
2646
|
+
for (const link of this._scene.links.values()) {
|
|
2647
|
+
const a = endpointElementId(link.from);
|
|
2648
|
+
const b = endpointElementId(link.to);
|
|
2649
|
+
if (a === sourceId && b !== undefined && b !== sourceId)
|
|
2650
|
+
neighbours.add(b);
|
|
2651
|
+
else if (b === sourceId && a !== undefined && a !== sourceId)
|
|
2652
|
+
neighbours.add(a);
|
|
2653
|
+
}
|
|
2654
|
+
if (neighbours.size > 0) {
|
|
2655
|
+
const ref = this.combinedSelectionBounds();
|
|
2656
|
+
const refCenter = ref
|
|
2657
|
+
? { x: ref.x + ref.width / 2, y: ref.y + ref.height / 2 }
|
|
2658
|
+
: { x: 0, y: 0 };
|
|
2659
|
+
const best = findClosestInDirection(this._scene, this._selection, direction, refCenter, (el) => this.isElementInteractable(el) && neighbours.has(el.id));
|
|
2660
|
+
if (best !== null) {
|
|
2661
|
+
this.setSelection([best]);
|
|
2662
|
+
return;
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
// No graph neighbour that way — fall back to spatial nearest.
|
|
2666
|
+
this.selectClosest(direction);
|
|
2667
|
+
}
|
|
2668
|
+
// --- F10: Image crop --------------------------------------------------
|
|
2669
|
+
/**
|
|
2670
|
+
* Live image-crop session, or `null` when not cropping. Excalidraw-style:
|
|
2671
|
+
* the crop frame IS the element's visible box, and the user drags edge /
|
|
2672
|
+
* corner handles inward (hides pixels) or the image body (pans the source).
|
|
2673
|
+
*
|
|
2674
|
+
* - `id` — the image being cropped.
|
|
2675
|
+
* - `crop` — pending normalised source rect.
|
|
2676
|
+
* - `position` / `width` / `height` — the pending element box (world position
|
|
2677
|
+
* + local size); a handle drag moves them, a body pan leaves them fixed.
|
|
2678
|
+
* - `drag` — the active gesture, or `null` when only hovering.
|
|
2679
|
+
* - `dragStartWorld` — pointer world position at drag start (body pan basis).
|
|
2680
|
+
*
|
|
2681
|
+
* Seeded on {@link beginImageCrop}; committed by {@link commitImageCrop} (one
|
|
2682
|
+
* undo step), abandoned by {@link cancelImageCrop}.
|
|
2683
|
+
*/
|
|
2684
|
+
cropSession = null;
|
|
2685
|
+
/** The image-crop session (read-only accessor for UI / overlay). */
|
|
2686
|
+
get imageCropSession() {
|
|
2687
|
+
const s = this.cropSession;
|
|
2688
|
+
if (s === null)
|
|
2689
|
+
return null;
|
|
2690
|
+
return { id: s.id, crop: s.crop, position: s.position, width: s.width, height: s.height };
|
|
2691
|
+
}
|
|
2692
|
+
/**
|
|
2693
|
+
* Enter crop mode for the image `id`, seeding the pending crop / box from its
|
|
2694
|
+
* current state (or the full image). No-op for non-image shapes or in
|
|
2695
|
+
* read-only. Typically triggered by a double-click on an image.
|
|
2696
|
+
*/
|
|
2697
|
+
beginImageCrop(id) {
|
|
2698
|
+
if (this.readOnly)
|
|
2699
|
+
return;
|
|
2700
|
+
const el = getElement(this._scene, id);
|
|
2701
|
+
if (el === undefined || !isImage(el))
|
|
2702
|
+
return;
|
|
2703
|
+
this.cancelInteraction();
|
|
2704
|
+
this._selection = Selection.single(id);
|
|
2705
|
+
this.cropSession = {
|
|
2706
|
+
id,
|
|
2707
|
+
crop: el.crop ?? FULL_CROP,
|
|
2708
|
+
position: el.position,
|
|
2709
|
+
width: el.width,
|
|
2710
|
+
height: el.height,
|
|
2711
|
+
drag: null,
|
|
2712
|
+
dragStartWorld: null,
|
|
2713
|
+
};
|
|
2714
|
+
this.setActiveTool("crop");
|
|
2715
|
+
this.refreshCursor();
|
|
2716
|
+
this.notify();
|
|
2717
|
+
}
|
|
2718
|
+
/**
|
|
2719
|
+
* Hit-test `worldPoint` against the pending crop chrome: a crop handle when
|
|
2720
|
+
* within {@link CROP_HANDLE_HIT_RADIUS} (screen px, zoom-compensated) of one,
|
|
2721
|
+
* `"body"` when inside the window, else `null`. Returns `null` when not
|
|
2722
|
+
* cropping.
|
|
2723
|
+
*/
|
|
2724
|
+
cropHandleAtWorld(worldPoint) {
|
|
2725
|
+
const session = this.cropSession;
|
|
2726
|
+
if (session === null)
|
|
2727
|
+
return null;
|
|
2728
|
+
const el = getElement(this._scene, session.id);
|
|
2729
|
+
if (el === undefined)
|
|
2730
|
+
return null;
|
|
2731
|
+
const pending = this.pendingCropElement(el);
|
|
2732
|
+
const points = cropHandleWorldPoints(pending);
|
|
2733
|
+
const zoom = this._scene.viewport.zoom || 1;
|
|
2734
|
+
const radius = CROP_HANDLE_HIT_RADIUS / zoom;
|
|
2735
|
+
for (const handle of CROP_HANDLES) {
|
|
2736
|
+
const p = points[handle];
|
|
2737
|
+
if (Math.hypot(worldPoint.x - p.x, worldPoint.y - p.y) <= radius)
|
|
2738
|
+
return handle;
|
|
2739
|
+
}
|
|
2740
|
+
const local = worldToLocal(pending, worldPoint);
|
|
2741
|
+
if (local.x >= 0 && local.x <= session.width && local.y >= 0 && local.y <= session.height) {
|
|
2742
|
+
return "body";
|
|
2743
|
+
}
|
|
2744
|
+
return null;
|
|
2745
|
+
}
|
|
2746
|
+
/** Begin dragging crop handle `handle` from `worldPoint`. */
|
|
2747
|
+
beginImageCropHandle(handle, worldPoint) {
|
|
2748
|
+
if (this.cropSession === null)
|
|
2749
|
+
return;
|
|
2750
|
+
this.cropSession = {
|
|
2751
|
+
...this.cropSession,
|
|
2752
|
+
drag: { kind: "handle", handle },
|
|
2753
|
+
dragStartWorld: worldPoint,
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
/** Begin panning the image body under the fixed window from `worldPoint`. */
|
|
2757
|
+
beginImageCropBody(worldPoint) {
|
|
2758
|
+
if (this.cropSession === null)
|
|
2759
|
+
return;
|
|
2760
|
+
this.cropSession = { ...this.cropSession, drag: { kind: "body" }, dragStartWorld: worldPoint };
|
|
2761
|
+
}
|
|
2762
|
+
/**
|
|
2763
|
+
* Update the active crop drag to `worldPoint` — resize the window (handle) or
|
|
2764
|
+
* pan the source (body). Geometry is recomputed from the ORIGINAL element so
|
|
2765
|
+
* it stays stable across many moves. No-op when no drag is active.
|
|
2766
|
+
*/
|
|
2767
|
+
updateImageCropDrag(worldPoint) {
|
|
2768
|
+
const session = this.cropSession;
|
|
2769
|
+
if (session?.drag == null || session.dragStartWorld === null)
|
|
2770
|
+
return;
|
|
2771
|
+
const el = getElement(this._scene, session.id);
|
|
2772
|
+
if (el === undefined)
|
|
2773
|
+
return;
|
|
2774
|
+
const baseCrop = el.crop ?? FULL_CROP;
|
|
2775
|
+
if (session.drag.kind === "handle") {
|
|
2776
|
+
const r = computeCropHandleDrag(el, baseCrop, session.drag.handle, worldPoint);
|
|
2777
|
+
this.cropSession = {
|
|
2778
|
+
...session,
|
|
2779
|
+
crop: r.crop,
|
|
2780
|
+
position: r.position,
|
|
2781
|
+
width: r.width,
|
|
2782
|
+
height: r.height,
|
|
2783
|
+
};
|
|
2784
|
+
}
|
|
2785
|
+
else {
|
|
2786
|
+
const r = computeCropBodyPan(el, baseCrop, session.dragStartWorld, worldPoint);
|
|
2787
|
+
this.cropSession = { ...session, crop: r.crop };
|
|
2788
|
+
}
|
|
2789
|
+
this.notify();
|
|
2790
|
+
}
|
|
2791
|
+
/** Finish the current crop drag (keeps the pending crop / box). */
|
|
2792
|
+
endImageCropDrag() {
|
|
2793
|
+
if (this.cropSession === null)
|
|
2794
|
+
return;
|
|
2795
|
+
this.cropSession = { ...this.cropSession, drag: null, dragStartWorld: null };
|
|
2796
|
+
}
|
|
2797
|
+
/** Apply the pending crop + box and leave crop mode. One undo step. */
|
|
2798
|
+
commitImageCrop() {
|
|
2799
|
+
const session = this.cropSession;
|
|
2800
|
+
if (session === null)
|
|
2801
|
+
return;
|
|
2802
|
+
const result = computeCommitImageCrop(this._scene, session.id, {
|
|
2803
|
+
crop: session.crop,
|
|
2804
|
+
position: session.position,
|
|
2805
|
+
width: session.width,
|
|
2806
|
+
height: session.height,
|
|
2807
|
+
});
|
|
2808
|
+
this.cropSession = null;
|
|
2809
|
+
if (result) {
|
|
2810
|
+
this._scene = result.scene;
|
|
2811
|
+
this._history.push(result.patch);
|
|
2812
|
+
}
|
|
2813
|
+
this.setActiveTool("select");
|
|
2814
|
+
this.refreshCursor();
|
|
2815
|
+
this.notify();
|
|
2816
|
+
}
|
|
2817
|
+
/** Abandon the crop session without changing the image. */
|
|
2818
|
+
cancelImageCrop() {
|
|
2819
|
+
if (this.cropSession === null)
|
|
2820
|
+
return;
|
|
2821
|
+
this.cropSession = null;
|
|
2822
|
+
this.setActiveTool("select");
|
|
2823
|
+
this.refreshCursor();
|
|
2824
|
+
this.notify();
|
|
2825
|
+
}
|
|
2826
|
+
/**
|
|
2827
|
+
* Synthetic element carrying the PENDING crop box (position / size) over the
|
|
2828
|
+
* original element's rotation / scale — the frame the user currently sees.
|
|
2829
|
+
* Used to project the crop frame and handles.
|
|
2830
|
+
*/
|
|
2831
|
+
pendingCropElement(el) {
|
|
2832
|
+
const session = this.cropSession;
|
|
2833
|
+
if (session === null)
|
|
2834
|
+
return el;
|
|
2835
|
+
return {
|
|
2836
|
+
...el,
|
|
2837
|
+
position: session.position,
|
|
2838
|
+
width: session.width,
|
|
2839
|
+
height: session.height,
|
|
2840
|
+
};
|
|
2841
|
+
}
|
|
2842
|
+
/**
|
|
2843
|
+
* World-space corners (clockwise) of the pending crop frame, or `null` when
|
|
2844
|
+
* not cropping. The frame is the pending element box mapped through its
|
|
2845
|
+
* local→world transform (so rotation / scale are honoured).
|
|
2846
|
+
*/
|
|
2847
|
+
cropFrameCorners() {
|
|
2848
|
+
const session = this.cropSession;
|
|
2849
|
+
if (session === null)
|
|
2850
|
+
return null;
|
|
2851
|
+
const el = getElement(this._scene, session.id);
|
|
2852
|
+
if (el === undefined)
|
|
2853
|
+
return null;
|
|
2854
|
+
const pending = this.pendingCropElement(el);
|
|
2855
|
+
const b = getElementLocalBounds(pending);
|
|
2856
|
+
return [
|
|
2857
|
+
{ x: b.x, y: b.y },
|
|
2858
|
+
{ x: b.x + b.width, y: b.y },
|
|
2859
|
+
{ x: b.x + b.width, y: b.y + b.height },
|
|
2860
|
+
{ x: b.x, y: b.y + b.height },
|
|
2861
|
+
].map((p) => localToWorld(pending, p));
|
|
2862
|
+
}
|
|
2863
|
+
/**
|
|
2864
|
+
* Ghost-image overlay descriptor for the crop session: the ORIGINAL element
|
|
2865
|
+
* (its transform + live bitmap handle) and the virtual full-image LOCAL rect
|
|
2866
|
+
* the whole bitmap occupies. `null` when not cropping. The overlay paints the
|
|
2867
|
+
* full bitmap faintly over this rect so hidden parts stay visible.
|
|
2868
|
+
*/
|
|
2869
|
+
cropGhost() {
|
|
2870
|
+
const session = this.cropSession;
|
|
2871
|
+
if (session === null)
|
|
2872
|
+
return null;
|
|
2873
|
+
const el = getElement(this._scene, session.id);
|
|
2874
|
+
if (el === undefined)
|
|
2875
|
+
return null;
|
|
2876
|
+
const baseCrop = el.crop ?? FULL_CROP;
|
|
2877
|
+
return { element: el, fullRect: cropFullImageLocalRect(el, baseCrop) };
|
|
2878
|
+
}
|
|
2071
2879
|
/**
|
|
2072
2880
|
* Update non-style text properties (`fontSize`, `fontFamily`,
|
|
2073
2881
|
* `maxWidth`) on every selected text shape. Non-text shapes are
|
|
2074
2882
|
* skipped. Single undo step. Used by the text contextual panel.
|
|
2075
2883
|
*/
|
|
2076
2884
|
updateTextProps(ids, partial) {
|
|
2885
|
+
if (this.readOnly)
|
|
2886
|
+
return;
|
|
2077
2887
|
const result = computeUpdateTextProps(this._scene, ids, partial);
|
|
2078
2888
|
if (!result)
|
|
2079
2889
|
return;
|
|
@@ -2087,6 +2897,8 @@ export class Editor {
|
|
|
2087
2897
|
* undoable step; no-op when no text is selected.
|
|
2088
2898
|
*/
|
|
2089
2899
|
adjustSelectionFontSize(direction) {
|
|
2900
|
+
if (this.readOnly)
|
|
2901
|
+
return;
|
|
2090
2902
|
const result = computeAdjustFontSize(this._scene, this._selection, direction);
|
|
2091
2903
|
if (!result)
|
|
2092
2904
|
return;
|
|
@@ -2103,6 +2915,8 @@ export class Editor {
|
|
|
2103
2915
|
* hover link-popup.
|
|
2104
2916
|
*/
|
|
2105
2917
|
setLink(ids, href) {
|
|
2918
|
+
if (this.readOnly)
|
|
2919
|
+
return;
|
|
2106
2920
|
const normalized = href === null ? null : normalizeHref(href);
|
|
2107
2921
|
const result = computeSetLink(this._scene, ids, normalized);
|
|
2108
2922
|
if (!result)
|
|
@@ -2141,6 +2955,8 @@ export class Editor {
|
|
|
2141
2955
|
return { id: shape.id, href, bounds: getElementWorldBounds(shape) };
|
|
2142
2956
|
}
|
|
2143
2957
|
bringToFront(id) {
|
|
2958
|
+
if (this.readOnly)
|
|
2959
|
+
return;
|
|
2144
2960
|
const result = computeBringToFront(this._scene, id, this._selection);
|
|
2145
2961
|
if (!result)
|
|
2146
2962
|
return;
|
|
@@ -2149,6 +2965,8 @@ export class Editor {
|
|
|
2149
2965
|
this.notify();
|
|
2150
2966
|
}
|
|
2151
2967
|
sendToBack(id) {
|
|
2968
|
+
if (this.readOnly)
|
|
2969
|
+
return;
|
|
2152
2970
|
const result = computeSendToBack(this._scene, id, this._selection);
|
|
2153
2971
|
if (!result)
|
|
2154
2972
|
return;
|
|
@@ -2158,6 +2976,8 @@ export class Editor {
|
|
|
2158
2976
|
}
|
|
2159
2977
|
/** Move the target shape one step toward the top of its layer. */
|
|
2160
2978
|
bringForward(id) {
|
|
2979
|
+
if (this.readOnly)
|
|
2980
|
+
return;
|
|
2161
2981
|
const result = computeBringForward(this._scene, id, this._selection);
|
|
2162
2982
|
if (!result)
|
|
2163
2983
|
return;
|
|
@@ -2167,6 +2987,8 @@ export class Editor {
|
|
|
2167
2987
|
}
|
|
2168
2988
|
/** Move the target shape one step toward the bottom of its layer. */
|
|
2169
2989
|
sendBackward(id) {
|
|
2990
|
+
if (this.readOnly)
|
|
2991
|
+
return;
|
|
2170
2992
|
const result = computeSendBackward(this._scene, id, this._selection);
|
|
2171
2993
|
if (!result)
|
|
2172
2994
|
return;
|
|
@@ -2220,6 +3042,8 @@ export class Editor {
|
|
|
2220
3042
|
}
|
|
2221
3043
|
/** Apply a batch of arrange patches as a single undoable step. */
|
|
2222
3044
|
commitArrange(patches) {
|
|
3045
|
+
if (this.readOnly)
|
|
3046
|
+
return;
|
|
2223
3047
|
if (patches.length === 0)
|
|
2224
3048
|
return;
|
|
2225
3049
|
const tx = this._history.transaction();
|
|
@@ -2254,6 +3078,8 @@ export class Editor {
|
|
|
2254
3078
|
* surprising and the operation is rarely chained with other edits.
|
|
2255
3079
|
*/
|
|
2256
3080
|
clear() {
|
|
3081
|
+
if (this.readOnly)
|
|
3082
|
+
return;
|
|
2257
3083
|
if (this._scene.elements.size === 0 && this._scene.links.size === 0)
|
|
2258
3084
|
return;
|
|
2259
3085
|
this._scene = {
|
|
@@ -2326,6 +3152,8 @@ export class Editor {
|
|
|
2326
3152
|
this.notify();
|
|
2327
3153
|
}
|
|
2328
3154
|
moveSelectionToLayer(targetLayer) {
|
|
3155
|
+
if (this.readOnly)
|
|
3156
|
+
return;
|
|
2329
3157
|
const result = computeMoveSelectionToLayer(this._scene, this._selection, targetLayer);
|
|
2330
3158
|
if (!result)
|
|
2331
3159
|
return;
|
|
@@ -2388,6 +3216,22 @@ export class Editor {
|
|
|
2388
3216
|
this._scene = next;
|
|
2389
3217
|
this.notify();
|
|
2390
3218
|
}
|
|
3219
|
+
/**
|
|
3220
|
+
* Center the camera on the current selection for a reveal / jump-to (search
|
|
3221
|
+
* navigation). Unlike {@link zoomToSelection}, it does NOT fill the screen —
|
|
3222
|
+
* a small match keeps its size and is merely centered; the zoom only drops to
|
|
3223
|
+
* fit an oversized match. No-op when the selection is empty.
|
|
3224
|
+
*/
|
|
3225
|
+
revealSelection(padding = 80) {
|
|
3226
|
+
const bounds = this.combinedSelectionBounds();
|
|
3227
|
+
if (!bounds)
|
|
3228
|
+
return;
|
|
3229
|
+
const next = computeRevealBounds(this._scene, bounds, padding);
|
|
3230
|
+
if (!next)
|
|
3231
|
+
return;
|
|
3232
|
+
this._scene = next;
|
|
3233
|
+
this.notify();
|
|
3234
|
+
}
|
|
2391
3235
|
/**
|
|
2392
3236
|
* Select the nearest interactable top-level element in `direction` from the
|
|
2393
3237
|
* current selection's centre (or the viewport centre when nothing is
|
|
@@ -2444,7 +3288,7 @@ export class Editor {
|
|
|
2444
3288
|
* of the modifier to this. Idempotent; never touches history.
|
|
2445
3289
|
*/
|
|
2446
3290
|
setSnapSuppressed(suppressed) {
|
|
2447
|
-
this.snapSuppressed = suppressed;
|
|
3291
|
+
this.interaction.snapSuppressed = suppressed;
|
|
2448
3292
|
}
|
|
2449
3293
|
/**
|
|
2450
3294
|
* Host hook: mirror the Alt / Shift modifier state so an in-flight resize or
|
|
@@ -2453,8 +3297,8 @@ export class Editor {
|
|
|
2453
3297
|
* keydown/keyup of the modifiers to this. Idempotent; never touches history.
|
|
2454
3298
|
*/
|
|
2455
3299
|
setTransformModifiers(mods) {
|
|
2456
|
-
this.transformAltKey = mods.alt;
|
|
2457
|
-
this.transformShiftKey = mods.shift;
|
|
3300
|
+
this.interaction.transformAltKey = mods.alt;
|
|
3301
|
+
this.interaction.transformShiftKey = mods.shift;
|
|
2458
3302
|
}
|
|
2459
3303
|
/**
|
|
2460
3304
|
* True when a gesture should snap. Snapping is coupled to grid display:
|
|
@@ -2464,7 +3308,7 @@ export class Editor {
|
|
|
2464
3308
|
*/
|
|
2465
3309
|
snapActive() {
|
|
2466
3310
|
const viewport = this._scene.viewport;
|
|
2467
|
-
return !this.snapSuppressed && viewport.gridEnabled && isSnapToGridEnabled(viewport);
|
|
3311
|
+
return (!this.interaction.snapSuppressed && viewport.gridEnabled && isSnapToGridEnabled(viewport));
|
|
2468
3312
|
}
|
|
2469
3313
|
/** World-unit spacing the current gesture snaps to. */
|
|
2470
3314
|
snapSpacing() {
|
|
@@ -2500,6 +3344,10 @@ export class Editor {
|
|
|
2500
3344
|
// Restore transient animationData (GIF bytes) from Scene.files
|
|
2501
3345
|
// before the tick so the animation adapter can decode frames.
|
|
2502
3346
|
animScene.rehydrateAnimatedImages(this);
|
|
3347
|
+
// Rebuild live handles for static images from Scene.files — their
|
|
3348
|
+
// serialised handle is gone and `src` is a dead `blob:` URL after a
|
|
3349
|
+
// reload. Async; repaints itself when the decode lands.
|
|
3350
|
+
void animScene.rehydrateStaticImages(this);
|
|
2503
3351
|
this.notify();
|
|
2504
3352
|
// Loaded scene may carry animated shapes (e.g. GIF re-imported
|
|
2505
3353
|
// from saved JSON). Re-arm the tick — `metadata.animated` survives
|
|
@@ -2625,6 +3473,10 @@ export class Editor {
|
|
|
2625
3473
|
return pickPressTarget(worldPoint, {
|
|
2626
3474
|
scene: this._scene,
|
|
2627
3475
|
selection: this._selection,
|
|
3476
|
+
// Selection chrome (resize / rotate / endpoint handles) is pressable
|
|
3477
|
+
// only under the select tool — a creation tool's press on a selected
|
|
3478
|
+
// shape must start the new element / link instead.
|
|
3479
|
+
selectionChromeActive: this.activeTool.type === "select",
|
|
2628
3480
|
selectedLink: this.selectedLink,
|
|
2629
3481
|
selectedLinkCount: this._selectedLinks.size,
|
|
2630
3482
|
enteredGroup: this._enteredGroup,
|
|
@@ -2718,6 +3570,8 @@ export class Editor {
|
|
|
2718
3570
|
* can't be moved or resized.
|
|
2719
3571
|
*/
|
|
2720
3572
|
toggleLockSelection() {
|
|
3573
|
+
if (this.readOnly)
|
|
3574
|
+
return;
|
|
2721
3575
|
if (this._selection.size === 0)
|
|
2722
3576
|
return;
|
|
2723
3577
|
const ids = [...this._selection];
|
|
@@ -2789,9 +3643,41 @@ export class Editor {
|
|
|
2789
3643
|
computeHiddenElements() {
|
|
2790
3644
|
return computeHiddenElementsPure(this._scene);
|
|
2791
3645
|
}
|
|
3646
|
+
/**
|
|
3647
|
+
* Live stroke-erase preview: while a Shift-held eraser gesture drags, the
|
|
3648
|
+
* fragments each touched brush WOULD become, plus the set of touched
|
|
3649
|
+
* originals to hide in the main pass. `null` outside a stroke-erase gesture
|
|
3650
|
+
* or when the path touches no brush. Recomputed each frame from the
|
|
3651
|
+
* path-so-far — never mutates the scene or history.
|
|
3652
|
+
*/
|
|
3653
|
+
computeStrokeErasePreview() {
|
|
3654
|
+
const stroke = this.eraseStroke;
|
|
3655
|
+
if (!stroke || !stroke.strokeMode || stroke.erased.size === 0)
|
|
3656
|
+
return null;
|
|
3657
|
+
const preview = computeStrokeErasePreviewFromMasks(this._scene, stroke.erased);
|
|
3658
|
+
if (!preview)
|
|
3659
|
+
return null;
|
|
3660
|
+
return { elements: preview.fragments, hidden: preview.hidden };
|
|
3661
|
+
}
|
|
2792
3662
|
computeDimElements(enteredGroupId) {
|
|
2793
3663
|
return computeDimElementsHelper(this._scene, this._selection, enteredGroupId);
|
|
2794
3664
|
}
|
|
3665
|
+
/**
|
|
3666
|
+
* Dim set fed to the renderer: group-isolation dim UNION the eraser's
|
|
3667
|
+
* pending-delete set (shapes swept by the current eraser stroke are shown
|
|
3668
|
+
* dimmed so the user sees what release will delete). `undefined` when neither
|
|
3669
|
+
* is active, keeping the fast tile-cache render path.
|
|
3670
|
+
*/
|
|
3671
|
+
computeDimSet() {
|
|
3672
|
+
const group = this._enteredGroup ? this.computeDimElements(this._enteredGroup) : undefined;
|
|
3673
|
+
const erase = this.interaction.eraseStroke?.pending;
|
|
3674
|
+
if (!erase || erase.size === 0)
|
|
3675
|
+
return group;
|
|
3676
|
+
const merged = new Set(group);
|
|
3677
|
+
for (const id of erase)
|
|
3678
|
+
merged.add(id);
|
|
3679
|
+
return merged;
|
|
3680
|
+
}
|
|
2795
3681
|
/**
|
|
2796
3682
|
* Enter a group — subsequent hits inside this group return children
|
|
2797
3683
|
* directly instead of the group root. `null` exits group-edit mode.
|
|
@@ -2915,11 +3801,11 @@ export class Editor {
|
|
|
2915
3801
|
*/
|
|
2916
3802
|
routeIsolationClick(clickEffect, worldPoint) {
|
|
2917
3803
|
const now = performance.now();
|
|
2918
|
-
const isDouble = now - this.lastClickAt < DOUBLE_CLICK_MS &&
|
|
2919
|
-
this.lastClickWorldPoint !== null &&
|
|
2920
|
-
vec2.distance(this.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
|
|
2921
|
-
this.lastClickAt = now;
|
|
2922
|
-
this.lastClickWorldPoint = worldPoint;
|
|
3804
|
+
const isDouble = now - this.interaction.lastClickAt < DOUBLE_CLICK_MS &&
|
|
3805
|
+
this.interaction.lastClickWorldPoint !== null &&
|
|
3806
|
+
vec2.distance(this.interaction.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
|
|
3807
|
+
this.interaction.lastClickAt = now;
|
|
3808
|
+
this.interaction.lastClickWorldPoint = worldPoint;
|
|
2923
3809
|
// Double-click the frame HEADER (label strip above the body) → rename.
|
|
2924
3810
|
// Checked before the clickEffect gate because the header sits outside
|
|
2925
3811
|
// the frame's hit-test bounds, so the click produces SELECT_CLEAR (or
|
|
@@ -3002,6 +3888,12 @@ export class Editor {
|
|
|
3002
3888
|
return pickDrillTargetHelper(this._scene, raw, top, this._enteredGroup);
|
|
3003
3889
|
}
|
|
3004
3890
|
applyEmit(emit) {
|
|
3891
|
+
// Read-only gate: in view mode every scene-mutating emit is dropped
|
|
3892
|
+
// (create / move / resize / rotate / annotation / edge edits + their
|
|
3893
|
+
// live previews). Selection + lasso emits fall through so a viewer can
|
|
3894
|
+
// still click / marquee-select and pan / zoom the document.
|
|
3895
|
+
if (this._readOnly && READ_ONLY_BLOCKED_EMITS.has(emit.type))
|
|
3896
|
+
return;
|
|
3005
3897
|
switch (emit.type) {
|
|
3006
3898
|
case "SELECT_REPLACE":
|
|
3007
3899
|
// Plain element click replaces the whole selection (elements + links).
|
|
@@ -3045,19 +3937,19 @@ export class Editor {
|
|
|
3045
3937
|
case "LASSO_PROGRESS":
|
|
3046
3938
|
// Capture the pre-lasso selection on the first progress emit
|
|
3047
3939
|
// of a gesture; subsequent emits use it as the additive base.
|
|
3048
|
-
this.lassoBaseSelection ??= this._selection;
|
|
3049
|
-
this.lassoBaseLinks ??= this._selectedLinks;
|
|
3940
|
+
this.interaction.lassoBaseSelection ??= this._selection;
|
|
3941
|
+
this.interaction.lassoBaseLinks ??= this._selectedLinks;
|
|
3050
3942
|
this.lassoPreview = emit.bounds;
|
|
3051
3943
|
this.applyLassoLiveSelection(emit.bounds, emit.mode);
|
|
3052
3944
|
this.notify();
|
|
3053
3945
|
return;
|
|
3054
3946
|
case "LASSO_CLEAR":
|
|
3055
3947
|
if (this.lassoPreview !== null ||
|
|
3056
|
-
this.lassoBaseSelection !== null ||
|
|
3057
|
-
this.lassoBaseLinks !== null) {
|
|
3948
|
+
this.interaction.lassoBaseSelection !== null ||
|
|
3949
|
+
this.interaction.lassoBaseLinks !== null) {
|
|
3058
3950
|
this.lassoPreview = null;
|
|
3059
|
-
this.lassoBaseSelection = null;
|
|
3060
|
-
this.lassoBaseLinks = null;
|
|
3951
|
+
this.interaction.lassoBaseSelection = null;
|
|
3952
|
+
this.interaction.lassoBaseLinks = null;
|
|
3061
3953
|
this.notify();
|
|
3062
3954
|
}
|
|
3063
3955
|
return;
|
|
@@ -3065,8 +3957,8 @@ export class Editor {
|
|
|
3065
3957
|
// Final commit — uses the same logic as the live preview so
|
|
3066
3958
|
// the visible selection matches what lands. Reset the base
|
|
3067
3959
|
// snapshot so the next gesture re-captures it.
|
|
3068
|
-
this.lassoBaseSelection = null;
|
|
3069
|
-
this.lassoBaseLinks = null;
|
|
3960
|
+
this.interaction.lassoBaseSelection = null;
|
|
3961
|
+
this.interaction.lassoBaseLinks = null;
|
|
3070
3962
|
this.applySelectByBounds(emit.bounds, emit.mode);
|
|
3071
3963
|
return;
|
|
3072
3964
|
case "MOVE_SHAPE":
|
|
@@ -3173,7 +4065,7 @@ export class Editor {
|
|
|
3173
4065
|
if (el && !this.isElementManipulable(el))
|
|
3174
4066
|
return;
|
|
3175
4067
|
// Shift constrains the drag to one axis before snapping.
|
|
3176
|
-
const moved = this.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
|
|
4068
|
+
const moved = this.interaction.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
|
|
3177
4069
|
const d = this.snapActive() ? snapMoveDelta(originalBounds, moved, this.snapSpacing()) : moved;
|
|
3178
4070
|
const patch = computeElementMovePatch(this._scene, id, d, originalBounds);
|
|
3179
4071
|
if (!patch)
|
|
@@ -3186,7 +4078,7 @@ export class Editor {
|
|
|
3186
4078
|
if (!this.groupMoveOrigin)
|
|
3187
4079
|
return;
|
|
3188
4080
|
// Shift constrains the drag to one axis before snapping.
|
|
3189
|
-
const moved = this.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
|
|
4081
|
+
const moved = this.interaction.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
|
|
3190
4082
|
const d = this.snapActive()
|
|
3191
4083
|
? snapGroupDelta(this.groupMoveOrigin, moved, this.snapSpacing())
|
|
3192
4084
|
: moved;
|
|
@@ -3261,7 +4153,14 @@ export class Editor {
|
|
|
3261
4153
|
this.drawingPreview !== null ||
|
|
3262
4154
|
this.edgePreview !== null ||
|
|
3263
4155
|
this.brushStroke !== null ||
|
|
3264
|
-
this.lassoPreview !== null
|
|
4156
|
+
this.lassoPreview !== null ||
|
|
4157
|
+
// Eraser sweep: marking / un-marking / cutting changes what's shown
|
|
4158
|
+
// WITHOUT mutating the scene, so the scene-diff dirty rect is empty and
|
|
4159
|
+
// the change would never repaint. Force a full repaint — but ONLY on the
|
|
4160
|
+
// frames that actually changed the marked / cut set (`eraseDirty`), so a
|
|
4161
|
+
// slowly-moving or stopped cursor doesn't re-render the whole scene every
|
|
4162
|
+
// frame (which froze big scenes).
|
|
4163
|
+
(this.interaction.eraseStroke !== null && this.eraseDirty)) {
|
|
3265
4164
|
return null;
|
|
3266
4165
|
}
|
|
3267
4166
|
// Anything that affects the global render — viewport (pan / zoom /
|
|
@@ -3275,6 +4174,12 @@ export class Editor {
|
|
|
3275
4174
|
// repaint when the entered-group identity changes between frames.
|
|
3276
4175
|
if (this.lastRenderedEnteredGroup !== this._enteredGroup)
|
|
3277
4176
|
return null;
|
|
4177
|
+
// Eraser just STOPPED (the active-stroke guard above already returned, so
|
|
4178
|
+
// `eraseStroke` is null here): on an Esc-cancel the marked shapes un-dim
|
|
4179
|
+
// without a scene change — commit changes the scene and is caught by the
|
|
4180
|
+
// diff — so force a full repaint that frame or the dim would linger.
|
|
4181
|
+
if (this.lastRenderedEraseActive)
|
|
4182
|
+
return null;
|
|
3278
4183
|
// Diff the two scenes for the dirty rect + tile-cache invalidation. The
|
|
3279
4184
|
// state-coupled guards above stay here; the pure scene diff lives in
|
|
3280
4185
|
// `computeSceneDirtyRect`.
|
|
@@ -3356,7 +4261,7 @@ export class Editor {
|
|
|
3356
4261
|
const gesture = this.rotateGestureOrigin;
|
|
3357
4262
|
if (!gesture)
|
|
3358
4263
|
return;
|
|
3359
|
-
const d = this.transformShiftKey
|
|
4264
|
+
const d = this.interaction.transformShiftKey
|
|
3360
4265
|
? Math.round(deltaAngle / ROTATE_SNAP_RADIANS) * ROTATE_SNAP_RADIANS
|
|
3361
4266
|
: deltaAngle;
|
|
3362
4267
|
const patches = computeRotatePatches(this._scene, gesture.origin, gesture.pivot, d);
|
|
@@ -3375,7 +4280,7 @@ export class Editor {
|
|
|
3375
4280
|
const result = computeGroupResizePatches(this._scene, this.groupResizeOrigin, handle, d, originalBounds,
|
|
3376
4281
|
// Aspect-locked when the selection type demands it (images / groups) or
|
|
3377
4282
|
// the user holds Shift for this gesture.
|
|
3378
|
-
this.selectionIsAspectLocked() || this.transformShiftKey, this.transformAltKey);
|
|
4283
|
+
this.selectionIsAspectLocked() || this.interaction.transformShiftKey, this.interaction.transformAltKey);
|
|
3379
4284
|
this._scene = result.scene;
|
|
3380
4285
|
for (const patch of result.patches)
|
|
3381
4286
|
this.recordGesturePatch(patch);
|
|
@@ -3389,9 +4294,9 @@ export class Editor {
|
|
|
3389
4294
|
// on the first tick so the closed-form never compounds. Grid-snap is
|
|
3390
4295
|
// skipped — snapping a tilted box to the world grid is ill-defined.
|
|
3391
4296
|
if (shape !== undefined && !isText(shape) && shape.rotation !== 0) {
|
|
3392
|
-
if (this.
|
|
3393
|
-
this.
|
|
3394
|
-
const result = computeRotatedElementResize(this._scene, this.
|
|
4297
|
+
if (this.interaction.resizeOriginElement?.id !== id)
|
|
4298
|
+
this.interaction.resizeOriginElement = shape;
|
|
4299
|
+
const result = computeRotatedElementResize(this._scene, this.interaction.resizeOriginElement, handle, delta, this.interaction.transformShiftKey, this.interaction.transformAltKey);
|
|
3395
4300
|
if (!result)
|
|
3396
4301
|
return;
|
|
3397
4302
|
this._scene = result.scene;
|
|
@@ -3405,10 +4310,14 @@ export class Editor {
|
|
|
3405
4310
|
// Text: aspect-locked font scaling. Snapshot the pristine shape on
|
|
3406
4311
|
// the gesture's first tick so the scale base never compounds.
|
|
3407
4312
|
if (shape !== undefined && isText(shape)) {
|
|
3408
|
-
if (this.
|
|
3409
|
-
this.
|
|
4313
|
+
if (this.interaction.resizeOriginElement?.id !== id) {
|
|
4314
|
+
this.interaction.resizeOriginElement = shape;
|
|
3410
4315
|
}
|
|
3411
|
-
|
|
4316
|
+
// The snapshot was taken from a text shape above; fall back to the
|
|
4317
|
+
// live shape if a stale non-text snapshot ever leaks through.
|
|
4318
|
+
const origin = this.interaction.resizeOriginElement;
|
|
4319
|
+
const textOrigin = isText(origin) ? origin : shape;
|
|
4320
|
+
const result = computeTextResize(this._scene, textOrigin, handle, d, originalBounds, this.interaction.transformAltKey);
|
|
3412
4321
|
if (!result)
|
|
3413
4322
|
return;
|
|
3414
4323
|
this._scene = result.scene;
|
|
@@ -3416,7 +4325,7 @@ export class Editor {
|
|
|
3416
4325
|
this.notify();
|
|
3417
4326
|
return;
|
|
3418
4327
|
}
|
|
3419
|
-
const result = computeElementResize(this._scene, id, handle, d, originalBounds, (s, raw, h) => this.clampContainerToChildren(s, raw, h), this.transformShiftKey, this.transformAltKey);
|
|
4328
|
+
const result = computeElementResize(this._scene, id, handle, d, originalBounds, (s, raw, h) => this.clampContainerToChildren(s, raw, h), this.interaction.transformShiftKey, this.interaction.transformAltKey);
|
|
3420
4329
|
if (!result)
|
|
3421
4330
|
return;
|
|
3422
4331
|
this._scene = result.scene;
|
|
@@ -3436,7 +4345,7 @@ export class Editor {
|
|
|
3436
4345
|
if (kind === "frame") {
|
|
3437
4346
|
this.assignFrameMembers(id, b);
|
|
3438
4347
|
}
|
|
3439
|
-
this.
|
|
4348
|
+
this.maybeRevertToolAfterCreate();
|
|
3440
4349
|
this.notify();
|
|
3441
4350
|
}
|
|
3442
4351
|
/** Generate the next "Frame N" name based on existing frames. */
|
|
@@ -3471,18 +4380,18 @@ export class Editor {
|
|
|
3471
4380
|
this._scene = result.scene;
|
|
3472
4381
|
this._history.push(result.patch);
|
|
3473
4382
|
this.edgePreview = null;
|
|
3474
|
-
this.
|
|
4383
|
+
this.maybeRevertToolAfterCreate();
|
|
3475
4384
|
// Dropped on empty canvas (free `point` end) → offer a shape-picker at
|
|
3476
4385
|
// the drop point (standard). The free-ended link stays; picking re-points
|
|
3477
4386
|
// it, dismissing keeps it. Only the `to` end is user-dragged here.
|
|
3478
4387
|
if (to.kind === "point") {
|
|
3479
|
-
this.pendingLinkDropMenu = { linkId: id, side: "to", world: to.position };
|
|
4388
|
+
this.interaction.pendingLinkDropMenu = { linkId: id, side: "to", world: to.position };
|
|
3480
4389
|
}
|
|
3481
4390
|
this.notify();
|
|
3482
4391
|
}
|
|
3483
4392
|
/** Pending shape-picker after a link was dropped on empty canvas. */
|
|
3484
4393
|
get linkDropMenu() {
|
|
3485
|
-
return this.pendingLinkDropMenu;
|
|
4394
|
+
return this.interaction.pendingLinkDropMenu;
|
|
3486
4395
|
}
|
|
3487
4396
|
/**
|
|
3488
4397
|
* Resolve a pending link-drop shape-picker by creating an element from
|
|
@@ -3491,12 +4400,12 @@ export class Editor {
|
|
|
3491
4400
|
* new element becomes the selection. No-op when no menu is pending.
|
|
3492
4401
|
*/
|
|
3493
4402
|
placeShapeAtLinkDrop(factory) {
|
|
3494
|
-
const pending = this.pendingLinkDropMenu;
|
|
4403
|
+
const pending = this.interaction.pendingLinkDropMenu;
|
|
3495
4404
|
if (!pending)
|
|
3496
4405
|
return;
|
|
3497
4406
|
const link = getLink(this._scene, pending.linkId);
|
|
3498
4407
|
if (!link) {
|
|
3499
|
-
this.pendingLinkDropMenu = null;
|
|
4408
|
+
this.interaction.pendingLinkDropMenu = null;
|
|
3500
4409
|
this.notify();
|
|
3501
4410
|
return;
|
|
3502
4411
|
}
|
|
@@ -3507,16 +4416,16 @@ export class Editor {
|
|
|
3507
4416
|
tx.add(r.addPatch);
|
|
3508
4417
|
tx.add(r.linkPatch);
|
|
3509
4418
|
tx.commit();
|
|
3510
|
-
this.pendingLinkDropMenu = null;
|
|
4419
|
+
this.interaction.pendingLinkDropMenu = null;
|
|
3511
4420
|
this._selection = Selection.single(newId);
|
|
3512
4421
|
this._selectedLinks = LinkSelection.EMPTY;
|
|
3513
4422
|
this.notify();
|
|
3514
4423
|
}
|
|
3515
4424
|
/** Dismiss the link-drop shape-picker, leaving the free-ended link. */
|
|
3516
4425
|
dismissLinkDropMenu() {
|
|
3517
|
-
if (!this.pendingLinkDropMenu)
|
|
4426
|
+
if (!this.interaction.pendingLinkDropMenu)
|
|
3518
4427
|
return;
|
|
3519
|
-
this.pendingLinkDropMenu = null;
|
|
4428
|
+
this.interaction.pendingLinkDropMenu = null;
|
|
3520
4429
|
this.notify();
|
|
3521
4430
|
}
|
|
3522
4431
|
/**
|
|
@@ -3580,9 +4489,9 @@ export class Editor {
|
|
|
3580
4489
|
this.notify();
|
|
3581
4490
|
}
|
|
3582
4491
|
applyLassoLiveSelection(bounds, mode) {
|
|
3583
|
-
const base = this.lassoBaseSelection ?? Selection.EMPTY;
|
|
4492
|
+
const base = this.interaction.lassoBaseSelection ?? Selection.EMPTY;
|
|
3584
4493
|
const next = selectByBoundsLivePure(this._scene, base, (id) => this.isLayerLocked(id), bounds, mode);
|
|
3585
|
-
const linkBase = this.lassoBaseLinks ?? LinkSelection.EMPTY;
|
|
4494
|
+
const linkBase = this.interaction.lassoBaseLinks ?? LinkSelection.EMPTY;
|
|
3586
4495
|
const nextLinks = selectLinksByBoundsLivePure(this._scene, linkBase, (id) => this.isLayerLocked(id), bounds, mode);
|
|
3587
4496
|
const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
|
|
3588
4497
|
if (Selection.equals(next, this._selection) && !linksChanged)
|
|
@@ -3601,57 +4510,14 @@ export class Editor {
|
|
|
3601
4510
|
* link snaps back to where it was. The handle dot follows via `linkEndpointDrag`.
|
|
3602
4511
|
*/
|
|
3603
4512
|
applyLinkEndpointMove(linkId, side, toPoint) {
|
|
3604
|
-
|
|
3605
|
-
if (!edge)
|
|
3606
|
-
return;
|
|
3607
|
-
// A real drag breaks the handle double-click chain (mirrors waypoint /
|
|
3608
|
-
// segment drags) so a quick click after dropping isn't read as a delete.
|
|
3609
|
-
this.lastHandleClickAt = 0;
|
|
3610
|
-
// Resolve the attach target under the cursor and snap the endpoint to it
|
|
3611
|
-
// with the SAME logic the drop uses, so the link attaches LIVE exactly as it
|
|
3612
|
-
// will commit — lands on the dot (fixed), floats on the body, or stays a
|
|
3613
|
-
// free point over empty space.
|
|
3614
|
-
const target = this.linkAttachTargetAt(toPoint);
|
|
3615
|
-
const targetId = target?.kind === "element" ? target.id : null;
|
|
3616
|
-
const ep = this.snapLinkEndpoint(targetId, toPoint);
|
|
3617
|
-
const r = updateLink(this._scene, linkId, (e) => side === "from" ? { ...e, from: ep } : { ...e, to: ep });
|
|
3618
|
-
this._scene = r.scene;
|
|
3619
|
-
this.recordGesturePatch(r.patch);
|
|
3620
|
-
this.linkEndpointDrag = { linkId, side, toPoint };
|
|
3621
|
-
// Attach-point highlight — the SAME feedback as drawing a new link
|
|
3622
|
-
// (candidate dots + float-element halo), driven by `hoveredLinkTarget`.
|
|
3623
|
-
this.updateHoveredLinkTarget(toPoint);
|
|
3624
|
-
this.notify();
|
|
4513
|
+
this.linkHandles.applyEndpointMove(linkId, side, toPoint);
|
|
3625
4514
|
}
|
|
3626
4515
|
applyLinkEndpointUpdate(emit) {
|
|
3627
|
-
|
|
3628
|
-
// snapped endpoint goes into the SAME transaction so the net history step is
|
|
3629
|
-
// original → final (one undo). A pure click (no move, no tx) that resolves
|
|
3630
|
-
// to a no-op change must not leave a junk undo entry.
|
|
3631
|
-
const moved = this.gestureTx !== null;
|
|
3632
|
-
const result = computeLinkEndpointUpdate(this._scene, emit, (toElement, toPoint) => this.snapLinkEndpoint(toElement, toPoint));
|
|
3633
|
-
if (result === null) {
|
|
3634
|
-
this.cancelGesture();
|
|
3635
|
-
this.linkEndpointDrag = null;
|
|
3636
|
-
this.hoveredLinkTarget = null;
|
|
3637
|
-
this.notify();
|
|
3638
|
-
return;
|
|
3639
|
-
}
|
|
3640
|
-
if (!moved && isNoop(result.patch)) {
|
|
3641
|
-
this.linkEndpointDrag = null;
|
|
3642
|
-
this.hoveredLinkTarget = null;
|
|
3643
|
-
this.notify();
|
|
3644
|
-
return;
|
|
3645
|
-
}
|
|
3646
|
-
this._scene = result.scene;
|
|
3647
|
-
this.recordGesturePatch(result.patch);
|
|
3648
|
-
this.commitGesture();
|
|
3649
|
-
this.linkEndpointDrag = null;
|
|
3650
|
-
this.hoveredLinkTarget = null;
|
|
4516
|
+
this.linkHandles.applyEndpointUpdate(emit);
|
|
3651
4517
|
}
|
|
3652
4518
|
/** True while a waypoint of the selected link is being dragged. */
|
|
3653
4519
|
get isDraggingWaypoint() {
|
|
3654
|
-
return this.
|
|
4520
|
+
return this.linkHandles.isDraggingWaypoint;
|
|
3655
4521
|
}
|
|
3656
4522
|
/**
|
|
3657
4523
|
* Begin a host-managed waypoint drag. `insert` splices a new waypoint at
|
|
@@ -3660,34 +4526,24 @@ export class Editor {
|
|
|
3660
4526
|
* transaction so the whole drag is one undo step.
|
|
3661
4527
|
*/
|
|
3662
4528
|
beginWaypointDrag(linkId, index, insert) {
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
4529
|
+
this.linkHandles.beginWaypointDrag(linkId, index, insert);
|
|
4530
|
+
}
|
|
4531
|
+
/** Caption (label pill) drag along the selected link's path. */
|
|
4532
|
+
get isDraggingLabel() {
|
|
4533
|
+
return this.linkHandles.isDraggingLabel;
|
|
4534
|
+
}
|
|
4535
|
+
beginLabelDrag(linkId) {
|
|
4536
|
+
this.linkHandles.beginLabelDrag(linkId);
|
|
4537
|
+
}
|
|
4538
|
+
updateLabelDrag(world) {
|
|
4539
|
+
this.linkHandles.updateLabelDrag(world);
|
|
4540
|
+
}
|
|
4541
|
+
endLabelDrag() {
|
|
4542
|
+
this.linkHandles.endLabelDrag();
|
|
3666
4543
|
}
|
|
3667
4544
|
/** Live update of the dragged waypoint to `world`. */
|
|
3668
4545
|
updateWaypointDrag(world) {
|
|
3669
|
-
|
|
3670
|
-
if (!drag)
|
|
3671
|
-
return;
|
|
3672
|
-
// A real drag breaks the handle double-click chain (see updateSegmentDrag).
|
|
3673
|
-
this.lastHandleClickAt = 0;
|
|
3674
|
-
const edge = getLink(this._scene, drag.linkId);
|
|
3675
|
-
if (!edge)
|
|
3676
|
-
return;
|
|
3677
|
-
const wps = [...(edge.waypoints ?? [])];
|
|
3678
|
-
if (drag.pendingInsert) {
|
|
3679
|
-
wps.splice(drag.index, 0, world);
|
|
3680
|
-
drag.pendingInsert = false;
|
|
3681
|
-
}
|
|
3682
|
-
else {
|
|
3683
|
-
if (drag.index < 0 || drag.index >= wps.length)
|
|
3684
|
-
return;
|
|
3685
|
-
wps[drag.index] = world;
|
|
3686
|
-
}
|
|
3687
|
-
const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, waypoints: wps }));
|
|
3688
|
-
this._scene = r.scene;
|
|
3689
|
-
this.recordGesturePatch(r.patch);
|
|
3690
|
-
this.notify();
|
|
4546
|
+
this.linkHandles.updateWaypointDrag(world);
|
|
3691
4547
|
}
|
|
3692
4548
|
/**
|
|
3693
4549
|
* Finish the waypoint drag. If the dragged waypoint landed within
|
|
@@ -3695,38 +4551,11 @@ export class Editor {
|
|
|
3695
4551
|
* (drag-onto-the-line to delete). A no-move insert adds nothing.
|
|
3696
4552
|
*/
|
|
3697
4553
|
endWaypointDrag() {
|
|
3698
|
-
|
|
3699
|
-
this.linkWaypointDrag = null;
|
|
3700
|
-
if (!drag)
|
|
3701
|
-
return;
|
|
3702
|
-
if (drag.pendingInsert) {
|
|
3703
|
-
// Never moved → it was a click on a midpoint; nothing inserted.
|
|
3704
|
-
this.commitGesture();
|
|
3705
|
-
return;
|
|
3706
|
-
}
|
|
3707
|
-
const edge = getLink(this._scene, drag.linkId);
|
|
3708
|
-
if (edge?.waypoints && drag.index >= 0 && drag.index < edge.waypoints.length) {
|
|
3709
|
-
const path = getLinkPath(this._scene, edge);
|
|
3710
|
-
const wp = req(edge.waypoints[drag.index]);
|
|
3711
|
-
// Neighbours in the [from, ...waypoints, to] chain: path[index] and
|
|
3712
|
-
// path[index + 2] (path[0] = from, so waypoint i sits at path[i + 1]).
|
|
3713
|
-
// Dropping the waypoint back onto the straight segment between its
|
|
3714
|
-
// neighbours removes the bend ("drag onto the line to delete").
|
|
3715
|
-
const collapse = WAYPOINT_COLLAPSE_RADIUS / (this._scene.viewport.zoom || 1);
|
|
3716
|
-
const a = path?.[drag.index];
|
|
3717
|
-
const b = path?.[drag.index + 2];
|
|
3718
|
-
if (a && b && hitTest.distanceToSegment(wp, a, b) <= collapse) {
|
|
3719
|
-
const wps = edge.waypoints.filter((_, i) => i !== drag.index);
|
|
3720
|
-
const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, waypoints: wps }));
|
|
3721
|
-
this._scene = r.scene;
|
|
3722
|
-
this.recordGesturePatch(r.patch);
|
|
3723
|
-
}
|
|
3724
|
-
}
|
|
3725
|
-
this.commitGesture();
|
|
4554
|
+
this.linkHandles.endWaypointDrag();
|
|
3726
4555
|
}
|
|
3727
4556
|
/** True while an elbow segment is being dragged. */
|
|
3728
4557
|
get isDraggingSegment() {
|
|
3729
|
-
return this.
|
|
4558
|
+
return this.linkHandles.isDraggingSegment;
|
|
3730
4559
|
}
|
|
3731
4560
|
/**
|
|
3732
4561
|
* Begin a host-managed elbow segment drag. `axis` is the segment's
|
|
@@ -3734,9 +4563,7 @@ export class Editor {
|
|
|
3734
4563
|
* across re-routes).
|
|
3735
4564
|
*/
|
|
3736
4565
|
beginSegmentDrag(linkId, axis, at) {
|
|
3737
|
-
|
|
3738
|
-
return;
|
|
3739
|
-
this.linkSegmentDrag = { linkId, axis, at };
|
|
4566
|
+
this.linkHandles.beginSegmentDrag(linkId, axis, at);
|
|
3740
4567
|
}
|
|
3741
4568
|
/**
|
|
3742
4569
|
* Move the dragged elbow segment perpendicular to its axis: pin its
|
|
@@ -3744,34 +4571,11 @@ export class Editor {
|
|
|
3744
4571
|
* rest around the pin (one undo step via the gesture transaction).
|
|
3745
4572
|
*/
|
|
3746
4573
|
updateSegmentDrag(world) {
|
|
3747
|
-
|
|
3748
|
-
if (!drag)
|
|
3749
|
-
return;
|
|
3750
|
-
// A real drag breaks the handle double-click chain, so a single click
|
|
3751
|
-
// right after pinning can't be misread as a double-click (= delete).
|
|
3752
|
-
this.lastHandleClickAt = 0;
|
|
3753
|
-
const edge = getLink(this._scene, drag.linkId);
|
|
3754
|
-
if (!edge)
|
|
3755
|
-
return;
|
|
3756
|
-
const pos = drag.axis === "h" ? world.y : world.x;
|
|
3757
|
-
const fixed = [...(edge.fixedSegments ?? [])];
|
|
3758
|
-
const entry = { axis: drag.axis, pos, at: drag.at };
|
|
3759
|
-
const at = fixed.findIndex((f) => f.axis === drag.axis && Math.abs(f.at - drag.at) < 0.5);
|
|
3760
|
-
if (at >= 0)
|
|
3761
|
-
fixed[at] = entry;
|
|
3762
|
-
else
|
|
3763
|
-
fixed.push(entry);
|
|
3764
|
-
const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, fixedSegments: fixed }));
|
|
3765
|
-
this._scene = r.scene;
|
|
3766
|
-
this.recordGesturePatch(r.patch);
|
|
3767
|
-
this.notify();
|
|
4574
|
+
this.linkHandles.updateSegmentDrag(world);
|
|
3768
4575
|
}
|
|
3769
4576
|
/** Finish the elbow segment drag (commit the gesture as one undo step). */
|
|
3770
4577
|
endSegmentDrag() {
|
|
3771
|
-
|
|
3772
|
-
return;
|
|
3773
|
-
this.linkSegmentDrag = null;
|
|
3774
|
-
this.commitGesture();
|
|
4578
|
+
this.linkHandles.endSegmentDrag();
|
|
3775
4579
|
}
|
|
3776
4580
|
/**
|
|
3777
4581
|
* Double-click detector for link edit handles (waypoint / segment).
|
|
@@ -3781,27 +4585,14 @@ export class Editor {
|
|
|
3781
4585
|
* `onDown`, so that path never sees them).
|
|
3782
4586
|
*/
|
|
3783
4587
|
isHandleDoubleClick(world) {
|
|
3784
|
-
|
|
3785
|
-
const isDouble = now - this.lastHandleClickAt < DOUBLE_CLICK_MS &&
|
|
3786
|
-
this.lastHandleClickWorld !== null &&
|
|
3787
|
-
vec2.distance(this.lastHandleClickWorld, world) <= DOUBLE_CLICK_TOLERANCE_PX;
|
|
3788
|
-
this.lastHandleClickAt = now;
|
|
3789
|
-
this.lastHandleClickWorld = world;
|
|
3790
|
-
return isDouble;
|
|
4588
|
+
return this.linkHandles.isHandleDoubleClick(world);
|
|
3791
4589
|
}
|
|
3792
4590
|
/**
|
|
3793
4591
|
* Delete a free bend point (waypoint) from a straight / bezier link by
|
|
3794
4592
|
* index — double-click a waypoint handle to remove it. One undo step.
|
|
3795
4593
|
*/
|
|
3796
4594
|
deleteWaypoint(linkId, index) {
|
|
3797
|
-
|
|
3798
|
-
if (!edge?.waypoints || index < 0 || index >= edge.waypoints.length)
|
|
3799
|
-
return;
|
|
3800
|
-
const wps = edge.waypoints.filter((_, i) => i !== index);
|
|
3801
|
-
const r = updateLink(this._scene, linkId, (e) => ({ ...e, waypoints: wps }));
|
|
3802
|
-
this._scene = r.scene;
|
|
3803
|
-
this._history.push(r.patch);
|
|
3804
|
-
this.notify();
|
|
4595
|
+
this.linkHandles.deleteWaypoint(linkId, index);
|
|
3805
4596
|
}
|
|
3806
4597
|
/**
|
|
3807
4598
|
* Remove the pinned (fixed) elbow segment that matches the given
|
|
@@ -3812,28 +4603,7 @@ export class Editor {
|
|
|
3812
4603
|
* step.
|
|
3813
4604
|
*/
|
|
3814
4605
|
resetSegmentPin(linkId, axis, pos, at) {
|
|
3815
|
-
|
|
3816
|
-
if (!edge?.fixedSegments || edge.fixedSegments.length === 0)
|
|
3817
|
-
return;
|
|
3818
|
-
let bestIdx = -1;
|
|
3819
|
-
let bestD = Infinity;
|
|
3820
|
-
for (let i = 0; i < edge.fixedSegments.length; i++) {
|
|
3821
|
-
const f = req(edge.fixedSegments[i]);
|
|
3822
|
-
if (f.axis !== axis)
|
|
3823
|
-
continue;
|
|
3824
|
-
const d = Math.abs(f.pos - pos) + Math.abs(f.at - at) * 0.001;
|
|
3825
|
-
if (d < bestD) {
|
|
3826
|
-
bestD = d;
|
|
3827
|
-
bestIdx = i;
|
|
3828
|
-
}
|
|
3829
|
-
}
|
|
3830
|
-
if (bestIdx < 0)
|
|
3831
|
-
return;
|
|
3832
|
-
const fixed = edge.fixedSegments.filter((_, i) => i !== bestIdx);
|
|
3833
|
-
const r = updateLink(this._scene, linkId, (e) => ({ ...e, fixedSegments: fixed }));
|
|
3834
|
-
this._scene = r.scene;
|
|
3835
|
-
this._history.push(r.patch);
|
|
3836
|
-
this.notify();
|
|
4606
|
+
this.linkHandles.resetSegmentPin(linkId, axis, pos, at);
|
|
3837
4607
|
}
|
|
3838
4608
|
/** Whether the selected link has obstacle-avoidance routing enabled. */
|
|
3839
4609
|
get selectedLinkAvoidsObstacles() {
|
|
@@ -3852,6 +4622,8 @@ export class Editor {
|
|
|
3852
4622
|
* derived (recomputed by `rerouteElbows`). No-op when no link is selected.
|
|
3853
4623
|
*/
|
|
3854
4624
|
setSelectedLinkAvoidObstacles(enabled) {
|
|
4625
|
+
if (this.readOnly)
|
|
4626
|
+
return;
|
|
3855
4627
|
const id = this.selectedLink;
|
|
3856
4628
|
if (id === null)
|
|
3857
4629
|
return;
|
|
@@ -3866,7 +4638,7 @@ export class Editor {
|
|
|
3866
4638
|
this._scene = r.scene;
|
|
3867
4639
|
this._history.push(r.patch);
|
|
3868
4640
|
// Force the next reroute to recompute with the new mode.
|
|
3869
|
-
this.
|
|
4641
|
+
this.elbowRoutes.delete(id);
|
|
3870
4642
|
this.notify();
|
|
3871
4643
|
}
|
|
3872
4644
|
updateHoveredLinkTarget(worldPoint) {
|
|
@@ -3889,7 +4661,8 @@ export class Editor {
|
|
|
3889
4661
|
const onTarget = result.all.filter((c) => c.metadata?.elementId === shape.id);
|
|
3890
4662
|
const anchor = onTarget.find((c) => c.kind === "anchor");
|
|
3891
4663
|
const outline = onTarget.find((c) => c.kind === "outline");
|
|
3892
|
-
const
|
|
4664
|
+
const refMeta = anchor?.metadata?.ref;
|
|
4665
|
+
const ref = isAnchorRef(refMeta) ? refMeta : undefined;
|
|
3893
4666
|
let activeName = null;
|
|
3894
4667
|
if (ref?.kind === "named") {
|
|
3895
4668
|
activeName = ref.name;
|
|
@@ -3939,7 +4712,7 @@ export class Editor {
|
|
|
3939
4712
|
this.gestures.record(patch);
|
|
3940
4713
|
}
|
|
3941
4714
|
commitGesture() {
|
|
3942
|
-
this.
|
|
4715
|
+
this.interaction.resizeOriginElement = null;
|
|
3943
4716
|
this.rotateGestureOrigin = null;
|
|
3944
4717
|
this.gestures.commit();
|
|
3945
4718
|
this.gestureStartScene = null;
|
|
@@ -3984,8 +4757,8 @@ export class Editor {
|
|
|
3984
4757
|
return this.gestureTx;
|
|
3985
4758
|
}
|
|
3986
4759
|
cancelGesture() {
|
|
3987
|
-
this.
|
|
3988
|
-
this.rotateGestureOrigin = null;
|
|
4760
|
+
this.interaction.resizeOriginElement = null;
|
|
4761
|
+
this.interaction.rotateGestureOrigin = null;
|
|
3989
4762
|
this.gestures.cancel();
|
|
3990
4763
|
// Roll the scene back to the pre-gesture snapshot — cancelling the history
|
|
3991
4764
|
// transaction alone leaves the live drag mutations in `_scene`.
|
|
@@ -4112,17 +4885,33 @@ export class Editor {
|
|
|
4112
4885
|
return patch;
|
|
4113
4886
|
}
|
|
4114
4887
|
/**
|
|
4115
|
-
*
|
|
4116
|
-
*
|
|
4117
|
-
*
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
*
|
|
4122
|
-
*
|
|
4123
|
-
*
|
|
4124
|
-
*
|
|
4125
|
-
*
|
|
4888
|
+
* Derived elbow-route cache, keyed by link — the source of truth for
|
|
4889
|
+
* A*-routed corners, living OUTSIDE the immutable `Scene`. Each entry holds
|
|
4890
|
+
* the routed interior `points` (between from/to) plus the `sig` of the
|
|
4891
|
+
* inputs it was computed from (endpoint refs + bound-shape bounds +
|
|
4892
|
+
* fixedSegments), so an unchanged link short-circuits the reroute.
|
|
4893
|
+
*
|
|
4894
|
+
* `rerouteElbows` still MIRRORS `points` onto `Link.routedPoints` in
|
|
4895
|
+
* `_scene` because three readers still consume the baked field: the render
|
|
4896
|
+
* path (`getLinkPath` in `renderer-core`), the headless `getLinkPath`, and
|
|
4897
|
+
* serialization (`schema.ts`). The mirror is compat-only derived state — no
|
|
4898
|
+
* history push / notify.
|
|
4899
|
+
*
|
|
4900
|
+
* TODO(fable R7a): drop the `_scene` mirror once (a) the render path reads
|
|
4901
|
+
* routes from this cache via the `RenderSnapshot`, and (b) headless
|
|
4902
|
+
* `getLinkPath` / serialization stop depending on baked `routedPoints`.
|
|
4903
|
+
* That eviction changes headless geometry, serialized output, and
|
|
4904
|
+
* collab-synced fields, so it spans the scene / serialization / renderer /
|
|
4905
|
+
* headless goldens and must land as its own cross-package change — out of
|
|
4906
|
+
* scope for this state-only pass.
|
|
4907
|
+
*/
|
|
4908
|
+
elbowRoutes = new Map();
|
|
4909
|
+
/**
|
|
4910
|
+
* Choke-point reroute (standard model): recompute the route for every
|
|
4911
|
+
* orthogonal link whose inputs changed since the last pass, store it in the
|
|
4912
|
+
* derived {@link elbowRoutes} cache, and mirror it onto `_scene`. Runs once
|
|
4913
|
+
* per frame before paint — derived state, so no history push / notify (would
|
|
4914
|
+
* loop). Cheap when nothing moved (signature short-circuit).
|
|
4126
4915
|
*/
|
|
4127
4916
|
rerouteElbows() {
|
|
4128
4917
|
let next = this._scene;
|
|
@@ -4130,22 +4919,140 @@ export class Editor {
|
|
|
4130
4919
|
if ((edge.routing ?? "straight") !== "orthogonal")
|
|
4131
4920
|
continue;
|
|
4132
4921
|
const sig = elbowSignature(this._scene, edge);
|
|
4133
|
-
if (this.
|
|
4922
|
+
if (this.elbowRoutes.get(id)?.sig === sig)
|
|
4134
4923
|
continue;
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4924
|
+
const points = routeElbowLink(next, edge);
|
|
4925
|
+
this.elbowRoutes.set(id, { sig, points });
|
|
4926
|
+
// Compat mirror onto the scene (see `elbowRoutes` doc).
|
|
4927
|
+
next = updateLink(next, id, (e) => ({ ...e, routedPoints: points })).scene;
|
|
4138
4928
|
}
|
|
4139
4929
|
this._scene = next;
|
|
4140
4930
|
}
|
|
4931
|
+
/**
|
|
4932
|
+
* Collect everything {@link renderEditor} paints from into a flat
|
|
4933
|
+
* {@link RenderSnapshot}. Resolves the derived viewport / dirty-rect / dim /
|
|
4934
|
+
* hide inputs and the shared spatial index up front (same order the
|
|
4935
|
+
* orchestrator used to call them in), so the paint pass stays side-effect
|
|
4936
|
+
* free and the orchestrator stays decoupled from this class.
|
|
4937
|
+
*
|
|
4938
|
+
* `computeDirtyWorld` is order-sensitive (it diffs against
|
|
4939
|
+
* `lastRenderedScene` and populates `tileDirtyElements`); it runs here and
|
|
4940
|
+
* the `lastRendered*` bookkeeping is applied in `render()` after the paint.
|
|
4941
|
+
*/
|
|
4942
|
+
buildRenderSnapshot() {
|
|
4943
|
+
// Stroke-erase live preview: the touched originals are hidden in the main
|
|
4944
|
+
// pass and their would-be fragments drawn on the overlay.
|
|
4945
|
+
const strokeErasePreview = this.computeStrokeErasePreview();
|
|
4946
|
+
const baseHidden = this.computeHiddenElements();
|
|
4947
|
+
const hideElements = strokeErasePreview === null
|
|
4948
|
+
? baseHidden
|
|
4949
|
+
: new Set([...(baseHidden ?? []), ...strokeErasePreview.hidden]);
|
|
4950
|
+
return {
|
|
4951
|
+
mainTarget: this.mainTarget,
|
|
4952
|
+
overlayTarget: this.overlayTarget,
|
|
4953
|
+
backgroundTarget: this.backgroundTarget,
|
|
4954
|
+
scene: this._scene,
|
|
4955
|
+
selection: this._selection,
|
|
4956
|
+
selectedLinks: this._selectedLinks,
|
|
4957
|
+
selectedLink: this.selectedLink,
|
|
4958
|
+
selectedAnnotation: this._selectedAnnotation,
|
|
4959
|
+
enteredGroup: this._enteredGroup,
|
|
4960
|
+
gridEnabled: this.gridEnabled,
|
|
4961
|
+
viewportWorld: this.computeViewportWorld(),
|
|
4962
|
+
dirtyWorld: this.computeDirtyWorld(),
|
|
4963
|
+
dimElements: this.computeDimSet(),
|
|
4964
|
+
eraseActive: (this.interaction.eraseStroke?.pending.size ?? 0) > 0,
|
|
4965
|
+
hideElements,
|
|
4966
|
+
strokeErasePreview,
|
|
4967
|
+
sharedIndex: this._scene.elements.size >= LARGE_SCENE_HIT_THRESHOLD ? this.ensureSpatialIndex() : null,
|
|
4968
|
+
boundsCache: this.boundsCache,
|
|
4969
|
+
// Per-instance playback clock threaded through the render context (see
|
|
4970
|
+
// RenderSnapshot.animationClock). Feeds the renderer our per-shape
|
|
4971
|
+
// playback state so paused / reduced-motion GIFs freeze and resumed ones
|
|
4972
|
+
// continue from the right frame — without mutating the process-global
|
|
4973
|
+
// clock each frame, so two editors on one page don't interfere.
|
|
4974
|
+
// A non-string / missing id maps to an untracked key, for which
|
|
4975
|
+
// `clock` falls back to the wall clock.
|
|
4976
|
+
animationClock: (shape) => this.gifPlayback.clock(castElementId(typeof shape.id === "string" ? shape.id : "")),
|
|
4977
|
+
tileComposeFn: this.tileComposeFn,
|
|
4978
|
+
tileDirtyElements: this.tileDirtyElements,
|
|
4979
|
+
mode: this.activeTool.type,
|
|
4980
|
+
activeLayerId: this._activeLayerId,
|
|
4981
|
+
cropFrame: this.cropFrameCorners(),
|
|
4982
|
+
cropGhost: this.cropGhost(),
|
|
4983
|
+
flowchartPreview: this.flowchartPreview,
|
|
4984
|
+
lassoPreview: this.lassoPreview,
|
|
4985
|
+
drawingPreview: this.drawingPreview,
|
|
4986
|
+
edgePreview: this.edgePreview,
|
|
4987
|
+
linkDragFromAnchor: this.linkDragFromAnchor,
|
|
4988
|
+
hoveredLinkTarget: this.hoveredLinkTarget,
|
|
4989
|
+
panGesture: this.panGesture,
|
|
4990
|
+
// `pinch` is unset during the constructor's first render; type says
|
|
4991
|
+
// non-null but runtime can be undefined.
|
|
4992
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- see above
|
|
4993
|
+
pinchActive: this.pinch?.isActive() ?? false,
|
|
4994
|
+
gestureActive: this.gestureTx !== null,
|
|
4995
|
+
linkEndpointDrag: this.linkEndpointDrag,
|
|
4996
|
+
linkSegmentDrag: this.linkSegmentDrag,
|
|
4997
|
+
linkWaypointDrag: this.linkWaypointDrag,
|
|
4998
|
+
hoverCursorWorld: this.hoverCursorWorld,
|
|
4999
|
+
anchorStartHitSlop: this.anchorStartHitSlop,
|
|
5000
|
+
anchorClickRadius: this.anchorClickRadius,
|
|
5001
|
+
containerHover: this.containerHover,
|
|
5002
|
+
brushStroke: this.brushPreviewStroke,
|
|
5003
|
+
laserStrokes: this.interaction.laserStrokes,
|
|
5004
|
+
eraserTrail: this.interaction.eraserTrail,
|
|
5005
|
+
// Eraser cursor ring: a size-matched circle following the pointer while
|
|
5006
|
+
// the erase tool is active (not read-only). Sourced from `lastPointerWorld`
|
|
5007
|
+
// (updated on every hover AND drag move) — `hoverCursorWorld` is forced
|
|
5008
|
+
// null outside select mode. Radius is the panel's eraser width in SCREEN
|
|
5009
|
+
// px (matches the slider number).
|
|
5010
|
+
eraserCursor: this.activeTool.type === "erase" && !this._readOnly && this.lastPointerWorld !== null
|
|
5011
|
+
? { center: this.lastPointerWorld, radius: this._brushSettings.width }
|
|
5012
|
+
: null,
|
|
5013
|
+
peerCursors: this._peerCursors,
|
|
5014
|
+
peerSelections: this._peerSelections,
|
|
5015
|
+
debugHitZones: this.debugHitZones,
|
|
5016
|
+
readOnly: this._readOnly,
|
|
5017
|
+
groupMoveOrigin: this.groupMoveOrigin,
|
|
5018
|
+
aspectLocked: this.selectionIsAspectLocked(),
|
|
5019
|
+
combinedSelectionBounds: this.combinedSelectionBounds(),
|
|
5020
|
+
editingText: this.editingTextOverlay(),
|
|
5021
|
+
previewClickCreate: (fromElement, anchorName) => this.previewClickCreate(fromElement, anchorName),
|
|
5022
|
+
isPlaybackPaused: (id) => this.isPlaybackPaused(id),
|
|
5023
|
+
};
|
|
5024
|
+
}
|
|
4141
5025
|
render() {
|
|
4142
5026
|
this.rerouteElbows();
|
|
4143
|
-
//
|
|
4144
|
-
//
|
|
4145
|
-
|
|
4146
|
-
//
|
|
4147
|
-
|
|
4148
|
-
|
|
5027
|
+
// Age out expired laser-trail points before the snapshot so the fade
|
|
5028
|
+
// advances every frame and the tick self-terminates once all trails clear.
|
|
5029
|
+
this.pruneLaser();
|
|
5030
|
+
// The per-shape animation clock is threaded per-instance through the
|
|
5031
|
+
// RenderSnapshot / render context (see `buildRenderSnapshot`), not set on
|
|
5032
|
+
// the process-global module clock each frame — so concurrent editors keep
|
|
5033
|
+
// independent playback.
|
|
5034
|
+
const snapshot = this.buildRenderSnapshot();
|
|
5035
|
+
renderEditor(snapshot);
|
|
5036
|
+
// Bookkeeping the orchestrator used to do inline: record what we just
|
|
5037
|
+
// painted (for the next frame's dirty diff / isolation-transition check)
|
|
5038
|
+
// and, on the tile-cache path, clear the consumed dirty set.
|
|
5039
|
+
this.lastRenderedScene = this._scene;
|
|
5040
|
+
this.lastRenderedEnteredGroup = this._enteredGroup;
|
|
5041
|
+
this.lastRenderedEraseActive = snapshot.eraseActive;
|
|
5042
|
+
// The forced erase repaint (if any) has now happened — later idle frames
|
|
5043
|
+
// (cursor moving / trail fading) skip the full main pass until the next cut.
|
|
5044
|
+
this.eraseDirty = false;
|
|
5045
|
+
// Clear the accumulated tile-dirty set only when the tile path actually
|
|
5046
|
+
// composited this frame. Group isolation (dim) / per-element hide make the
|
|
5047
|
+
// orchestrator fall back to the full renderScene path (it can't reproduce
|
|
5048
|
+
// dim/hide on cached tiles), so on those frames the pending invalidations
|
|
5049
|
+
// must survive to be applied when the tile path resumes — mirror the same
|
|
5050
|
+
// condition here.
|
|
5051
|
+
const isolationActive = (snapshot.dimElements !== undefined && snapshot.dimElements.size > 0) ||
|
|
5052
|
+
(snapshot.hideElements !== undefined && snapshot.hideElements.size > 0);
|
|
5053
|
+
if (snapshot.tileComposeFn && snapshot.viewportWorld && !isolationActive) {
|
|
5054
|
+
this.tileDirtyElements = new Map();
|
|
5055
|
+
}
|
|
4149
5056
|
// Present AFTER the paint, on the same tick — deferred-submission
|
|
4150
5057
|
// surfaces (WebGL2 / OffscreenCanvas) would otherwise lag one frame.
|
|
4151
5058
|
this.onAfterRender?.();
|