@oh-just-another/state 0.58.0 → 0.59.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 +67 -0
- package/README.md +76 -27
- package/dist/.tsbuildinfo +1 -1
- package/dist/actions/actionArrange.d.ts +13 -0
- package/dist/actions/actionArrange.d.ts.map +1 -0
- package/dist/actions/actionArrange.js +130 -0
- package/dist/actions/actionArrange.js.map +1 -0
- package/dist/actions/actionClipboard.d.ts +2 -0
- package/dist/actions/actionClipboard.d.ts.map +1 -1
- package/dist/actions/actionClipboard.js +27 -1
- package/dist/actions/actionClipboard.js.map +1 -1
- package/dist/actions/actionText.d.ts +5 -0
- package/dist/actions/actionText.d.ts.map +1 -0
- package/dist/actions/actionText.js +37 -0
- package/dist/actions/actionText.js.map +1 -0
- package/dist/actions/actionView.js +1 -1
- package/dist/actions/index.d.ts +3 -1
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +3 -1
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/registry.d.ts.map +1 -1
- package/dist/actions/registry.js +4 -0
- package/dist/actions/registry.js.map +1 -1
- package/dist/actions/types.d.ts +1 -1
- package/dist/actions/types.d.ts.map +1 -1
- package/dist/actions/types.js.map +1 -1
- package/dist/auto-layout-scheduler.d.ts +1 -3
- package/dist/auto-layout-scheduler.d.ts.map +1 -1
- package/dist/auto-layout-scheduler.js +1 -2
- package/dist/auto-layout-scheduler.js.map +1 -1
- package/dist/built-in-handlers.d.ts.map +1 -1
- package/dist/built-in-handlers.js +14 -1
- package/dist/built-in-handlers.js.map +1 -1
- package/dist/constants.d.ts +48 -23
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +48 -23
- package/dist/constants.js.map +1 -1
- package/dist/dom-events.d.ts +7 -0
- package/dist/dom-events.d.ts.map +1 -1
- package/dist/dom-events.js +12 -0
- package/dist/dom-events.js.map +1 -1
- package/dist/editor/animation-scene.d.ts +21 -0
- package/dist/editor/animation-scene.d.ts.map +1 -0
- package/dist/editor/animation-scene.js +71 -0
- package/dist/editor/animation-scene.js.map +1 -0
- package/dist/editor/animation.d.ts +43 -0
- package/dist/editor/animation.d.ts.map +1 -0
- package/dist/editor/animation.js +80 -0
- package/dist/editor/animation.js.map +1 -0
- package/dist/editor/applies/arrange.d.ts +46 -0
- package/dist/editor/applies/arrange.d.ts.map +1 -0
- package/dist/editor/applies/arrange.js +162 -0
- package/dist/editor/applies/arrange.js.map +1 -0
- package/dist/editor/applies/edge.d.ts +8 -1
- package/dist/editor/applies/edge.d.ts.map +1 -1
- package/dist/editor/applies/edge.js +31 -1
- package/dist/editor/applies/edge.js.map +1 -1
- package/dist/editor/applies/move.d.ts +6 -0
- package/dist/editor/applies/move.d.ts.map +1 -1
- package/dist/editor/applies/move.js +6 -0
- package/dist/editor/applies/move.js.map +1 -1
- package/dist/editor/applies/resize.d.ts +26 -5
- package/dist/editor/applies/resize.d.ts.map +1 -1
- package/dist/editor/applies/resize.js +91 -13
- package/dist/editor/applies/resize.js.map +1 -1
- package/dist/editor/caret-blink.d.ts +18 -0
- package/dist/editor/caret-blink.d.ts.map +1 -0
- package/dist/editor/caret-blink.js +42 -0
- package/dist/editor/caret-blink.js.map +1 -0
- package/dist/editor/container-ops.d.ts.map +1 -1
- package/dist/editor/container-ops.js +2 -2
- package/dist/editor/container-ops.js.map +1 -1
- package/dist/editor/dirty-rect.d.ts +23 -0
- package/dist/editor/dirty-rect.d.ts.map +1 -0
- package/dist/editor/dirty-rect.js +115 -0
- package/dist/editor/dirty-rect.js.map +1 -0
- package/dist/editor/gif-playback.d.ts +45 -0
- package/dist/editor/gif-playback.d.ts.map +1 -0
- package/dist/editor/gif-playback.js +125 -0
- package/dist/editor/gif-playback.js.map +1 -0
- package/dist/editor/hit-test.d.ts +3 -3
- package/dist/editor/hit-test.d.ts.map +1 -1
- package/dist/editor/hit-test.js +40 -15
- package/dist/editor/hit-test.js.map +1 -1
- package/dist/editor/long-press.d.ts.map +1 -1
- package/dist/editor/long-press.js +2 -2
- package/dist/editor/long-press.js.map +1 -1
- package/dist/editor/pinch.d.ts.map +1 -1
- package/dist/editor/pinch.js +6 -6
- package/dist/editor/pinch.js.map +1 -1
- package/dist/editor/pointer-binding.d.ts.map +1 -1
- package/dist/editor/pointer-binding.js +42 -25
- package/dist/editor/pointer-binding.js.map +1 -1
- package/dist/editor/public/arrange-group.d.ts +3 -9
- package/dist/editor/public/arrange-group.d.ts.map +1 -1
- package/dist/editor/public/arrange-group.js +4 -6
- package/dist/editor/public/arrange-group.js.map +1 -1
- package/dist/editor/public/clipboard.d.ts +2 -4
- package/dist/editor/public/clipboard.d.ts.map +1 -1
- package/dist/editor/public/clipboard.js +2 -4
- package/dist/editor/public/clipboard.js.map +1 -1
- package/dist/editor/public/cursor.d.ts +43 -0
- package/dist/editor/public/cursor.d.ts.map +1 -0
- package/dist/editor/public/cursor.js +135 -0
- package/dist/editor/public/cursor.js.map +1 -0
- package/dist/editor/public/frame-name.d.ts +20 -0
- package/dist/editor/public/frame-name.d.ts.map +1 -0
- package/dist/editor/public/frame-name.js +54 -0
- package/dist/editor/public/frame-name.js.map +1 -0
- package/dist/editor/public/image-insert.d.ts +1 -1
- package/dist/editor/public/image-insert.d.ts.map +1 -1
- package/dist/editor/public/layers.d.ts.map +1 -1
- package/dist/editor/public/layers.js +2 -2
- package/dist/editor/public/layers.js.map +1 -1
- package/dist/editor/public/link.d.ts +11 -2
- package/dist/editor/public/link.d.ts.map +1 -1
- package/dist/editor/public/link.js +70 -2
- package/dist/editor/public/link.js.map +1 -1
- package/dist/editor/public/placement.d.ts +60 -2
- package/dist/editor/public/placement.d.ts.map +1 -1
- package/dist/editor/public/placement.js +185 -2
- package/dist/editor/public/placement.js.map +1 -1
- package/dist/editor/public/selection-ops.d.ts +23 -1
- package/dist/editor/public/selection-ops.d.ts.map +1 -1
- package/dist/editor/public/selection-ops.js +79 -2
- package/dist/editor/public/selection-ops.js.map +1 -1
- package/dist/editor/public/text-edit.d.ts +1 -10
- package/dist/editor/public/text-edit.d.ts.map +1 -1
- package/dist/editor/public/text-edit.js +2 -16
- package/dist/editor/public/text-edit.js.map +1 -1
- package/dist/editor/public/z-order.d.ts.map +1 -1
- package/dist/editor/public/z-order.js +4 -4
- package/dist/editor/public/z-order.js.map +1 -1
- package/dist/editor/public/zoom-pan.d.ts +6 -4
- package/dist/editor/public/zoom-pan.d.ts.map +1 -1
- package/dist/editor/public/zoom-pan.js +8 -7
- package/dist/editor/public/zoom-pan.js.map +1 -1
- package/dist/editor/render-orchestrator.d.ts.map +1 -1
- package/dist/editor/render-orchestrator.js +4 -9
- package/dist/editor/render-orchestrator.js.map +1 -1
- package/dist/editor/resize-helpers.d.ts +21 -5
- package/dist/editor/resize-helpers.d.ts.map +1 -1
- package/dist/editor/resize-helpers.js +59 -19
- package/dist/editor/resize-helpers.js.map +1 -1
- package/dist/editor/viewport-helpers.d.ts.map +1 -1
- package/dist/editor/viewport-helpers.js +3 -3
- package/dist/editor/viewport-helpers.js.map +1 -1
- package/dist/editor-events.d.ts +1 -1
- package/dist/editor-events.d.ts.map +1 -1
- package/dist/editor.d.ts +125 -133
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +351 -989
- package/dist/editor.js.map +1 -1
- package/dist/file-drop.d.ts.map +1 -1
- package/dist/file-drop.js +1 -2
- package/dist/file-drop.js.map +1 -1
- package/dist/frame-helpers.d.ts.map +1 -1
- package/dist/frame-helpers.js +5 -5
- package/dist/frame-helpers.js.map +1 -1
- package/dist/group-helpers.d.ts.map +1 -1
- package/dist/group-helpers.js +4 -4
- package/dist/group-helpers.js.map +1 -1
- package/dist/handle.d.ts +61 -6
- package/dist/handle.d.ts.map +1 -1
- package/dist/handle.js +105 -7
- package/dist/handle.js.map +1 -1
- package/dist/hotkeys-binding.d.ts +26 -0
- package/dist/hotkeys-binding.d.ts.map +1 -0
- package/dist/hotkeys-binding.js +35 -0
- package/dist/hotkeys-binding.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/link-selection.d.ts +3 -5
- package/dist/link-selection.d.ts.map +1 -1
- package/dist/link-selection.js +3 -12
- package/dist/link-selection.js.map +1 -1
- package/dist/machine.d.ts +27 -1
- package/dist/machine.d.ts.map +1 -1
- package/dist/machine.js +38 -0
- package/dist/machine.js.map +1 -1
- package/dist/overlay.d.ts +1 -4
- package/dist/overlay.d.ts.map +1 -1
- package/dist/overlay.js +136 -36
- package/dist/overlay.js.map +1 -1
- package/dist/selection.d.ts.map +1 -1
- package/dist/selection.js +2 -10
- package/dist/selection.js.map +1 -1
- package/dist/util.d.ts +8 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +17 -0
- package/dist/util.js.map +1 -0
- package/package.json +7 -7
package/dist/editor.js
CHANGED
|
@@ -1,30 +1,34 @@
|
|
|
1
1
|
import { createActor } from "xstate";
|
|
2
2
|
import { createEmitter } from "@oh-just-another/events";
|
|
3
3
|
import { elementId as castElementId } from "@oh-just-another/types";
|
|
4
|
-
import { addElement, anchorSnapper, apply, buildSpatialIndex,
|
|
4
|
+
import { addElement, 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, removeElement, SnapEngine, isNoop, invert, updateLink, updateElement, isSnapToGridEnabled, resolveSnapSpacing, } from "@oh-just-another/scene";
|
|
5
5
|
import { layerId as castLayerId, } from "@oh-just-another/types";
|
|
6
|
-
import { bounds as B, matrix } from "@oh-just-another/math";
|
|
7
|
-
import { caretGeometry,
|
|
6
|
+
import { bounds as B, matrix, vec2, hitTest } from "@oh-just-another/math";
|
|
7
|
+
import { caretGeometry, layoutText, onAnimationContentReady, pointToCaretIndex, selectionRects as textSelectionRects, setActiveRasterizer, setActiveTextShaper, setAnimationClock, ElementCache, } from "@oh-just-another/renderer-core";
|
|
8
8
|
import { History, } from "@oh-just-another/history";
|
|
9
|
-
import {
|
|
9
|
+
import { DEFAULT_LINK_ROUTING, WAYPOINT_COLLAPSE_RADIUS } from "./constants.js";
|
|
10
10
|
import { FileDropRegistry } from "./file-drop.js";
|
|
11
11
|
import { imageFileDropHandler, videoFileDropHandler } from "./built-in-handlers.js";
|
|
12
|
-
import { AnimationTick } from "./animation-tick.js";
|
|
13
12
|
import { computeDimElements as computeDimElementsHelper, isDescendantOfGroup as isDescendantOfGroupHelper, pickDrillTarget as pickDrillTargetHelper, promoteToGroupRoot as promoteToGroupRootHelper, topGroupAncestor as topGroupAncestorHelper, } from "./group-helpers.js";
|
|
14
13
|
import { assignFrameMembers as assignFrameMembersHelper, nextFrameName as nextFrameNameHelper, reconcileFrameMembership as reconcileFrameMembershipHelper, } from "./frame-helpers.js";
|
|
15
14
|
import { AutoCompactScheduler } from "./auto-compact.js";
|
|
16
15
|
import { AutoLayoutScheduler } from "./auto-layout-scheduler.js";
|
|
17
|
-
import { DEFAULT_SNAP_THRESHOLD, LINK_ENDPOINT_HANDLE_RADIUS, LINK_HIT_THRESHOLD, LARGE_SCENE_HIT_THRESHOLD, TOUCH_LINK_HANDLE_HIT_SLOP, TOUCH_LINK_HIT_THRESHOLD, TOUCH_HANDLE_HIT_SLOP, ANCHOR_START_HIT_SLOP, ANCHOR_DOT_CLICK_RADIUS,
|
|
18
|
-
import { HANDLE_HIT_SLOP
|
|
19
|
-
import {
|
|
16
|
+
import { DEFAULT_SNAP_THRESHOLD, LINK_ENDPOINT_HANDLE_RADIUS, LINK_HIT_THRESHOLD, LARGE_SCENE_HIT_THRESHOLD, TOUCH_LINK_HANDLE_HIT_SLOP, TOUCH_LINK_HIT_THRESHOLD, TOUCH_HANDLE_HIT_SLOP, ANCHOR_START_HIT_SLOP, ANCHOR_DOT_CLICK_RADIUS, TOUCH_ANCHOR_START_HIT_SLOP, TOUCH_ANCHOR_DOT_CLICK_RADIUS, DOUBLE_CLICK_MS, DOUBLE_CLICK_TOLERANCE_PX, WHEEL_ZOOM_STEP, ROTATE_SNAP_RADIANS, } from "./constants.js";
|
|
17
|
+
import { HANDLE_HIT_SLOP } from "./handle.js";
|
|
18
|
+
import { req } from "./util.js";
|
|
20
19
|
import { interactionMachine, } from "./machine.js";
|
|
21
20
|
import { createEventCache, fanOutEvents, primeEventCache, } from "./editor/event-fanout.js";
|
|
21
|
+
import { AnimationController } from "./editor/animation.js";
|
|
22
|
+
import { CaretBlinkController } from "./editor/caret-blink.js";
|
|
22
23
|
import { GestureController } from "./editor/gesture-tx.js";
|
|
24
|
+
import { GifPlaybackController } from "./editor/gif-playback.js";
|
|
25
|
+
import * as animScene from "./editor/animation-scene.js";
|
|
26
|
+
import { computeSceneDirtyRect } from "./editor/dirty-rect.js";
|
|
23
27
|
import { LongPressController } from "./editor/long-press.js";
|
|
24
28
|
import { pickPressTarget } from "./editor/hit-test.js";
|
|
25
29
|
import { PinchController } from "./editor/pinch.js";
|
|
26
30
|
import { applyContainerDrop as applyContainerDropPure, clampContainerToChildren as clampContainerToChildrenPure, maybeGrowContainer as maybeGrowContainerPure, } from "./editor/container-ops.js";
|
|
27
|
-
import { computeGroupResizePatches, computeElementResize, computeTextResize, } from "./editor/applies/resize.js";
|
|
31
|
+
import { computeGroupResizePatches, computeElementResize, computeRotatedElementResize, computeTextResize, } from "./editor/applies/resize.js";
|
|
28
32
|
import { bindPointerEvents as bindPointerEventsExternal } from "./editor/pointer-binding.js";
|
|
29
33
|
import { beginBrushStroke as beginBrushStrokePure, commitBrushStroke as commitBrushStrokePure, extendBrushStroke as extendBrushStrokePure, newBrushId, } from "./editor/public/brush.js";
|
|
30
34
|
import { copySelected as copySelectedPure, pasteFromClipboard, selectionFromPasted, } from "./editor/public/clipboard.js";
|
|
@@ -32,55 +36,27 @@ import { computeCreateLayer, computeMoveSelectionToLayer, computeRemoveLayer, co
|
|
|
32
36
|
import { computePan, computeResetZoom, computeSetGrid, computeViewportResize, computeZoomAt, computeZoomToFit, computeZoomToBounds, } from "./editor/public/zoom-pan.js";
|
|
33
37
|
import { computeAddAnnotation, computeAddComment, computeRemoveAnnotation, computeRemoveComment, computeToggleAnnotationResolved, hitAnnotation as hitAnnotationPure, } from "./editor/public/annotations.js";
|
|
34
38
|
import { canBeginTextEdit } from "./editor/public/text-edit.js";
|
|
39
|
+
import { frameHeaderAt as computeFrameHeaderAt, computeFrameNameCommit, } from "./editor/public/frame-name.js";
|
|
40
|
+
import { computeCursor } from "./editor/public/cursor.js";
|
|
35
41
|
import { compactLayerZOrderPatches, computeBringForward, computeBringToFront, computeSendBackward, computeSendToBack, } from "./editor/public/z-order.js";
|
|
36
42
|
import { computeArrangeAsGrid, computeArrangeAsStack, computeGroupSelected, computeUngroup, expandSelectionWithDescendants, newGroupElementId, pickFocusCycle, selectionRoots, } from "./editor/public/arrange-group.js";
|
|
37
43
|
import { buildImageElement, computeAddBinaryFile, hasAnimatedElement, } from "./editor/public/image-insert.js";
|
|
38
|
-
import { computeDeleteSelection, computeDuplicateSelection, computeMoveSelectionBy, computeSelectAll, computeSelectAllLinks, computeSetSelection, computeUpdateStyle, computeUpdateTextProps, describeNudge as describeNudgePure, selectionFromNewIds, } from "./editor/public/selection-ops.js";
|
|
39
|
-
import { computeSetLink, normalizeHref, safeHref } from "./editor/public/link.js";
|
|
40
|
-
import { beginPlacementState, buildElementAtCursor, buildTextElementAt, computePlacementCancel, computePlacementContainerDrop, computePlacementUpdate, newElementIdAtCursor, } from "./editor/public/placement.js";
|
|
44
|
+
import { computeDeleteSelection, computeDuplicateSelection, computeMoveSelectionBy, computeSelectAll, computeSelectAllLinks, computeAdjustFontSize, computeSetSelection, computeUpdateStyle, computeUpdateTextProps, describeNudge as describeNudgePure, findClosestInDirection, selectionFromNewIds, } from "./editor/public/selection-ops.js";
|
|
45
|
+
import { computeSetLink, normalizeHref, safeHref, snapLinkEndpoint as snapLinkEndpointPure, } from "./editor/public/link.js";
|
|
46
|
+
import { beginPlacementState, buildElementAtCursor, buildTextElementAt, computePlacementCancel, computePlacementContainerDrop, computePlacementUpdate, computeLinkedElementFromAnchor, computeDuplicateInPlace, computeShapeAtLinkDrop, newElementIdAtCursor, previewClickCreate as previewClickCreatePure, } from "./editor/public/placement.js";
|
|
41
47
|
import { renderEditor } from "./editor/render-orchestrator.js";
|
|
42
48
|
import { combinedSelectionBounds as combinedSelectionBoundsPure, computeViewportWorld as computeViewportWorldPure, groupChildrenUnion as groupChildrenUnionPure, } from "./editor/viewport-helpers.js";
|
|
43
49
|
import { computeHiddenElements as computeHiddenElementsPure } from "./editor/shape-filters.js";
|
|
44
50
|
import { selectByBounds as selectByBoundsPure, selectByBoundsLive as selectByBoundsLivePure, selectLinksByBoundsLive as selectLinksByBoundsLivePure, } from "./editor/applies/selection.js";
|
|
45
|
-
import { computeLinkEndpointUpdate, computeLinkPreviewEndpoints } from "./editor/applies/edge.js";
|
|
46
|
-
import { computeAnnotationMovePatch, computeGroupMovePatches, computeElementMovePatch, } from "./editor/applies/move.js";
|
|
51
|
+
import { computeLinkEndpointUpdate, computeLinkPreviewEndpoints, elbowSignature, } from "./editor/applies/edge.js";
|
|
52
|
+
import { computeAnnotationMovePatch, computeGroupMovePatches, computeElementMovePatch, constrainDeltaToAxis, } from "./editor/applies/move.js";
|
|
53
|
+
import { computeAlignPatches, computeDistributePatches, computeFlipPatches, computeRotatePatches, selectionCenter, } from "./editor/applies/arrange.js";
|
|
47
54
|
import { computeMovingLinkPatches, computeMovingLinkForNudge } from "./editor/applies/link-move.js";
|
|
48
55
|
import { computeCreateLink, computeCreateElement, newLinkId, newElementId, } from "./editor/applies/create.js";
|
|
49
56
|
import { snapCreateBounds, snapGroupDelta, snapMoveDelta, snapResizeDelta, } from "./editor/applies/snap-grid.js";
|
|
50
57
|
import {} from "./overlay.js";
|
|
51
58
|
import * as Selection from "./selection.js";
|
|
52
59
|
import * as LinkSelection from "./link-selection.js";
|
|
53
|
-
/**
|
|
54
|
-
* Index-access helper for provably-valid indices: throws instead of
|
|
55
|
-
* returning `undefined` so callers stay non-nullable without `!`.
|
|
56
|
-
*/
|
|
57
|
-
const req = (v) => {
|
|
58
|
-
if (v === undefined)
|
|
59
|
-
throw new Error("packages/state: index out of range");
|
|
60
|
-
return v;
|
|
61
|
-
};
|
|
62
|
-
/** Resize handle → cursor override role. */
|
|
63
|
-
const RESIZE_ROLE = {
|
|
64
|
-
nw: "resize-nwse",
|
|
65
|
-
se: "resize-nwse",
|
|
66
|
-
ne: "resize-nesw",
|
|
67
|
-
sw: "resize-nesw",
|
|
68
|
-
n: "resize-ns",
|
|
69
|
-
s: "resize-ns",
|
|
70
|
-
e: "resize-ew",
|
|
71
|
-
w: "resize-ew",
|
|
72
|
-
};
|
|
73
|
-
/** Build a CSS `cursor` value from a {@link CursorSpec}. */
|
|
74
|
-
const cssCursor = (spec, fallbackKeyword) => {
|
|
75
|
-
if (typeof spec === "string")
|
|
76
|
-
return spec;
|
|
77
|
-
const hx = spec.hotspot?.x ?? 0;
|
|
78
|
-
const hy = spec.hotspot?.y ?? 0;
|
|
79
|
-
const img = spec.url2x !== undefined
|
|
80
|
-
? `image-set(url("${spec.url}") 1x, url("${spec.url2x}") 2x)`
|
|
81
|
-
: `url("${spec.url}")`;
|
|
82
|
-
return `${img} ${String(hx)} ${String(hy)}, ${spec.fallback ?? fallbackKeyword}`;
|
|
83
|
-
};
|
|
84
60
|
export class Editor {
|
|
85
61
|
host;
|
|
86
62
|
mainTarget;
|
|
@@ -94,11 +70,6 @@ export class Editor {
|
|
|
94
70
|
* View-only — never persisted or recorded in history.
|
|
95
71
|
*/
|
|
96
72
|
debugHitZones = false;
|
|
97
|
-
/**
|
|
98
|
-
* When false the background grid is not painted. Toggled via `toggleGrid`
|
|
99
|
-
* (`g` hotkey, standard parity). View-only — never persisted or in history.
|
|
100
|
-
*/
|
|
101
|
-
gridVisible = true;
|
|
102
73
|
actor;
|
|
103
74
|
listeners = new Set();
|
|
104
75
|
/**
|
|
@@ -113,7 +84,7 @@ export class Editor {
|
|
|
113
84
|
events = createEmitter();
|
|
114
85
|
/**
|
|
115
86
|
* Last-emitted snapshot of every observable slice. Used by
|
|
116
|
-
* `fanOutEvents`
|
|
87
|
+
* `fanOutEvents` to decide which
|
|
117
88
|
* typed events to fire on each `notify()` — only the slices
|
|
118
89
|
* whose identity changed since the previous notify get an event.
|
|
119
90
|
*/
|
|
@@ -235,6 +206,13 @@ export class Editor {
|
|
|
235
206
|
* position / size against the combined bounds delta each frame.
|
|
236
207
|
*/
|
|
237
208
|
groupResizeOrigin = null;
|
|
209
|
+
/**
|
|
210
|
+
* Press-time snapshot for a rotate gesture: the pivot (selection bbox centre)
|
|
211
|
+
* and every member's pristine `position` / `rotation`. Each frame rotates
|
|
212
|
+
* from this baseline so the cumulative angle never drifts. Cleared on gesture
|
|
213
|
+
* end (commit / cancel).
|
|
214
|
+
*/
|
|
215
|
+
rotateGestureOrigin = null;
|
|
238
216
|
/**
|
|
239
217
|
* Pristine shape snapshot for a single-shape text resize, captured on
|
|
240
218
|
* the gesture's first tick. Font scaling is computed against this base
|
|
@@ -270,6 +248,20 @@ export class Editor {
|
|
|
270
248
|
* move / resize / create wrappers; never persisted.
|
|
271
249
|
*/
|
|
272
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
|
+
/**
|
|
260
|
+
* In-editor style memory for copy-style / paste-style. Holds the visual
|
|
261
|
+
* `style` (fill / stroke / dash / …) captured from a shape; `null` until a
|
|
262
|
+
* copy happens. Not the OS clipboard — a lightweight per-editor buffer.
|
|
263
|
+
*/
|
|
264
|
+
styleClipboard = null;
|
|
273
265
|
/**
|
|
274
266
|
* Persistent world-bounds cache shared with `renderScene` for viewport
|
|
275
267
|
* culling. Object-identity keyed — invalidates automatically whenever
|
|
@@ -343,7 +335,6 @@ export class Editor {
|
|
|
343
335
|
* Fractional-order compaction scheduler (microtask-coalesced).
|
|
344
336
|
* Triggered from every `notify()`; only does real work when at
|
|
345
337
|
* least one shape/edge order string crossed AUTO_COMPACT_THRESHOLD.
|
|
346
|
-
* See `./auto-compact.ts` for the extracted logic.
|
|
347
338
|
*/
|
|
348
339
|
autoCompactScheduler = new AutoCompactScheduler({
|
|
349
340
|
getScene: () => this._scene,
|
|
@@ -353,8 +344,7 @@ export class Editor {
|
|
|
353
344
|
});
|
|
354
345
|
/**
|
|
355
346
|
* Auto-layout scheduler — microtask-coalesced re-run of every
|
|
356
|
-
* shape carrying `metadata.autoLayout`.
|
|
357
|
-
* `./auto-layout-scheduler.ts` for the extracted logic.
|
|
347
|
+
* shape carrying `metadata.autoLayout`.
|
|
358
348
|
*/
|
|
359
349
|
autoLayoutScheduler = new AutoLayoutScheduler({
|
|
360
350
|
getScene: () => this._scene,
|
|
@@ -429,10 +419,10 @@ export class Editor {
|
|
|
429
419
|
* Screen-space origin point.
|
|
430
420
|
*/
|
|
431
421
|
touchPanCandidate = null;
|
|
432
|
-
// Pinch gesture state lives in PinchController
|
|
433
|
-
//
|
|
422
|
+
// Pinch gesture state lives in PinchController; `pinch.isActive()`
|
|
423
|
+
// reports whether a two-finger gesture is in flight.
|
|
434
424
|
pinch;
|
|
435
|
-
/** Bridge for
|
|
425
|
+
/** Bridge for the container-ops helpers. Built lazily in constructor. */
|
|
436
426
|
containerOpsRef;
|
|
437
427
|
/**
|
|
438
428
|
* Space-bar held → next pointer drag pans the canvas instead of
|
|
@@ -501,9 +491,9 @@ export class Editor {
|
|
|
501
491
|
* the timer fires. Hosts subscribe via `onLongPress` to surface a
|
|
502
492
|
* context menu (mobile alternative to right-click).
|
|
503
493
|
*/
|
|
504
|
-
// Long-press timer + origin live in LongPressController
|
|
505
|
-
//
|
|
506
|
-
//
|
|
494
|
+
// Long-press timer + origin live in LongPressController. The Set of
|
|
495
|
+
// subscribers stays here because `onLongPress` is part of the public
|
|
496
|
+
// Editor API.
|
|
507
497
|
longPress;
|
|
508
498
|
longPressListeners = new Set();
|
|
509
499
|
/**
|
|
@@ -538,8 +528,7 @@ export class Editor {
|
|
|
538
528
|
/**
|
|
539
529
|
* Wraps gesture lifecycle (transaction open/commit/cancel +
|
|
540
530
|
* post-create mode revert) so editor.ts doesn't carry the bodies.
|
|
541
|
-
*
|
|
542
|
-
* controller calls back through the narrow `GestureRef` bridge
|
|
531
|
+
* The controller calls back through the narrow `GestureRef` bridge
|
|
543
532
|
* built lazily below.
|
|
544
533
|
*/
|
|
545
534
|
gestures;
|
|
@@ -699,8 +688,8 @@ export class Editor {
|
|
|
699
688
|
}, (delta) => {
|
|
700
689
|
this.panBy(delta);
|
|
701
690
|
});
|
|
702
|
-
// Bridge for container-ops
|
|
703
|
-
// pure functions
|
|
691
|
+
// Bridge for the container-ops helpers — narrow surface that the
|
|
692
|
+
// pure functions call back into.
|
|
704
693
|
// eslint-disable-next-line @typescript-eslint/no-this-alias -- bridge literal rebinds `this`; alias keeps Editor reference
|
|
705
694
|
const self2 = this;
|
|
706
695
|
this.containerOpsRef = {
|
|
@@ -723,13 +712,11 @@ export class Editor {
|
|
|
723
712
|
// throttle rAF to ~1fps in background but don't stop it; an explicit
|
|
724
713
|
// stop saves the decode + render entirely). Resume when visible again,
|
|
725
714
|
// viewport permitting.
|
|
726
|
-
|
|
727
|
-
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
|
728
|
-
}
|
|
715
|
+
this.animation.attach();
|
|
729
716
|
// Restore GIF/video bytes onto animated image shapes loaded from
|
|
730
717
|
// an initial scene (e.g. localStorage), then arm the tick so the
|
|
731
718
|
// animation plays from first paint.
|
|
732
|
-
|
|
719
|
+
animScene.rehydrateAnimatedImages(this);
|
|
733
720
|
this.maybeAnimate();
|
|
734
721
|
// An animated adapter (GIF) decodes asynchronously; when a decode
|
|
735
722
|
// completes it nudges us here. Re-render so a PAUSED animated shape
|
|
@@ -822,7 +809,6 @@ export class Editor {
|
|
|
822
809
|
this._selectedAnnotation = id;
|
|
823
810
|
this.notify();
|
|
824
811
|
}
|
|
825
|
-
// Pure bodies in `./editor/public/annotations.ts`.
|
|
826
812
|
addAnnotation(opts) {
|
|
827
813
|
const result = computeAddAnnotation(this._scene, opts, this.commentAuthor, (p) => this.uniqueId(p));
|
|
828
814
|
this._scene = result.scene;
|
|
@@ -934,16 +920,17 @@ export class Editor {
|
|
|
934
920
|
this.debugHitZones = on;
|
|
935
921
|
this.scheduleRender();
|
|
936
922
|
}
|
|
937
|
-
/**
|
|
923
|
+
/** Whether the background grid is enabled for the scene. */
|
|
924
|
+
get gridEnabled() {
|
|
925
|
+
return this._scene.viewport.gridEnabled;
|
|
926
|
+
}
|
|
927
|
+
/** Show/hide the background grid (`g`). Persists in the viewport, not in history. */
|
|
938
928
|
setGridVisible(on) {
|
|
939
|
-
|
|
940
|
-
return;
|
|
941
|
-
this.gridVisible = on;
|
|
942
|
-
this.scheduleRender();
|
|
929
|
+
this.setGrid({ enabled: on });
|
|
943
930
|
}
|
|
944
|
-
/** Toggle background grid
|
|
931
|
+
/** Toggle the background grid on/off. */
|
|
945
932
|
toggleGrid() {
|
|
946
|
-
this.
|
|
933
|
+
this.setGrid({ enabled: !this.gridEnabled });
|
|
947
934
|
}
|
|
948
935
|
/** Whether the active draw-mode sticks after a create (toolbar lock). */
|
|
949
936
|
get toolLocked() {
|
|
@@ -1016,7 +1003,6 @@ export class Editor {
|
|
|
1016
1003
|
this._toolLocked = locked;
|
|
1017
1004
|
this.notify();
|
|
1018
1005
|
}
|
|
1019
|
-
// Body moved to `./editor/gesture-tx.ts`.
|
|
1020
1006
|
maybeRevertModeAfterCreate() {
|
|
1021
1007
|
this.gestures.maybeRevertModeAfterCreate();
|
|
1022
1008
|
}
|
|
@@ -1086,13 +1072,12 @@ export class Editor {
|
|
|
1086
1072
|
* file-drop handler, a host CDN URL, an SVG string in
|
|
1087
1073
|
* `image/svg+xml;base64,...` form.
|
|
1088
1074
|
*/
|
|
1089
|
-
// Pure body in `./editor/public/image-insert.ts`.
|
|
1090
1075
|
insertImage(input) {
|
|
1091
1076
|
const id = castElementId(this.uniqueId("img"));
|
|
1092
1077
|
const shape = buildImageElement(this._scene, input, id, this._activeLayerId);
|
|
1093
1078
|
this.addElement(shape);
|
|
1094
1079
|
if (input.animated) {
|
|
1095
|
-
this.
|
|
1080
|
+
this.gifPlayback.ensure(id);
|
|
1096
1081
|
this.maybeAnimate();
|
|
1097
1082
|
}
|
|
1098
1083
|
return id;
|
|
@@ -1110,67 +1095,25 @@ export class Editor {
|
|
|
1110
1095
|
* current frame of natively-animated elements. Self-terminates
|
|
1111
1096
|
* when no animated shapes remain.
|
|
1112
1097
|
*
|
|
1113
|
-
* Lifecycle managed by the `AnimationTick` helper
|
|
1114
|
-
*
|
|
1115
|
-
* `
|
|
1116
|
-
*/
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
// on viewport changes via `maybeAnimate()` in `notify()`.
|
|
1126
|
-
isAnimated: () => this.hasVisibleAnimatedElement(),
|
|
1127
|
-
onTick: () => {
|
|
1128
|
-
// Adaptive throttle — skip this rAF if an animation frame was rendered
|
|
1129
|
-
// too recently. The target interval grows with the measured render
|
|
1130
|
-
// cost so a heavy scene drops GIF fps instead of blowing the frame
|
|
1131
|
-
// budget.
|
|
1132
|
-
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
1133
|
-
const target = Math.min(ANIMATION_MAX_INTERVAL_MS, Math.max(ANIMATION_MIN_INTERVAL_MS, this.gifRenderCostEma * ANIMATION_COST_FACTOR));
|
|
1134
|
-
if (now - this.lastGifTickMs < target)
|
|
1135
|
-
return;
|
|
1136
|
-
this.lastGifTickMs = now;
|
|
1137
|
-
// Freeze heavy GIFs that have played long enough.
|
|
1138
|
-
this.autoStopHeavyGifs();
|
|
1139
|
-
// Force a full re-render: the scene reference hasn't changed,
|
|
1140
|
-
// but the animation adapter advanced the GIF frame. Re-painting
|
|
1141
|
-
// picks up the current frame.
|
|
1098
|
+
* Lifecycle managed by the `AnimationTick` helper.
|
|
1099
|
+
* `insertImage({animated:true})` and `loadScene` start the tick;
|
|
1100
|
+
* `dispose()` stops it.
|
|
1101
|
+
*/
|
|
1102
|
+
animation = new AnimationController({
|
|
1103
|
+
hasVisibleAnimatedElement: () => animScene.hasVisibleAnimatedElement(this),
|
|
1104
|
+
autoStopHeavyGifs: () => {
|
|
1105
|
+
animScene.autoStopHeavyGifs(this);
|
|
1106
|
+
},
|
|
1107
|
+
forceAnimationRepaint: () => {
|
|
1108
|
+
// The scene reference hasn't changed, but the adapter advanced the GIF
|
|
1109
|
+
// frame — drop the paint cache and force a repaint.
|
|
1142
1110
|
this.lastRenderedScene = null;
|
|
1143
1111
|
this.render();
|
|
1144
|
-
const cost = (typeof performance !== "undefined" ? performance.now() : Date.now()) - now;
|
|
1145
|
-
// EMA so a single spike doesn't overreact; decays back when load drops.
|
|
1146
|
-
this.gifRenderCostEma = this.gifRenderCostEma * 0.8 + cost * 0.2;
|
|
1147
1112
|
},
|
|
1148
1113
|
});
|
|
1149
|
-
// Pure body in `./editor/public/image-insert.ts`.
|
|
1150
1114
|
hasAnimatedElement() {
|
|
1151
1115
|
return hasAnimatedElement(this._scene);
|
|
1152
1116
|
}
|
|
1153
|
-
/**
|
|
1154
|
-
* True when at least one animated shape's world AABB intersects the
|
|
1155
|
-
* current viewport. Drives viewport-culling of the animation tick —
|
|
1156
|
-
* off-screen GIFs don't burn decode / render cost, and the wall-clock
|
|
1157
|
-
* frame selection means they show the right frame the moment they
|
|
1158
|
-
* scroll back in.
|
|
1159
|
-
*/
|
|
1160
|
-
hasVisibleAnimatedElement() {
|
|
1161
|
-
if (!hasAnimatedElement(this._scene))
|
|
1162
|
-
return false;
|
|
1163
|
-
const viewport = this.computeViewportWorld();
|
|
1164
|
-
if (!viewport)
|
|
1165
|
-
return true; // no viewport yet — don't suppress
|
|
1166
|
-
for (const shape of this._scene.elements.values()) {
|
|
1167
|
-
if (shape.metadata?.animated !== true)
|
|
1168
|
-
continue;
|
|
1169
|
-
if (B.intersects(getElementWorldBounds(shape), viewport))
|
|
1170
|
-
return true;
|
|
1171
|
-
}
|
|
1172
|
-
return false;
|
|
1173
|
-
}
|
|
1174
1117
|
/**
|
|
1175
1118
|
* Re-arm the animation tick after a change that may have brought an
|
|
1176
1119
|
* animated shape into (or out of) view — pan / zoom / scene edit.
|
|
@@ -1178,93 +1121,21 @@ export class Editor {
|
|
|
1178
1121
|
* `isAnimated()` is false, so this is cheap to call from `notify()`.
|
|
1179
1122
|
*/
|
|
1180
1123
|
maybeAnimate() {
|
|
1181
|
-
|
|
1182
|
-
this.animationTick.start();
|
|
1183
|
-
}
|
|
1184
|
-
/** Bound `visibilitychange` handler — pause/resume the tick. */
|
|
1185
|
-
onVisibilityChange = () => {
|
|
1186
|
-
if (typeof document === "undefined")
|
|
1187
|
-
return;
|
|
1188
|
-
if (document.hidden) {
|
|
1189
|
-
this.animationTick.stop();
|
|
1190
|
-
}
|
|
1191
|
-
else {
|
|
1192
|
-
this.maybeAnimate();
|
|
1193
|
-
}
|
|
1194
|
-
};
|
|
1195
|
-
// ── Per-shape GIF playback (auto-stop + reduced-motion) ──────
|
|
1196
|
-
/**
|
|
1197
|
-
* Transient per-shape playback state for animated images. `originMs`
|
|
1198
|
-
* is the wall-clock the current play run started; `frozenMs` is the
|
|
1199
|
-
* playback offset a paused shape is held at. Not serialised — purely
|
|
1200
|
-
* a runtime view, rebuilt on insert / rehydrate.
|
|
1201
|
-
*/
|
|
1202
|
-
playbackState = new Map();
|
|
1203
|
-
/** Element id currently hovered — a hovered heavy GIF keeps playing
|
|
1204
|
-
* (its auto-stop timer is held off). Set by the pointer hover path. */
|
|
1205
|
-
hoveredAnimatedId = null;
|
|
1206
|
-
static nowMs() {
|
|
1207
|
-
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
1208
|
-
}
|
|
1209
|
-
static prefersReducedMotion() {
|
|
1210
|
-
if (typeof matchMedia !== "function")
|
|
1211
|
-
return false;
|
|
1212
|
-
try {
|
|
1213
|
-
return matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
1214
|
-
}
|
|
1215
|
-
catch {
|
|
1216
|
-
return false;
|
|
1217
|
-
}
|
|
1124
|
+
this.animation.maybe();
|
|
1218
1125
|
}
|
|
1219
1126
|
/**
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1127
|
+
* Per-shape GIF playback state (auto-stop + reduced-motion). Extracted into
|
|
1128
|
+
* a controller; the Editor keeps the orchestration (scene iteration,
|
|
1129
|
+
* animation tick, render scheduling) and delegates state ops here.
|
|
1222
1130
|
*/
|
|
1223
|
-
|
|
1224
|
-
if (this.playbackState.has(id))
|
|
1225
|
-
return;
|
|
1226
|
-
const now = Editor.nowMs();
|
|
1227
|
-
this.playbackState.set(id, {
|
|
1228
|
-
playing: !Editor.prefersReducedMotion(),
|
|
1229
|
-
originMs: now,
|
|
1230
|
-
playStartMs: now,
|
|
1231
|
-
frozenMs: 0,
|
|
1232
|
-
});
|
|
1233
|
-
}
|
|
1234
|
-
/** Playback timestamp fed to the renderer's animation clock for a
|
|
1235
|
-
* shape: wall-clock when unmanaged, play offset when playing, the
|
|
1236
|
-
* frozen frame when paused. */
|
|
1237
|
-
playbackClock(elementId) {
|
|
1238
|
-
const st = this.playbackState.get(elementId);
|
|
1239
|
-
const now = Editor.nowMs();
|
|
1240
|
-
if (!st)
|
|
1241
|
-
return now;
|
|
1242
|
-
return st.playing ? now - st.originMs : st.frozenMs;
|
|
1243
|
-
}
|
|
1131
|
+
gifPlayback = new GifPlaybackController();
|
|
1244
1132
|
/**
|
|
1245
1133
|
* Toggle GIF playback for a shape — wired to a click on an animated
|
|
1246
1134
|
* image (resume after auto-stop, play after reduced-motion). Resuming
|
|
1247
1135
|
* continues from the frozen frame.
|
|
1248
1136
|
*/
|
|
1249
1137
|
togglePlayback(id) {
|
|
1250
|
-
|
|
1251
|
-
const st = this.playbackState.get(id);
|
|
1252
|
-
if (!st) {
|
|
1253
|
-
this.playbackState.set(id, { playing: true, originMs: now, playStartMs: now, frozenMs: 0 });
|
|
1254
|
-
}
|
|
1255
|
-
else if (st.playing) {
|
|
1256
|
-
st.frozenMs = now - st.originMs;
|
|
1257
|
-
st.playing = false;
|
|
1258
|
-
}
|
|
1259
|
-
else {
|
|
1260
|
-
// Resume from the frozen frame AND restart the auto-stop timer,
|
|
1261
|
-
// otherwise a heavy GIF (frozen past GIF_AUTOSTOP_MS) would
|
|
1262
|
-
// re-trip auto-stop on the very next tick — playing one frame
|
|
1263
|
-
// then freezing again.
|
|
1264
|
-
st.originMs = now - st.frozenMs;
|
|
1265
|
-
st.playStartMs = now;
|
|
1266
|
-
st.playing = true;
|
|
1267
|
-
}
|
|
1138
|
+
this.gifPlayback.toggle(id);
|
|
1268
1139
|
this.maybeAnimate();
|
|
1269
1140
|
this.scheduleRender();
|
|
1270
1141
|
}
|
|
@@ -1275,24 +1146,14 @@ export class Editor {
|
|
|
1275
1146
|
* Pass `null` when the pointer leaves all shapes.
|
|
1276
1147
|
*/
|
|
1277
1148
|
hoverAnimatedElement(id) {
|
|
1278
|
-
if (this.
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
if (id !== null) {
|
|
1282
|
-
const st = this.playbackState.get(id);
|
|
1283
|
-
const now = Editor.nowMs();
|
|
1284
|
-
if (st && !st.playing) {
|
|
1285
|
-
st.originMs = now - st.frozenMs;
|
|
1286
|
-
st.playStartMs = now;
|
|
1287
|
-
st.playing = true;
|
|
1288
|
-
this.maybeAnimate();
|
|
1289
|
-
this.scheduleRender();
|
|
1290
|
-
}
|
|
1149
|
+
if (this.gifPlayback.hoverEnter(id)) {
|
|
1150
|
+
this.maybeAnimate();
|
|
1151
|
+
this.scheduleRender();
|
|
1291
1152
|
}
|
|
1292
1153
|
}
|
|
1293
1154
|
/** True when the shape's GIF is paused (drives the overlay badge). */
|
|
1294
1155
|
isPlaybackPaused(id) {
|
|
1295
|
-
return this.
|
|
1156
|
+
return this.gifPlayback.isPaused(id);
|
|
1296
1157
|
}
|
|
1297
1158
|
/**
|
|
1298
1159
|
* Record the idle cursor position so the overlay can grow the SINGLE
|
|
@@ -1315,74 +1176,6 @@ export class Editor {
|
|
|
1315
1176
|
const t = this.hoveredLinkTarget;
|
|
1316
1177
|
return t ? { elementId: t.elementId, mode: t.mode } : null;
|
|
1317
1178
|
}
|
|
1318
|
-
/**
|
|
1319
|
-
* Freeze heavy GIFs after `GIF_AUTOSTOP_MS` of continuous play.
|
|
1320
|
-
* Light GIFs (small byte payload) loop forever. Called from the tick
|
|
1321
|
-
* before each animation render.
|
|
1322
|
-
*/
|
|
1323
|
-
autoStopHeavyGifs() {
|
|
1324
|
-
const now = Editor.nowMs();
|
|
1325
|
-
for (const shape of this._scene.elements.values()) {
|
|
1326
|
-
if (shape.type !== "image")
|
|
1327
|
-
continue;
|
|
1328
|
-
const img = shape;
|
|
1329
|
-
if (!img.animationKind)
|
|
1330
|
-
continue;
|
|
1331
|
-
const st = this.playbackState.get(img.id);
|
|
1332
|
-
if (!st?.playing)
|
|
1333
|
-
continue;
|
|
1334
|
-
const heavy = img.animationData instanceof ArrayBuffer && img.animationData.byteLength > HEAVY_GIF_BYTES;
|
|
1335
|
-
if (!heavy)
|
|
1336
|
-
continue;
|
|
1337
|
-
// Hovered heavy GIF keeps playing — push its timer forward so it
|
|
1338
|
-
// never auto-stops while the pointer is over it.
|
|
1339
|
-
if (img.id === this.hoveredAnimatedId) {
|
|
1340
|
-
st.playStartMs = now;
|
|
1341
|
-
continue;
|
|
1342
|
-
}
|
|
1343
|
-
if (now - st.playStartMs > GIF_AUTOSTOP_MS) {
|
|
1344
|
-
st.frozenMs = now - st.originMs;
|
|
1345
|
-
st.playing = false;
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
/**
|
|
1350
|
-
* Restore transient `animationData` for animated image shapes after
|
|
1351
|
-
* a scene load. The raw GIF bytes don't survive serialisation
|
|
1352
|
-
* (`serializeScene` strips the ArrayBuffer), but they're persisted
|
|
1353
|
-
* in `Scene.files` via the shape's `fileId`. Here we copy the bytes
|
|
1354
|
-
* back onto `shape.animationData` so the registered animation
|
|
1355
|
-
* adapter (host-side, e.g. the gifuct decoder) can produce frames.
|
|
1356
|
-
*
|
|
1357
|
-
* Applied directly to `_scene` (no history entry — this is an
|
|
1358
|
-
* internal rehydration, not a user edit). No-op for shapes that
|
|
1359
|
-
* already carry live `animationData` or lack a resolvable file.
|
|
1360
|
-
*/
|
|
1361
|
-
rehydrateAnimatedImages() {
|
|
1362
|
-
for (const shape of this._scene.elements.values()) {
|
|
1363
|
-
if (shape.type !== "image")
|
|
1364
|
-
continue;
|
|
1365
|
-
const img = shape;
|
|
1366
|
-
if (!img.animationKind)
|
|
1367
|
-
continue;
|
|
1368
|
-
// Seed playback for every animated shape loaded from the scene
|
|
1369
|
-
// (reduced-motion is honoured at this point too).
|
|
1370
|
-
this.initPlayback(img.id);
|
|
1371
|
-
if (!img.fileId)
|
|
1372
|
-
continue;
|
|
1373
|
-
if (img.animationData instanceof ArrayBuffer)
|
|
1374
|
-
continue; // already live
|
|
1375
|
-
const file = getBinaryFile(this._scene, img.fileId);
|
|
1376
|
-
if (!file)
|
|
1377
|
-
continue;
|
|
1378
|
-
this._scene = apply(this._scene, {
|
|
1379
|
-
kind: "element",
|
|
1380
|
-
id: img.id,
|
|
1381
|
-
before: img,
|
|
1382
|
-
after: { ...img, animationData: file.data },
|
|
1383
|
-
});
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
1179
|
/**
|
|
1387
1180
|
* Drag-to-place flow for palette templates. Adds the shape to the
|
|
1388
1181
|
* scene immediately so the user sees it dragging under the cursor,
|
|
@@ -1393,7 +1186,6 @@ export class Editor {
|
|
|
1393
1186
|
* Typical wiring: HTML5 dragenter starts the placement, dragover
|
|
1394
1187
|
* updates, drop commits, dragleave / window keydown(Escape) cancel.
|
|
1395
1188
|
*/
|
|
1396
|
-
// Placement helpers live in `./editor/public/placement.ts`.
|
|
1397
1189
|
// Editor owns the transaction lifecycle and selection mutate;
|
|
1398
1190
|
// the closure threads scene mutations through the pure helpers.
|
|
1399
1191
|
beginPlacement(shape) {
|
|
@@ -1437,7 +1229,6 @@ export class Editor {
|
|
|
1437
1229
|
},
|
|
1438
1230
|
};
|
|
1439
1231
|
}
|
|
1440
|
-
// Pure body in `./editor/public/selection-ops.ts`.
|
|
1441
1232
|
deleteSelected() {
|
|
1442
1233
|
const result = computeDeleteSelection(this._scene, this._selection, this._selectedLinks);
|
|
1443
1234
|
if (!result)
|
|
@@ -1498,8 +1289,9 @@ export class Editor {
|
|
|
1498
1289
|
_textSel = null;
|
|
1499
1290
|
/** Anchor offset for a canvas drag-select inside the edited text. */
|
|
1500
1291
|
_textDragAnchor = null;
|
|
1501
|
-
|
|
1502
|
-
|
|
1292
|
+
caretBlink = new CaretBlinkController(() => {
|
|
1293
|
+
this.notify();
|
|
1294
|
+
});
|
|
1503
1295
|
get editingTextSelection() {
|
|
1504
1296
|
return this._textSel;
|
|
1505
1297
|
}
|
|
@@ -1510,41 +1302,17 @@ export class Editor {
|
|
|
1510
1302
|
return this._textSel.dir === "backward" ? this._textSel.start : this._textSel.end;
|
|
1511
1303
|
}
|
|
1512
1304
|
get caretBlinkOn() {
|
|
1513
|
-
return this.
|
|
1305
|
+
return this.caretBlink.on;
|
|
1514
1306
|
}
|
|
1515
1307
|
/** `true` while a canvas drag-select inside the edited text is active. */
|
|
1516
1308
|
get isTextDragging() {
|
|
1517
1309
|
return this._textDragAnchor !== null;
|
|
1518
1310
|
}
|
|
1519
|
-
startCaretBlink() {
|
|
1520
|
-
this._caretBlinkOn = true;
|
|
1521
|
-
this.stopCaretBlink();
|
|
1522
|
-
// Only run the blink when a DOM clock exists (browser host). Node
|
|
1523
|
-
// test envs construct the editor without a window — skip so a
|
|
1524
|
-
// dangling interval can't keep the process alive.
|
|
1525
|
-
if (typeof window === "undefined")
|
|
1526
|
-
return;
|
|
1527
|
-
this._caretBlinkTimer = setInterval(() => {
|
|
1528
|
-
this._caretBlinkOn = !this._caretBlinkOn;
|
|
1529
|
-
this.notify();
|
|
1530
|
-
}, CARET_BLINK_INTERVAL_MS);
|
|
1531
|
-
}
|
|
1532
|
-
stopCaretBlink() {
|
|
1533
|
-
if (this._caretBlinkTimer !== null) {
|
|
1534
|
-
clearInterval(this._caretBlinkTimer);
|
|
1535
|
-
this._caretBlinkTimer = null;
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1538
|
-
/** Reset the caret to solid (called on type / move so it never blinks off mid-action). */
|
|
1539
|
-
wakeCaret() {
|
|
1540
|
-
this._caretBlinkOn = true;
|
|
1541
|
-
}
|
|
1542
1311
|
/**
|
|
1543
1312
|
* Begin editing a text shape's body. No-op when the shape doesn't
|
|
1544
1313
|
* exist or isn't a text shape. Concurrent edits commit themselves
|
|
1545
1314
|
* (only one shape at a time). Caret defaults to the end of the text.
|
|
1546
1315
|
*/
|
|
1547
|
-
// Pure bodies in `./editor/public/text-edit.ts`.
|
|
1548
1316
|
/** Open inline caption editing for a link (double-click). */
|
|
1549
1317
|
beginLinkCaptionEdit(id) {
|
|
1550
1318
|
if (!getLink(this._scene, id))
|
|
@@ -1604,12 +1372,12 @@ export class Editor {
|
|
|
1604
1372
|
const t = edge.label?.position ?? 0.5;
|
|
1605
1373
|
let total = 0;
|
|
1606
1374
|
for (let i = 1; i < path.length; i++)
|
|
1607
|
-
total +=
|
|
1375
|
+
total += vec2.distance(req(path[i - 1]), req(path[i]));
|
|
1608
1376
|
let remaining = total * t;
|
|
1609
1377
|
for (let i = 1; i < path.length; i++) {
|
|
1610
1378
|
const a = req(path[i - 1]);
|
|
1611
1379
|
const b = req(path[i]);
|
|
1612
|
-
const seg =
|
|
1380
|
+
const seg = vec2.distance(a, b);
|
|
1613
1381
|
if (remaining <= seg) {
|
|
1614
1382
|
const r = seg === 0 ? 0 : remaining / seg;
|
|
1615
1383
|
return { x: a.x + (b.x - a.x) * r, y: a.y + (b.y - a.y) * r };
|
|
@@ -1630,7 +1398,7 @@ export class Editor {
|
|
|
1630
1398
|
const shape = getElement(this._scene, id);
|
|
1631
1399
|
const len = shape?.text.length ?? 0;
|
|
1632
1400
|
this._textSel = { start: len, end: len, dir: "forward" };
|
|
1633
|
-
this.
|
|
1401
|
+
this.caretBlink.start();
|
|
1634
1402
|
this.notify();
|
|
1635
1403
|
}
|
|
1636
1404
|
// --- Frame name inline editing (double-click the header) ---
|
|
@@ -1640,7 +1408,7 @@ export class Editor {
|
|
|
1640
1408
|
*/
|
|
1641
1409
|
beginFrameNameEdit(id) {
|
|
1642
1410
|
const shape = getElement(this._scene, id);
|
|
1643
|
-
if (shape
|
|
1411
|
+
if (shape === undefined || !isFrame(shape))
|
|
1644
1412
|
return;
|
|
1645
1413
|
if (this.isLayerLocked(shape.layerId))
|
|
1646
1414
|
return;
|
|
@@ -1659,23 +1427,10 @@ export class Editor {
|
|
|
1659
1427
|
if (id === null)
|
|
1660
1428
|
return;
|
|
1661
1429
|
this._editingFrameName = null;
|
|
1662
|
-
const
|
|
1663
|
-
if (
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
if (trimmed !== current) {
|
|
1667
|
-
const r = updateElement(this._scene, id, (s) => {
|
|
1668
|
-
const copy = { ...s };
|
|
1669
|
-
// `exactOptionalPropertyTypes`: drop the key when cleared.
|
|
1670
|
-
if (trimmed === "")
|
|
1671
|
-
delete copy.name;
|
|
1672
|
-
else
|
|
1673
|
-
copy.name = trimmed;
|
|
1674
|
-
return copy;
|
|
1675
|
-
});
|
|
1676
|
-
this._scene = r.scene;
|
|
1677
|
-
this._history.push(r.patch);
|
|
1678
|
-
}
|
|
1430
|
+
const r = computeFrameNameCommit(this._scene, id, name);
|
|
1431
|
+
if (r) {
|
|
1432
|
+
this._scene = r.scene;
|
|
1433
|
+
this._history.push(r.patch);
|
|
1679
1434
|
}
|
|
1680
1435
|
this.notify();
|
|
1681
1436
|
}
|
|
@@ -1693,25 +1448,7 @@ export class Editor {
|
|
|
1693
1448
|
* hit-test bounds. Assumes unrotated frames (the common case).
|
|
1694
1449
|
*/
|
|
1695
1450
|
frameHeaderAt(p) {
|
|
1696
|
-
|
|
1697
|
-
let bestOrder = "";
|
|
1698
|
-
for (const s of this._scene.elements.values()) {
|
|
1699
|
-
if (!isFrame(s))
|
|
1700
|
-
continue;
|
|
1701
|
-
const hx = s.position.x;
|
|
1702
|
-
// The header strip can extend up to the frame's full width (it hugs
|
|
1703
|
-
// the label but is capped there), so the rename hit zone spans it.
|
|
1704
|
-
const hw = s.width * s.scale.x;
|
|
1705
|
-
const hh = FRAME_HEADER_HEIGHT * s.scale.y;
|
|
1706
|
-
const hyTop = s.position.y - hh;
|
|
1707
|
-
if (p.x >= hx && p.x <= hx + hw && p.y >= hyTop && p.y <= hyTop + hh) {
|
|
1708
|
-
if (bestId === null || s.order > bestOrder) {
|
|
1709
|
-
bestId = s.id;
|
|
1710
|
-
bestOrder = s.order;
|
|
1711
|
-
}
|
|
1712
|
-
}
|
|
1713
|
-
}
|
|
1714
|
-
return bestId;
|
|
1451
|
+
return computeFrameHeaderAt(this._scene, p);
|
|
1715
1452
|
}
|
|
1716
1453
|
/**
|
|
1717
1454
|
* Live edit transport from the hidden `<textarea>`: replace the
|
|
@@ -1726,7 +1463,7 @@ export class Editor {
|
|
|
1726
1463
|
const r = updateElement(this._scene, id, (s) => ({ ...s, text: value }));
|
|
1727
1464
|
this._scene = r.scene;
|
|
1728
1465
|
this._textSel = { start: selStart, end: selEnd, dir };
|
|
1729
|
-
this.
|
|
1466
|
+
this.caretBlink.wake();
|
|
1730
1467
|
this.notify();
|
|
1731
1468
|
}
|
|
1732
1469
|
/** Selection-only update (arrows / shift-select / click) — no text change. */
|
|
@@ -1734,7 +1471,7 @@ export class Editor {
|
|
|
1734
1471
|
if (!this._editingTextElement)
|
|
1735
1472
|
return;
|
|
1736
1473
|
this._textSel = { start: selStart, end: selEnd, dir };
|
|
1737
|
-
this.
|
|
1474
|
+
this.caretBlink.wake();
|
|
1738
1475
|
this.notify();
|
|
1739
1476
|
}
|
|
1740
1477
|
/**
|
|
@@ -1752,9 +1489,15 @@ export class Editor {
|
|
|
1752
1489
|
const layout = this.editingTextLayout(shape);
|
|
1753
1490
|
if (!layout)
|
|
1754
1491
|
return null;
|
|
1755
|
-
// World → shape-local
|
|
1756
|
-
//
|
|
1757
|
-
|
|
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
|
+
};
|
|
1758
1501
|
const align = shape.style.textAlign ?? "left";
|
|
1759
1502
|
return pointToCaretIndex(layout, local, this.measureFor(shape), align);
|
|
1760
1503
|
}
|
|
@@ -1842,18 +1585,24 @@ export class Editor {
|
|
|
1842
1585
|
const align = shape.style.textAlign ?? "left";
|
|
1843
1586
|
const measure = this.measureFor(shape);
|
|
1844
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;
|
|
1845
1594
|
const local = textSelectionRects(layout, this._textSel.start, this._textSel.end, measure, align);
|
|
1846
1595
|
const selectionRects = local.map((r) => ({
|
|
1847
|
-
x: px + r.x,
|
|
1848
|
-
y: py + r.y,
|
|
1849
|
-
width: r.width,
|
|
1850
|
-
height: r.height,
|
|
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),
|
|
1851
1600
|
}));
|
|
1852
1601
|
let caret = null;
|
|
1853
|
-
if (this.
|
|
1602
|
+
if (this.caretBlink.on) {
|
|
1854
1603
|
const cIdx = this._textSel.dir === "backward" ? this._textSel.start : this._textSel.end;
|
|
1855
1604
|
const g = caretGeometry(layout, cIdx, measure, shape.fontSize, align);
|
|
1856
|
-
caret = { x: px + g.x, y: py + g.y, height: g.height };
|
|
1605
|
+
caret = { x: px + g.x * sx, y: py + g.y * sy, height: g.height * Math.abs(sy) };
|
|
1857
1606
|
}
|
|
1858
1607
|
return { caret, caretColor: shape.style.fill ?? "#1a1a1a", selectionRects };
|
|
1859
1608
|
}
|
|
@@ -1872,7 +1621,7 @@ export class Editor {
|
|
|
1872
1621
|
this._pendingTextCreate = null;
|
|
1873
1622
|
this._textEditOrigin = null;
|
|
1874
1623
|
this._textSel = null;
|
|
1875
|
-
this.
|
|
1624
|
+
this.caretBlink.stop();
|
|
1876
1625
|
const finalElement = getElement(this._scene, id);
|
|
1877
1626
|
const text = finalElement?.text ?? "";
|
|
1878
1627
|
// Empty (whitespace-only) text removes the shape. Pending = silent
|
|
@@ -1917,7 +1666,7 @@ export class Editor {
|
|
|
1917
1666
|
this._pendingTextCreate = null;
|
|
1918
1667
|
this._textEditOrigin = null;
|
|
1919
1668
|
this._textSel = null;
|
|
1920
|
-
this.
|
|
1669
|
+
this.caretBlink.stop();
|
|
1921
1670
|
// Revert live edits with no history entry. Pending creations are
|
|
1922
1671
|
// removed entirely; existing shapes have only their TEXT restored
|
|
1923
1672
|
// (panel-driven field changes during the edit keep their own
|
|
@@ -1941,7 +1690,6 @@ export class Editor {
|
|
|
1941
1690
|
* keyboard navigation; hosts pass `{ x: 1, y: 0 }` for fine nudge
|
|
1942
1691
|
* and `{ x: 10, y: 0 }` for shift-arrow.
|
|
1943
1692
|
*/
|
|
1944
|
-
// Pure body in `./editor/public/selection-ops.ts`.
|
|
1945
1693
|
moveSelectionBy(delta) {
|
|
1946
1694
|
if (this._selection.size === 0 && this._selectedLinks.size === 0)
|
|
1947
1695
|
return;
|
|
@@ -1980,7 +1728,6 @@ export class Editor {
|
|
|
1980
1728
|
* Hosts can bind this to "Enter" while in a draw mode, providing a
|
|
1981
1729
|
* mouse-free alternative to drag-out creation.
|
|
1982
1730
|
*/
|
|
1983
|
-
// Pure body in `./editor/public/placement.ts`.
|
|
1984
1731
|
createElementAtCursor() {
|
|
1985
1732
|
const vp = this._scene.viewport;
|
|
1986
1733
|
const world = this.screenToWorld({
|
|
@@ -2020,7 +1767,6 @@ export class Editor {
|
|
|
2020
1767
|
this.beginTextEdit(id);
|
|
2021
1768
|
return id;
|
|
2022
1769
|
}
|
|
2023
|
-
// Pure bodies in `./editor/public/brush.ts`.
|
|
2024
1770
|
beginBrushStroke(world, pressure = 0.5) {
|
|
2025
1771
|
this.brushStroke = beginBrushStrokePure(world, pressure);
|
|
2026
1772
|
this.notify();
|
|
@@ -2054,7 +1800,6 @@ export class Editor {
|
|
|
2054
1800
|
get pendingBrushStroke() {
|
|
2055
1801
|
return this.brushStroke;
|
|
2056
1802
|
}
|
|
2057
|
-
// Pure bodies in `./editor/public/arrange-group.ts`.
|
|
2058
1803
|
arrangeAsGrid(opts = {}) {
|
|
2059
1804
|
const origin = this.combinedSelectionBounds() ?? { x: 0, y: 0 };
|
|
2060
1805
|
const result = computeArrangeAsGrid(this._scene, this._selection, opts, origin);
|
|
@@ -2156,7 +1901,6 @@ export class Editor {
|
|
|
2156
1901
|
* Duplicate the selected shapes 10 px down-right of the originals.
|
|
2157
1902
|
* Links between selected shapes are NOT cloned. Single undo step.
|
|
2158
1903
|
*/
|
|
2159
|
-
// Pure body in `./editor/public/selection-ops.ts`.
|
|
2160
1904
|
duplicateSelected() {
|
|
2161
1905
|
const result = computeDuplicateSelection(this._scene, this._selection, () => ++this.nextId);
|
|
2162
1906
|
if (!result)
|
|
@@ -2199,32 +1943,11 @@ export class Editor {
|
|
|
2199
1943
|
const idMap = new Map();
|
|
2200
1944
|
for (const id of ids)
|
|
2201
1945
|
idMap.set(id, castElementId(this.uniqueId("shape")));
|
|
1946
|
+
const dup = computeDuplicateInPlace(this._scene, ids, idMap);
|
|
2202
1947
|
const tx = this._history.transaction();
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
continue;
|
|
2207
|
-
const newId = idMap.get(id);
|
|
2208
|
-
if (newId === undefined)
|
|
2209
|
-
continue;
|
|
2210
|
-
const order = orderForTop([...this._scene.elements.values()]
|
|
2211
|
-
.filter((sh) => sh.layerId === shape.layerId)
|
|
2212
|
-
.map((sh) => sh.order));
|
|
2213
|
-
const copy = { ...shape, id: newId, order };
|
|
2214
|
-
if (copy.parentId !== undefined) {
|
|
2215
|
-
const mapped = idMap.get(copy.parentId);
|
|
2216
|
-
if (mapped !== undefined)
|
|
2217
|
-
copy.parentId = mapped;
|
|
2218
|
-
}
|
|
2219
|
-
if (copy.frameId !== undefined) {
|
|
2220
|
-
const mapped = idMap.get(copy.frameId);
|
|
2221
|
-
if (mapped !== undefined)
|
|
2222
|
-
copy.frameId = mapped;
|
|
2223
|
-
}
|
|
2224
|
-
const r = addElement(this._scene, copy);
|
|
2225
|
-
this._scene = r.scene;
|
|
2226
|
-
tx.add(r.patch);
|
|
2227
|
-
}
|
|
1948
|
+
this._scene = dup.scene;
|
|
1949
|
+
for (const patch of dup.patches)
|
|
1950
|
+
tx.add(patch);
|
|
2228
1951
|
tx.commit();
|
|
2229
1952
|
// Select the clones of the originally-selected ids.
|
|
2230
1953
|
const selectedClones = [];
|
|
@@ -2269,7 +1992,6 @@ export class Editor {
|
|
|
2269
1992
|
* `navigator.clipboard` (out of scope for the editor).
|
|
2270
1993
|
*/
|
|
2271
1994
|
clipboard = [];
|
|
2272
|
-
// Pure body in `./editor/public/clipboard.ts`.
|
|
2273
1995
|
copySelected() {
|
|
2274
1996
|
const out = copySelectedPure(this._scene, this._selection);
|
|
2275
1997
|
if (out.length === 0)
|
|
@@ -2290,7 +2012,6 @@ export class Editor {
|
|
|
2290
2012
|
*
|
|
2291
2013
|
* New shapes get fresh ids and end up selected. Single undo step.
|
|
2292
2014
|
*/
|
|
2293
|
-
// Pure body in `./editor/public/clipboard.ts`.
|
|
2294
2015
|
paste(targetWorld) {
|
|
2295
2016
|
if (this.clipboard.length === 0)
|
|
2296
2017
|
return;
|
|
@@ -2315,7 +2036,30 @@ export class Editor {
|
|
|
2315
2036
|
*
|
|
2316
2037
|
* No-op when `ids` is empty or none of the targeted shapes exist.
|
|
2317
2038
|
*/
|
|
2318
|
-
|
|
2039
|
+
/**
|
|
2040
|
+
* Capture the visual style of the first selected element into the style
|
|
2041
|
+
* buffer, for a later {@link pasteSelectionStyle}. No-op / clears nothing
|
|
2042
|
+
* when the selection is empty.
|
|
2043
|
+
*/
|
|
2044
|
+
copySelectionStyle() {
|
|
2045
|
+
for (const id of this._selection) {
|
|
2046
|
+
const el = getElement(this._scene, id);
|
|
2047
|
+
if (el !== undefined) {
|
|
2048
|
+
this.styleClipboard = { ...el.style };
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
/** Apply the copied style (if any) to every selected element. One undo step. */
|
|
2054
|
+
pasteSelectionStyle() {
|
|
2055
|
+
if (this.styleClipboard === null)
|
|
2056
|
+
return;
|
|
2057
|
+
this.updateStyle(this._selection, this.styleClipboard);
|
|
2058
|
+
}
|
|
2059
|
+
/** Whether a style has been copied and can be pasted. */
|
|
2060
|
+
get hasStyleClipboard() {
|
|
2061
|
+
return this.styleClipboard !== null;
|
|
2062
|
+
}
|
|
2319
2063
|
updateStyle(ids, partial) {
|
|
2320
2064
|
const result = computeUpdateStyle(this._scene, ids, partial);
|
|
2321
2065
|
if (!result)
|
|
@@ -2337,6 +2081,19 @@ export class Editor {
|
|
|
2337
2081
|
this._history.push(result.patch);
|
|
2338
2082
|
this.notify();
|
|
2339
2083
|
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Step the font size of every selected text shape up or down by one gentle
|
|
2086
|
+
* multiplicative increment (each shape relative to its own size). One
|
|
2087
|
+
* undoable step; no-op when no text is selected.
|
|
2088
|
+
*/
|
|
2089
|
+
adjustSelectionFontSize(direction) {
|
|
2090
|
+
const result = computeAdjustFontSize(this._scene, this._selection, direction);
|
|
2091
|
+
if (!result)
|
|
2092
|
+
return;
|
|
2093
|
+
this._scene = result.scene;
|
|
2094
|
+
this._history.push(result.patch);
|
|
2095
|
+
this.notify();
|
|
2096
|
+
}
|
|
2340
2097
|
/**
|
|
2341
2098
|
* Set (or clear, with `null`) the element-level hyperlink (`href`) on
|
|
2342
2099
|
* every shape in `ids`. Single undo step. Pass a raw user string — it
|
|
@@ -2383,7 +2140,6 @@ export class Editor {
|
|
|
2383
2140
|
return null;
|
|
2384
2141
|
return { id: shape.id, href, bounds: getElementWorldBounds(shape) };
|
|
2385
2142
|
}
|
|
2386
|
-
// Pure bodies in `./editor/public/z-order.ts`.
|
|
2387
2143
|
bringToFront(id) {
|
|
2388
2144
|
const result = computeBringToFront(this._scene, id, this._selection);
|
|
2389
2145
|
if (!result)
|
|
@@ -2418,6 +2174,62 @@ export class Editor {
|
|
|
2418
2174
|
this._history.push(result.patch);
|
|
2419
2175
|
this.notify();
|
|
2420
2176
|
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Mirror the current selection about its bounding-box centre on the given
|
|
2179
|
+
* axis — `horizontal` flips left↔right, `vertical` flips top↔bottom. A single
|
|
2180
|
+
* element flips about its own centre. One undoable step.
|
|
2181
|
+
*/
|
|
2182
|
+
flipSelection(axis) {
|
|
2183
|
+
this.commitArrange(computeFlipPatches(this._scene, this._selection, axis));
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* Align the selection to the given edge / centre line of its bounding box
|
|
2187
|
+
* (e.g. `left` flushes every shape's left edge; `h-center` lines up
|
|
2188
|
+
* horizontal centres). Needs two or more elements. One undoable step.
|
|
2189
|
+
*/
|
|
2190
|
+
alignSelection(edge) {
|
|
2191
|
+
this.commitArrange(computeAlignPatches(this._scene, this._selection, edge));
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* Evenly space the selection along the given axis so the gaps between
|
|
2195
|
+
* adjacent shapes are equal; the outermost shapes stay put. Needs three or
|
|
2196
|
+
* more elements. One undoable step.
|
|
2197
|
+
*/
|
|
2198
|
+
distributeSelection(axis) {
|
|
2199
|
+
this.commitArrange(computeDistributePatches(this._scene, this._selection, axis));
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* Rotate the whole selection by `delta` radians about its bounding-box centre
|
|
2203
|
+
* (a single shape turns about its own centre). One undoable step. The live
|
|
2204
|
+
* rotate gesture drives the same maths from a press-time snapshot.
|
|
2205
|
+
*/
|
|
2206
|
+
rotateSelection(delta) {
|
|
2207
|
+
const elements = [];
|
|
2208
|
+
const origin = new Map();
|
|
2209
|
+
for (const id of this._selection) {
|
|
2210
|
+
const el = getElement(this._scene, id);
|
|
2211
|
+
if (el === undefined)
|
|
2212
|
+
continue;
|
|
2213
|
+
elements.push(el);
|
|
2214
|
+
origin.set(id, { position: el.position, rotation: el.rotation });
|
|
2215
|
+
}
|
|
2216
|
+
if (elements.length === 0)
|
|
2217
|
+
return;
|
|
2218
|
+
const pivot = selectionCenter(elements);
|
|
2219
|
+
this.commitArrange(computeRotatePatches(this._scene, origin, pivot, delta));
|
|
2220
|
+
}
|
|
2221
|
+
/** Apply a batch of arrange patches as a single undoable step. */
|
|
2222
|
+
commitArrange(patches) {
|
|
2223
|
+
if (patches.length === 0)
|
|
2224
|
+
return;
|
|
2225
|
+
const tx = this._history.transaction();
|
|
2226
|
+
for (const patch of patches) {
|
|
2227
|
+
this._scene = apply(this._scene, patch);
|
|
2228
|
+
tx.add(patch);
|
|
2229
|
+
}
|
|
2230
|
+
tx.commit();
|
|
2231
|
+
this.notify();
|
|
2232
|
+
}
|
|
2421
2233
|
compactLayerZOrder(layerId, options = {}) {
|
|
2422
2234
|
const recordHistory = options.recordHistory ?? true;
|
|
2423
2235
|
const layerIds = layerId ? [layerId] : [...this._scene.layers.keys()];
|
|
@@ -2468,7 +2280,6 @@ export class Editor {
|
|
|
2468
2280
|
this._activeLayerId = id;
|
|
2469
2281
|
this.notify();
|
|
2470
2282
|
}
|
|
2471
|
-
// Pure bodies in `./editor/public/layers.ts`.
|
|
2472
2283
|
createLayer(name) {
|
|
2473
2284
|
const result = computeCreateLayer(this._scene, name, newLayerId(++this.nextId));
|
|
2474
2285
|
this._scene = result.scene;
|
|
@@ -2532,7 +2343,6 @@ export class Editor {
|
|
|
2532
2343
|
* x → shapes move right relative to the user). Not recorded in
|
|
2533
2344
|
* history — viewport state is editor-local.
|
|
2534
2345
|
*/
|
|
2535
|
-
// Pure bodies in `./editor/public/zoom-pan.ts`.
|
|
2536
2346
|
panBy(deltaScreen) {
|
|
2537
2347
|
const next = computePan(this._scene, deltaScreen);
|
|
2538
2348
|
if (!next)
|
|
@@ -2588,43 +2398,13 @@ export class Editor {
|
|
|
2588
2398
|
selectClosest(direction) {
|
|
2589
2399
|
const ref = this.combinedSelectionBounds();
|
|
2590
2400
|
const vp = this._scene.viewport;
|
|
2591
|
-
const
|
|
2401
|
+
const refCenter = ref
|
|
2592
2402
|
? { x: ref.x + ref.width / 2, y: ref.y + ref.height / 2 }
|
|
2593
2403
|
: {
|
|
2594
2404
|
x: vp.pan.x + vp.size.width / 2 / vp.zoom,
|
|
2595
2405
|
y: vp.pan.y + vp.size.height / 2 / vp.zoom,
|
|
2596
2406
|
};
|
|
2597
|
-
const
|
|
2598
|
-
? { x: -1, y: 0 }
|
|
2599
|
-
: direction === "right"
|
|
2600
|
-
? { x: 1, y: 0 }
|
|
2601
|
-
: direction === "up"
|
|
2602
|
-
? { x: 0, y: -1 }
|
|
2603
|
-
: { x: 0, y: 1 };
|
|
2604
|
-
let best = null;
|
|
2605
|
-
let bestScore = Infinity;
|
|
2606
|
-
for (const s of this._scene.elements.values()) {
|
|
2607
|
-
if (s.parentId !== undefined)
|
|
2608
|
-
continue; // top-level shapes only
|
|
2609
|
-
if (this._selection.has(s.id))
|
|
2610
|
-
continue;
|
|
2611
|
-
if (!this.isElementInteractable(s))
|
|
2612
|
-
continue;
|
|
2613
|
-
const b = getElementWorldBounds(s);
|
|
2614
|
-
const cx = b.x + b.width / 2 - refC.x;
|
|
2615
|
-
const cy = b.y + b.height / 2 - refC.y;
|
|
2616
|
-
const along = cx * dv.x + cy * dv.y;
|
|
2617
|
-
if (along <= 0)
|
|
2618
|
-
continue; // not in the direction's half-plane
|
|
2619
|
-
const perp = Math.abs(cx * dv.y - cy * dv.x);
|
|
2620
|
-
if (perp > along)
|
|
2621
|
-
continue; // outside the 45° cone
|
|
2622
|
-
const score = along + perp;
|
|
2623
|
-
if (score < bestScore) {
|
|
2624
|
-
bestScore = score;
|
|
2625
|
-
best = s.id;
|
|
2626
|
-
}
|
|
2627
|
-
}
|
|
2407
|
+
const best = findClosestInDirection(this._scene, this._selection, direction, refCenter, (s) => this.isElementInteractable(s));
|
|
2628
2408
|
if (best === null)
|
|
2629
2409
|
return;
|
|
2630
2410
|
this.setSelection([best]);
|
|
@@ -2667,22 +2447,28 @@ export class Editor {
|
|
|
2667
2447
|
this.snapSuppressed = suppressed;
|
|
2668
2448
|
}
|
|
2669
2449
|
/**
|
|
2670
|
-
*
|
|
2671
|
-
*
|
|
2672
|
-
* (
|
|
2673
|
-
*
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2450
|
+
* Host hook: mirror the Alt / Shift modifier state so an in-flight resize or
|
|
2451
|
+
* move reacts to it — `alt` resizes about the centre, `shift` locks the
|
|
2452
|
+
* resize aspect ratio (or constrains a move to one axis). The app wires
|
|
2453
|
+
* keydown/keyup of the modifiers to this. Idempotent; never touches history.
|
|
2454
|
+
*/
|
|
2455
|
+
setTransformModifiers(mods) {
|
|
2456
|
+
this.transformAltKey = mods.alt;
|
|
2457
|
+
this.transformShiftKey = mods.shift;
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* True when a gesture should snap. Snapping is coupled to grid display:
|
|
2461
|
+
* it is active only while the grid is enabled (`gridEnabled`) — snapping to
|
|
2462
|
+
* a hidden grid is confusing. `snapToGrid` is an extra programmatic opt-out;
|
|
2463
|
+
* the suppress modifier (Cmd/Ctrl) bypasses snapping for the current gesture.
|
|
2677
2464
|
*/
|
|
2678
2465
|
snapActive() {
|
|
2679
2466
|
const viewport = this._scene.viewport;
|
|
2680
|
-
|
|
2681
|
-
return !this.snapSuppressed && gridShown && isSnapToGridEnabled(viewport);
|
|
2467
|
+
return !this.snapSuppressed && viewport.gridEnabled && isSnapToGridEnabled(viewport);
|
|
2682
2468
|
}
|
|
2683
2469
|
/** World-unit spacing the current gesture snaps to. */
|
|
2684
2470
|
snapSpacing() {
|
|
2685
|
-
return resolveSnapSpacing(
|
|
2471
|
+
return resolveSnapSpacing();
|
|
2686
2472
|
}
|
|
2687
2473
|
/**
|
|
2688
2474
|
* Replace the entire scene (e.g. after `parseScene`). Clears history,
|
|
@@ -2713,7 +2499,7 @@ export class Editor {
|
|
|
2713
2499
|
}
|
|
2714
2500
|
// Restore transient animationData (GIF bytes) from Scene.files
|
|
2715
2501
|
// before the tick so the animation adapter can decode frames.
|
|
2716
|
-
|
|
2502
|
+
animScene.rehydrateAnimatedImages(this);
|
|
2717
2503
|
this.notify();
|
|
2718
2504
|
// Loaded scene may carry animated shapes (e.g. GIF re-imported
|
|
2719
2505
|
// from saved JSON). Re-arm the tick — `metadata.animated` survives
|
|
@@ -2731,20 +2517,14 @@ export class Editor {
|
|
|
2731
2517
|
this.cursorListeners.clear();
|
|
2732
2518
|
this.longPressListeners.clear();
|
|
2733
2519
|
this.announceListeners.clear();
|
|
2734
|
-
this.
|
|
2520
|
+
this.animation.detach();
|
|
2735
2521
|
this.animationContentOff?.();
|
|
2736
|
-
if (typeof document !== "undefined") {
|
|
2737
|
-
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
2738
|
-
}
|
|
2739
2522
|
if (this.renderRafId !== null && typeof cancelAnimationFrame !== "undefined") {
|
|
2740
2523
|
cancelAnimationFrame(this.renderRafId);
|
|
2741
2524
|
this.renderRafId = null;
|
|
2742
2525
|
}
|
|
2743
2526
|
}
|
|
2744
2527
|
// --- Internal ---
|
|
2745
|
-
// Body moved to `./editor/pointer-binding.ts` (~700 lines of
|
|
2746
|
-
// pointer / wheel / keyboard dispatch). The thin wrapper here
|
|
2747
|
-
// preserves the original constructor call site.
|
|
2748
2528
|
bindPointerEvents() {
|
|
2749
2529
|
return bindPointerEventsExternal(this);
|
|
2750
2530
|
}
|
|
@@ -2800,14 +2580,14 @@ export class Editor {
|
|
|
2800
2580
|
isDrawingPhase(ctx) {
|
|
2801
2581
|
return ctx.mode === "draw-rect" || ctx.mode === "draw-ellipse" || ctx.mode === "draw-edge";
|
|
2802
2582
|
}
|
|
2803
|
-
// --- Long-press ---
|
|
2583
|
+
// --- Long-press ---
|
|
2804
2584
|
startLongPress(screenPoint) {
|
|
2805
2585
|
this.longPress.start(screenPoint);
|
|
2806
2586
|
}
|
|
2807
2587
|
cancelLongPress() {
|
|
2808
2588
|
this.longPress.cancel();
|
|
2809
2589
|
}
|
|
2810
|
-
// --- Pinch gesture ---
|
|
2590
|
+
// --- Pinch gesture ---
|
|
2811
2591
|
beginPinch() {
|
|
2812
2592
|
this.pinch.begin([...this.activePointers.values()]);
|
|
2813
2593
|
}
|
|
@@ -2822,9 +2602,9 @@ export class Editor {
|
|
|
2822
2602
|
screenToWorld(point) {
|
|
2823
2603
|
return matrix.applyToPoint(getScreenToWorld(this._scene.viewport), point);
|
|
2824
2604
|
}
|
|
2825
|
-
//
|
|
2826
|
-
//
|
|
2827
|
-
//
|
|
2605
|
+
// Editor passes a narrow context bundle that closes over its
|
|
2606
|
+
// private state + accel helpers (acceleratedElementAt,
|
|
2607
|
+
// isElementInteractable, …).
|
|
2828
2608
|
/**
|
|
2829
2609
|
* Attach target under `worldPoint` for an endpoint-rebind drop: the topmost
|
|
2830
2610
|
* interactable ELEMENT (group-promoted), ignoring link bodies and the dragged
|
|
@@ -2867,87 +2647,10 @@ export class Editor {
|
|
|
2867
2647
|
* out of sync. `worldPoint` defaults to the last known pointer position.
|
|
2868
2648
|
*/
|
|
2869
2649
|
refreshCursor(worldPoint) {
|
|
2870
|
-
const next =
|
|
2650
|
+
const next = computeCursor(this, worldPoint ?? this.lastPointerWorld);
|
|
2871
2651
|
if (this.host.style.cursor !== next)
|
|
2872
2652
|
this.host.style.cursor = next;
|
|
2873
2653
|
}
|
|
2874
|
-
/**
|
|
2875
|
-
* The CSS cursor for the current state. Priority: active gesture → text edit → pan affordance →
|
|
2876
|
-
* draw tool → idle hover hit-test. Pure read of editor state; no side effects.
|
|
2877
|
-
*/
|
|
2878
|
-
computeCursor(p) {
|
|
2879
|
-
// Each outcome is a (role, fallback-keyword) pair; `resolveCursor` returns
|
|
2880
|
-
// a host-registered custom image for that role if one exists, else the
|
|
2881
|
-
// keyword. Roles are the stable override keys (see `setCursorOverride`).
|
|
2882
|
-
const r = (role, keyword) => this.resolveCursor(role, keyword);
|
|
2883
|
-
const resizeRole = (h) => r(RESIZE_ROLE[h], cursorForHandle(h));
|
|
2884
|
-
// 1. Active gestures (highest priority — what the pointer is doing now).
|
|
2885
|
-
if (this.panGesture)
|
|
2886
|
-
return r("pan-active", "grabbing");
|
|
2887
|
-
if (this.linkDragFromAnchor?.moved === true)
|
|
2888
|
-
return r("draw", "crosshair");
|
|
2889
|
-
if (this.isDraggingWaypoint || this.isDraggingSegment)
|
|
2890
|
-
return r("move", "grabbing");
|
|
2891
|
-
if (this.annotationDrag?.moved === true)
|
|
2892
|
-
return r("move", "grabbing");
|
|
2893
|
-
if (this.brushStroke)
|
|
2894
|
-
return r("draw", "crosshair");
|
|
2895
|
-
// Machine-driven drag past the threshold (`gestureTx` opens then): resize
|
|
2896
|
-
// shows the handle's arrow; element / link move shows grabbing.
|
|
2897
|
-
if (this.gestureTx) {
|
|
2898
|
-
const t = this.actor.getSnapshot().context.pressTarget;
|
|
2899
|
-
if (t && (t.kind === "handle" || t.kind === "group-handle"))
|
|
2900
|
-
return resizeRole(t.handle);
|
|
2901
|
-
if (t && (t.kind === "element" || t.kind === "link" || t.kind === "edge-endpoint")) {
|
|
2902
|
-
return r("move", "grabbing");
|
|
2903
|
-
}
|
|
2904
|
-
}
|
|
2905
|
-
// 2. In-canvas text editing → I-beam.
|
|
2906
|
-
if (this.editingTextElement !== null)
|
|
2907
|
-
return r("text", "text");
|
|
2908
|
-
// 3. Pan affordance (idle): Space held or hand tool.
|
|
2909
|
-
if (this.spaceHeld || this.mode === "hand")
|
|
2910
|
-
return r("pan-ready", "grab");
|
|
2911
|
-
// 4. Draw tools (idle, before a gesture starts).
|
|
2912
|
-
switch (this.mode) {
|
|
2913
|
-
case "draw-rect":
|
|
2914
|
-
case "draw-ellipse":
|
|
2915
|
-
case "draw-frame":
|
|
2916
|
-
case "draw-edge":
|
|
2917
|
-
case "brush":
|
|
2918
|
-
return r("draw", "crosshair");
|
|
2919
|
-
case "draw-text":
|
|
2920
|
-
return r("text", "text");
|
|
2921
|
-
default:
|
|
2922
|
-
break;
|
|
2923
|
-
}
|
|
2924
|
-
// 5. Idle hover in select mode — key off the hit-test target.
|
|
2925
|
-
if (p) {
|
|
2926
|
-
if (this.isOverLinkStartDot(p))
|
|
2927
|
-
return r("link-start", "crosshair");
|
|
2928
|
-
const t = this.hitTest(p);
|
|
2929
|
-
switch (t.kind) {
|
|
2930
|
-
case "handle":
|
|
2931
|
-
case "group-handle":
|
|
2932
|
-
return resizeRole(t.handle);
|
|
2933
|
-
case "edge-endpoint":
|
|
2934
|
-
return r("link-handle", "grab");
|
|
2935
|
-
case "annotation":
|
|
2936
|
-
return r("annotation", "pointer");
|
|
2937
|
-
default:
|
|
2938
|
-
return r("default", "default");
|
|
2939
|
-
}
|
|
2940
|
-
}
|
|
2941
|
-
return r("default", "default");
|
|
2942
|
-
}
|
|
2943
|
-
/**
|
|
2944
|
-
* Resolve a cursor role to a CSS `cursor` value: a host-registered custom
|
|
2945
|
-
* image (via {@link setCursorOverride}) if present, else `fallbackKeyword`.
|
|
2946
|
-
*/
|
|
2947
|
-
resolveCursor(role, fallbackKeyword) {
|
|
2948
|
-
const spec = this.cursorOverrides.get(role);
|
|
2949
|
-
return spec === undefined ? fallbackKeyword : cssCursor(spec, fallbackKeyword);
|
|
2950
|
-
}
|
|
2951
2654
|
/**
|
|
2952
2655
|
* Register (or clear, with `null`) a custom cursor image for a state role.
|
|
2953
2656
|
* The image is shown wherever `computeCursor` resolves that role; pass a
|
|
@@ -2963,31 +2666,12 @@ export class Editor {
|
|
|
2963
2666
|
this.refreshCursor();
|
|
2964
2667
|
}
|
|
2965
2668
|
/**
|
|
2966
|
-
*
|
|
2967
|
-
*
|
|
2968
|
-
*
|
|
2969
|
-
* exactly where a press would begin a link.
|
|
2669
|
+
* Read-only lookup of a host-registered cursor override. Used by the cursor
|
|
2670
|
+
* module to resolve a role without exposing the mutable override map (mutate
|
|
2671
|
+
* only via {@link setCursorOverride}, so `refreshCursor` stays in sync).
|
|
2970
2672
|
*/
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
return false;
|
|
2974
|
-
const id = [...this._selection][0];
|
|
2975
|
-
if (id === undefined)
|
|
2976
|
-
return false;
|
|
2977
|
-
const shape = getElement(this._scene, id);
|
|
2978
|
-
if (!shape)
|
|
2979
|
-
return false;
|
|
2980
|
-
const zoom = this._scene.viewport.zoom || 1;
|
|
2981
|
-
const { worldPoints } = anchorOverlayPoints(shape, LINK_START_ANCHOR_OUTSET / zoom);
|
|
2982
|
-
const grab = (ANCHOR_DOT_ACTIVE_RADIUS + this.anchorStartHitSlop) / zoom;
|
|
2983
|
-
const grab2 = grab * grab;
|
|
2984
|
-
for (const wp of worldPoints) {
|
|
2985
|
-
const dx = wp.x - p.x;
|
|
2986
|
-
const dy = wp.y - p.y;
|
|
2987
|
-
if (dx * dx + dy * dy <= grab2)
|
|
2988
|
-
return true;
|
|
2989
|
-
}
|
|
2990
|
-
return false;
|
|
2673
|
+
getCursorOverride(role) {
|
|
2674
|
+
return this.cursorOverrides.get(role);
|
|
2991
2675
|
}
|
|
2992
2676
|
/** True when the given layer exists and is marked `locked`. */
|
|
2993
2677
|
isLayerLocked(layerId) {
|
|
@@ -3075,8 +2759,7 @@ export class Editor {
|
|
|
3075
2759
|
* Topmost group ancestor of `shape` (walks parentId chain, returns
|
|
3076
2760
|
* the highest `type === "group"` parent). `null` if `shape` has no
|
|
3077
2761
|
* group ancestor. Used by drill-down: a double-click on a shape
|
|
3078
|
-
* with a group ancestor enters that group.
|
|
3079
|
-
* `./group-helpers.ts`.
|
|
2762
|
+
* with a group ancestor enters that group.
|
|
3080
2763
|
*/
|
|
3081
2764
|
topGroupAncestor(shape) {
|
|
3082
2765
|
return topGroupAncestorHelper(this._scene, shape);
|
|
@@ -3103,7 +2786,6 @@ export class Editor {
|
|
|
3103
2786
|
* practice, but the guard keeps the contract simple — "what you've
|
|
3104
2787
|
* selected, you can see".
|
|
3105
2788
|
*/
|
|
3106
|
-
// Body moved to `./editor/shape-filters.ts`.
|
|
3107
2789
|
computeHiddenElements() {
|
|
3108
2790
|
return computeHiddenElementsPure(this._scene);
|
|
3109
2791
|
}
|
|
@@ -3133,7 +2815,7 @@ export class Editor {
|
|
|
3133
2815
|
const el = getElement(this._scene, id);
|
|
3134
2816
|
if (!el)
|
|
3135
2817
|
return;
|
|
3136
|
-
const isFrameEl = el
|
|
2818
|
+
const isFrameEl = isFrame(el);
|
|
3137
2819
|
const members = [];
|
|
3138
2820
|
for (const s of this._scene.elements.values()) {
|
|
3139
2821
|
if (s.parentId === id || (isFrameEl && s.frameId === id))
|
|
@@ -3141,7 +2823,7 @@ export class Editor {
|
|
|
3141
2823
|
}
|
|
3142
2824
|
if (members.length === 0)
|
|
3143
2825
|
return;
|
|
3144
|
-
if (el
|
|
2826
|
+
if (isGroup(el))
|
|
3145
2827
|
this._enteredGroup = id;
|
|
3146
2828
|
this.setSelection(members);
|
|
3147
2829
|
}
|
|
@@ -3235,7 +2917,7 @@ export class Editor {
|
|
|
3235
2917
|
const now = performance.now();
|
|
3236
2918
|
const isDouble = now - this.lastClickAt < DOUBLE_CLICK_MS &&
|
|
3237
2919
|
this.lastClickWorldPoint !== null &&
|
|
3238
|
-
|
|
2920
|
+
vec2.distance(this.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
|
|
3239
2921
|
this.lastClickAt = now;
|
|
3240
2922
|
this.lastClickWorldPoint = worldPoint;
|
|
3241
2923
|
// Double-click the frame HEADER (label strip above the body) → rename.
|
|
@@ -3282,11 +2964,11 @@ export class Editor {
|
|
|
3282
2964
|
if (isDouble &&
|
|
3283
2965
|
(clickEffect.type === "SELECT_REPLACE" || clickEffect.type === "SELECT_TOGGLE")) {
|
|
3284
2966
|
const raw = this.acceleratedElementAt(worldPoint);
|
|
3285
|
-
if (raw
|
|
2967
|
+
if (raw !== undefined && isText(raw)) {
|
|
3286
2968
|
this.beginTextEdit(raw.id);
|
|
3287
2969
|
return true;
|
|
3288
2970
|
}
|
|
3289
|
-
if (raw
|
|
2971
|
+
if (raw !== undefined && isFrame(raw)) {
|
|
3290
2972
|
this.beginFrameNameEdit(raw.id);
|
|
3291
2973
|
return true;
|
|
3292
2974
|
}
|
|
@@ -3401,6 +3083,9 @@ export class Editor {
|
|
|
3401
3083
|
case "RESIZE_SHAPE":
|
|
3402
3084
|
this.applyResize(emit.id, emit.handle, emit.delta, emit.originalBounds);
|
|
3403
3085
|
return;
|
|
3086
|
+
case "ROTATE":
|
|
3087
|
+
this.applyRotate(emit.deltaAngle);
|
|
3088
|
+
return;
|
|
3404
3089
|
case "CREATE_SHAPE":
|
|
3405
3090
|
this.applyCreate(emit.shapeType, emit.bounds);
|
|
3406
3091
|
return;
|
|
@@ -3447,7 +3132,6 @@ export class Editor {
|
|
|
3447
3132
|
* Wrapped in a single gestureTx so per-move updates collapse
|
|
3448
3133
|
* into one undo step.
|
|
3449
3134
|
*/
|
|
3450
|
-
// Pure body in `./editor/applies/move.ts`.
|
|
3451
3135
|
applyAnnotationMove(id, delta, origin) {
|
|
3452
3136
|
const result = computeAnnotationMovePatch(this._scene, id, delta, origin);
|
|
3453
3137
|
if (!result)
|
|
@@ -3483,13 +3167,14 @@ export class Editor {
|
|
|
3483
3167
|
dispatchTemplateDrop(emit) {
|
|
3484
3168
|
this.applyEmit(emit);
|
|
3485
3169
|
}
|
|
3486
|
-
// Pure body in `./editor/applies/move.ts`.
|
|
3487
3170
|
applyMove(id, delta, originalBounds) {
|
|
3488
3171
|
// Locked / layer-locked elements are selectable but don't move.
|
|
3489
3172
|
const el = getElement(this._scene, id);
|
|
3490
3173
|
if (el && !this.isElementManipulable(el))
|
|
3491
3174
|
return;
|
|
3492
|
-
|
|
3175
|
+
// Shift constrains the drag to one axis before snapping.
|
|
3176
|
+
const moved = this.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
|
|
3177
|
+
const d = this.snapActive() ? snapMoveDelta(originalBounds, moved, this.snapSpacing()) : moved;
|
|
3493
3178
|
const patch = computeElementMovePatch(this._scene, id, d, originalBounds);
|
|
3494
3179
|
if (!patch)
|
|
3495
3180
|
return;
|
|
@@ -3500,9 +3185,11 @@ export class Editor {
|
|
|
3500
3185
|
applyGroupMove(delta) {
|
|
3501
3186
|
if (!this.groupMoveOrigin)
|
|
3502
3187
|
return;
|
|
3188
|
+
// Shift constrains the drag to one axis before snapping.
|
|
3189
|
+
const moved = this.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
|
|
3503
3190
|
const d = this.snapActive()
|
|
3504
|
-
? snapGroupDelta(this.groupMoveOrigin,
|
|
3505
|
-
:
|
|
3191
|
+
? snapGroupDelta(this.groupMoveOrigin, moved, this.snapSpacing())
|
|
3192
|
+
: moved;
|
|
3506
3193
|
const patches = computeGroupMovePatches(this._scene, this.groupMoveOrigin, d);
|
|
3507
3194
|
for (const patch of patches) {
|
|
3508
3195
|
this._scene = apply(this._scene, patch);
|
|
@@ -3520,7 +3207,6 @@ export class Editor {
|
|
|
3520
3207
|
}
|
|
3521
3208
|
this.notify();
|
|
3522
3209
|
}
|
|
3523
|
-
// Body moved to `./editor/viewport-helpers.ts`.
|
|
3524
3210
|
computeViewportWorld() {
|
|
3525
3211
|
return computeViewportWorldPure(this._scene);
|
|
3526
3212
|
}
|
|
@@ -3589,125 +3275,16 @@ export class Editor {
|
|
|
3589
3275
|
// repaint when the entered-group identity changes between frames.
|
|
3590
3276
|
if (this.lastRenderedEnteredGroup !== this._enteredGroup)
|
|
3591
3277
|
return null;
|
|
3592
|
-
//
|
|
3593
|
-
//
|
|
3594
|
-
//
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
const add = (b) => {
|
|
3600
|
-
acc = acc ? B.union(acc, b) : b;
|
|
3601
|
-
};
|
|
3602
|
-
// Track shapes that changed (added / removed / mutated). Links
|
|
3603
|
-
// attached to any of these have stale rendered paths even when
|
|
3604
|
-
// the edge object itself is reference-equal — the path resolves
|
|
3605
|
-
// through the shape's new position, but the old path stays on
|
|
3606
|
-
// screen as a "ghost" trail unless we explicitly invalidate it.
|
|
3607
|
-
const changedElementIds = new Set();
|
|
3608
|
-
for (const [id, shape] of next.elements) {
|
|
3609
|
-
const old = prev.elements.get(id);
|
|
3610
|
-
if (old === shape)
|
|
3611
|
-
continue;
|
|
3612
|
-
changedElementIds.add(id);
|
|
3613
|
-
// Render bounds (not geometric) so overpaint — a frame's header
|
|
3614
|
-
// strip, confetti particles — is cleared too, no ghost trail.
|
|
3615
|
-
const afterBounds = getElementRenderBounds(shape);
|
|
3616
|
-
const beforeBounds = old ? getElementRenderBounds(old) : null;
|
|
3617
|
-
add(afterBounds);
|
|
3618
|
-
if (beforeBounds)
|
|
3619
|
-
add(beforeBounds);
|
|
3620
|
-
// Stash for the tile-cache path — covers add + move via
|
|
3621
|
-
// before/after pair; pure mutation re-uses the single
|
|
3622
|
-
// afterBounds rect.
|
|
3623
|
-
if (this.tileComposeFn !== null) {
|
|
3624
|
-
this.tileDirtyElements.set(id, { before: beforeBounds, after: afterBounds });
|
|
3625
|
-
}
|
|
3626
|
-
}
|
|
3627
|
-
for (const [id, shape] of prev.elements) {
|
|
3628
|
-
if (!next.elements.has(id)) {
|
|
3629
|
-
changedElementIds.add(id);
|
|
3630
|
-
// Render bounds so a removed frame/confetti clears its overpaint.
|
|
3631
|
-
const beforeBounds = getElementRenderBounds(shape);
|
|
3632
|
-
add(beforeBounds);
|
|
3633
|
-
if (this.tileComposeFn !== null) {
|
|
3634
|
-
this.tileDirtyElements.set(id, { before: beforeBounds, after: null });
|
|
3635
|
-
}
|
|
3636
|
-
}
|
|
3637
|
-
}
|
|
3638
|
-
const linkTouchesChangedElement = (edge) => {
|
|
3639
|
-
for (const ep of [edge.from, edge.to]) {
|
|
3640
|
-
if (ep.kind !== "point") {
|
|
3641
|
-
if (changedElementIds.has(ep.elementId))
|
|
3642
|
-
return true;
|
|
3643
|
-
}
|
|
3644
|
-
}
|
|
3645
|
-
return false;
|
|
3646
|
-
};
|
|
3647
|
-
for (const [id, edge] of next.links) {
|
|
3648
|
-
const old = prev.links.get(id);
|
|
3649
|
-
// Refresh edge dirty-rect when: edge object changed, OR an
|
|
3650
|
-
// endpoint references a shape that moved this frame (path is
|
|
3651
|
-
// re-resolved every render but the old screen pixels persist).
|
|
3652
|
-
if (old === edge && !linkTouchesChangedElement(edge))
|
|
3653
|
-
continue;
|
|
3654
|
-
const b = computeLinkWorldBounds(next, edge);
|
|
3655
|
-
if (b)
|
|
3656
|
-
add(b);
|
|
3657
|
-
const oldLink = old ?? edge; // prev scene resolves with prev shapes for ghost-clear
|
|
3658
|
-
const ob = computeLinkWorldBounds(prev, oldLink);
|
|
3659
|
-
if (ob)
|
|
3660
|
-
add(ob);
|
|
3278
|
+
// Diff the two scenes for the dirty rect + tile-cache invalidation. The
|
|
3279
|
+
// state-coupled guards above stay here; the pure scene diff lives in
|
|
3280
|
+
// `computeSceneDirtyRect`.
|
|
3281
|
+
const { world, tileDirty } = computeSceneDirtyRect(prev, next);
|
|
3282
|
+
if (this.tileComposeFn !== null) {
|
|
3283
|
+
for (const [id, entry] of tileDirty)
|
|
3284
|
+
this.tileDirtyElements.set(id, entry);
|
|
3661
3285
|
}
|
|
3662
|
-
|
|
3663
|
-
if (!next.links.has(id)) {
|
|
3664
|
-
const b = computeLinkWorldBounds(prev, edge);
|
|
3665
|
-
if (b)
|
|
3666
|
-
add(b);
|
|
3667
|
-
}
|
|
3668
|
-
}
|
|
3669
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- `acc` is mutated via the `add` closure; TS flow analysis can't see it and narrows to null
|
|
3670
|
-
if (acc === null)
|
|
3671
|
-
return { x: -1e9, y: -1e9, width: 0, height: 0 };
|
|
3672
|
-
// Transitive expansion: any shape whose bounds intersect the
|
|
3673
|
-
// current dirty rect must be repainted, AND its bounds added
|
|
3674
|
-
// to the dirty rect so any shape ABOVE it that overlaps gets
|
|
3675
|
-
// included too. Repeat until the set stabilises.
|
|
3676
|
-
//
|
|
3677
|
-
// Without this, dragging A through a B/C stack produces
|
|
3678
|
-
// visual jitter: B intersects the dirty rect and gets
|
|
3679
|
-
// repainted, but C — sitting above B and partially overlapping
|
|
3680
|
-
// it — doesn't intersect the original dirty, so B re-emerges
|
|
3681
|
-
// on top of where C should still be drawn. Z-order is correct
|
|
3682
|
-
// in `getElementsInLayer`; the issue is missed shapes, not
|
|
3683
|
-
// wrong order.
|
|
3684
|
-
const visited = new Set();
|
|
3685
|
-
let expanded = acc;
|
|
3686
|
-
let grew = true;
|
|
3687
|
-
while (grew) {
|
|
3688
|
-
grew = false;
|
|
3689
|
-
for (const shape of next.elements.values()) {
|
|
3690
|
-
if (visited.has(shape.id))
|
|
3691
|
-
continue;
|
|
3692
|
-
const bb = getElementWorldBounds(shape);
|
|
3693
|
-
if (!B.intersects(bb, expanded))
|
|
3694
|
-
continue;
|
|
3695
|
-
visited.add(shape.id);
|
|
3696
|
-
const merged = B.union(expanded, bb);
|
|
3697
|
-
if (merged.x !== expanded.x ||
|
|
3698
|
-
merged.y !== expanded.y ||
|
|
3699
|
-
merged.width !== expanded.width ||
|
|
3700
|
-
merged.height !== expanded.height) {
|
|
3701
|
-
expanded = merged;
|
|
3702
|
-
grew = true;
|
|
3703
|
-
}
|
|
3704
|
-
}
|
|
3705
|
-
}
|
|
3706
|
-
// Inflate by a couple pixels to cover anti-aliased stroke fuzz
|
|
3707
|
-
// around the geometry edges.
|
|
3708
|
-
return B.expand(expanded, 4);
|
|
3286
|
+
return world;
|
|
3709
3287
|
}
|
|
3710
|
-
// Bodies moved to `./editor/viewport-helpers.ts`.
|
|
3711
3288
|
combinedSelectionBounds() {
|
|
3712
3289
|
let acc = combinedSelectionBoundsPure(this._scene, this._selection);
|
|
3713
3290
|
// Selected links join the selection box (standard parity) — union in
|
|
@@ -3763,19 +3340,42 @@ export class Editor {
|
|
|
3763
3340
|
// Multi-selection: lock when every selected shape is an image — they
|
|
3764
3341
|
// must never be stretched out of ratio, only scaled together.
|
|
3765
3342
|
for (const id of this._selection) {
|
|
3766
|
-
|
|
3343
|
+
const el = getElement(this._scene, id);
|
|
3344
|
+
if (el === undefined || !isImage(el))
|
|
3767
3345
|
return false;
|
|
3768
3346
|
}
|
|
3769
3347
|
return true;
|
|
3770
3348
|
}
|
|
3771
|
-
|
|
3349
|
+
/**
|
|
3350
|
+
* Per-frame rotate during a grip drag: turn the press-time snapshot by
|
|
3351
|
+
* `deltaAngle` about its pivot. Shift snaps the swept angle to
|
|
3352
|
+
* {@link ROTATE_SNAP_RADIANS} steps. Recorded as gesture patches (one undo
|
|
3353
|
+
* step on commit).
|
|
3354
|
+
*/
|
|
3355
|
+
applyRotate(deltaAngle) {
|
|
3356
|
+
const gesture = this.rotateGestureOrigin;
|
|
3357
|
+
if (!gesture)
|
|
3358
|
+
return;
|
|
3359
|
+
const d = this.transformShiftKey
|
|
3360
|
+
? Math.round(deltaAngle / ROTATE_SNAP_RADIANS) * ROTATE_SNAP_RADIANS
|
|
3361
|
+
: deltaAngle;
|
|
3362
|
+
const patches = computeRotatePatches(this._scene, gesture.origin, gesture.pivot, d);
|
|
3363
|
+
for (const patch of patches) {
|
|
3364
|
+
this._scene = apply(this._scene, patch);
|
|
3365
|
+
this.recordGesturePatch(patch);
|
|
3366
|
+
}
|
|
3367
|
+
this.notify();
|
|
3368
|
+
}
|
|
3772
3369
|
applyGroupResize(handle, delta, originalBounds) {
|
|
3773
3370
|
if (!this.groupResizeOrigin)
|
|
3774
3371
|
return;
|
|
3775
3372
|
const d = this.snapActive()
|
|
3776
3373
|
? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
|
|
3777
3374
|
: delta;
|
|
3778
|
-
const result = computeGroupResizePatches(this._scene, this.groupResizeOrigin, handle, d, originalBounds,
|
|
3375
|
+
const result = computeGroupResizePatches(this._scene, this.groupResizeOrigin, handle, d, originalBounds,
|
|
3376
|
+
// Aspect-locked when the selection type demands it (images / groups) or
|
|
3377
|
+
// the user holds Shift for this gesture.
|
|
3378
|
+
this.selectionIsAspectLocked() || this.transformShiftKey, this.transformAltKey);
|
|
3779
3379
|
this._scene = result.scene;
|
|
3780
3380
|
for (const patch of result.patches)
|
|
3781
3381
|
this.recordGesturePatch(patch);
|
|
@@ -3783,16 +3383,32 @@ export class Editor {
|
|
|
3783
3383
|
}
|
|
3784
3384
|
applyResize(id, handle, delta, originalBounds) {
|
|
3785
3385
|
const shape = getElement(this._scene, id);
|
|
3386
|
+
// Rotated shape: the axis-aligned world resize below would corrupt it
|
|
3387
|
+
// (handles live on the tilted box). Resize in the shape's own frame,
|
|
3388
|
+
// keeping the opposite corner fixed in world. Snapshot the pristine pose
|
|
3389
|
+
// on the first tick so the closed-form never compounds. Grid-snap is
|
|
3390
|
+
// skipped — snapping a tilted box to the world grid is ill-defined.
|
|
3391
|
+
if (shape !== undefined && !isText(shape) && shape.rotation !== 0) {
|
|
3392
|
+
if (this._resizeOriginElement?.id !== id)
|
|
3393
|
+
this._resizeOriginElement = shape;
|
|
3394
|
+
const result = computeRotatedElementResize(this._scene, this._resizeOriginElement, handle, delta, this.transformShiftKey, this.transformAltKey);
|
|
3395
|
+
if (!result)
|
|
3396
|
+
return;
|
|
3397
|
+
this._scene = result.scene;
|
|
3398
|
+
this.recordGesturePatch(result.patch);
|
|
3399
|
+
this.notify();
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3786
3402
|
const d = this.snapActive()
|
|
3787
3403
|
? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
|
|
3788
3404
|
: delta;
|
|
3789
3405
|
// Text: aspect-locked font scaling. Snapshot the pristine shape on
|
|
3790
3406
|
// the gesture's first tick so the scale base never compounds.
|
|
3791
|
-
if (shape
|
|
3407
|
+
if (shape !== undefined && isText(shape)) {
|
|
3792
3408
|
if (this._resizeOriginElement?.id !== id) {
|
|
3793
3409
|
this._resizeOriginElement = shape;
|
|
3794
3410
|
}
|
|
3795
|
-
const result = computeTextResize(this._scene, this._resizeOriginElement, handle, d, originalBounds);
|
|
3411
|
+
const result = computeTextResize(this._scene, this._resizeOriginElement, handle, d, originalBounds, this.transformAltKey);
|
|
3796
3412
|
if (!result)
|
|
3797
3413
|
return;
|
|
3798
3414
|
this._scene = result.scene;
|
|
@@ -3800,14 +3416,13 @@ export class Editor {
|
|
|
3800
3416
|
this.notify();
|
|
3801
3417
|
return;
|
|
3802
3418
|
}
|
|
3803
|
-
const result = computeElementResize(this._scene, id, handle, d, originalBounds, (s, raw, h) => this.clampContainerToChildren(s, raw, h));
|
|
3419
|
+
const result = computeElementResize(this._scene, id, handle, d, originalBounds, (s, raw, h) => this.clampContainerToChildren(s, raw, h), this.transformShiftKey, this.transformAltKey);
|
|
3804
3420
|
if (!result)
|
|
3805
3421
|
return;
|
|
3806
3422
|
this._scene = result.scene;
|
|
3807
3423
|
this.recordGesturePatch(result.patch);
|
|
3808
3424
|
this.notify();
|
|
3809
3425
|
}
|
|
3810
|
-
// Pure body in `./editor/applies/create.ts`.
|
|
3811
3426
|
applyCreate(kind, bounds) {
|
|
3812
3427
|
const id = newElementId(++this.nextId);
|
|
3813
3428
|
const b = this.snapActive() ? snapCreateBounds(bounds, this.snapSpacing()) : bounds;
|
|
@@ -3847,8 +3462,7 @@ export class Editor {
|
|
|
3847
3462
|
reconcileFrameMembership() {
|
|
3848
3463
|
this._scene = reconcileFrameMembershipHelper(this._scene, this._history);
|
|
3849
3464
|
}
|
|
3850
|
-
//
|
|
3851
|
-
// stays here because it needs the snap engine.
|
|
3465
|
+
// Endpoint snapping stays here because it needs the snap engine.
|
|
3852
3466
|
applyCreateLink(emit) {
|
|
3853
3467
|
const from = this.snapLinkEndpoint(emit.fromElement, emit.fromPoint);
|
|
3854
3468
|
const to = this.snapLinkEndpoint(emit.toElement, emit.toPoint);
|
|
@@ -3887,35 +3501,11 @@ export class Editor {
|
|
|
3887
3501
|
return;
|
|
3888
3502
|
}
|
|
3889
3503
|
const newId = newElementId(++this.nextId);
|
|
3890
|
-
const
|
|
3891
|
-
.filter((sh) => sh.layerId === this._activeLayerId)
|
|
3892
|
-
.map((sh) => sh.order));
|
|
3893
|
-
const built = factory({
|
|
3894
|
-
id: newId,
|
|
3895
|
-
layerId: this._activeLayerId,
|
|
3896
|
-
position: pending.world,
|
|
3897
|
-
order,
|
|
3898
|
-
});
|
|
3899
|
-
// Centre the element on the drop point regardless of how the factory
|
|
3900
|
-
// anchored it at `position`.
|
|
3901
|
-
const wb = getElementWorldBounds(built);
|
|
3902
|
-
const shape = {
|
|
3903
|
-
...built,
|
|
3904
|
-
position: {
|
|
3905
|
-
x: built.position.x + (pending.world.x - (wb.x + wb.width / 2)),
|
|
3906
|
-
y: built.position.y + (pending.world.y - (wb.y + wb.height / 2)),
|
|
3907
|
-
},
|
|
3908
|
-
};
|
|
3504
|
+
const r = computeShapeAtLinkDrop(this._scene, this._activeLayerId, pending, newId, factory);
|
|
3909
3505
|
const tx = this._history.transaction();
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
tx.add(
|
|
3913
|
-
const upd = updateLink(this._scene, pending.linkId, (e) => ({
|
|
3914
|
-
...e,
|
|
3915
|
-
[pending.side]: { kind: "floating", elementId: newId },
|
|
3916
|
-
}));
|
|
3917
|
-
this._scene = upd.scene;
|
|
3918
|
-
tx.add(upd.patch);
|
|
3506
|
+
this._scene = r.scene;
|
|
3507
|
+
tx.add(r.addPatch);
|
|
3508
|
+
tx.add(r.linkPatch);
|
|
3919
3509
|
tx.commit();
|
|
3920
3510
|
this.pendingLinkDropMenu = null;
|
|
3921
3511
|
this._selection = Selection.single(newId);
|
|
@@ -3937,50 +3527,15 @@ export class Editor {
|
|
|
3937
3527
|
* selection. Element + link land in one undo step.
|
|
3938
3528
|
*/
|
|
3939
3529
|
createLinkedElementFromAnchor(fromElement, anchorName) {
|
|
3940
|
-
const src = getElement(this._scene, fromElement);
|
|
3941
|
-
if (!src)
|
|
3942
|
-
return;
|
|
3943
|
-
const anchor = { kind: "named", name: anchorName };
|
|
3944
|
-
const normal = getAnchorOutwardNormal(src, anchor);
|
|
3945
|
-
const bounds = getElementWorldBounds(src);
|
|
3946
|
-
const srcCenter = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
|
|
3947
|
-
// Same-size clone → centre-to-centre distance = the source's extent
|
|
3948
|
-
// along the normal + the gap, leaving exactly
|
|
3949
|
-
// ANCHOR_CLICK_NEW_ELEMENT_GAP between the facing edges. `extentAlong`
|
|
3950
|
-
// resolves to width for a horizontal normal, height for a vertical one
|
|
3951
|
-
// (link-start dots are the four edge midpoints).
|
|
3952
|
-
const extentAlong = Math.abs(normal.x) * bounds.width + Math.abs(normal.y) * bounds.height;
|
|
3953
|
-
const dist = extentAlong + ANCHOR_CLICK_NEW_ELEMENT_GAP;
|
|
3954
|
-
const delta = { x: normal.x * dist, y: normal.y * dist };
|
|
3955
3530
|
const newId = newElementId(++this.nextId);
|
|
3956
|
-
const order = orderForTop([...this._scene.elements.values()]
|
|
3957
|
-
.filter((sh) => sh.layerId === src.layerId)
|
|
3958
|
-
.map((sh) => sh.order));
|
|
3959
|
-
let clone = {
|
|
3960
|
-
...src,
|
|
3961
|
-
id: newId,
|
|
3962
|
-
position: { x: src.position.x + delta.x, y: src.position.y + delta.y },
|
|
3963
|
-
order,
|
|
3964
|
-
};
|
|
3965
|
-
// Blank user text — the new element is a fresh same-kind shape, not a
|
|
3966
|
-
// content copy (standard). Only `text` (TextElement) and `name`
|
|
3967
|
-
// (FrameElement) carry user-entered text. Cast through `Element` because
|
|
3968
|
-
// `exactOptionalPropertyTypes` rejects the bare object literal against
|
|
3969
|
-
// the union (TS2375), even though the narrowed branch is sound.
|
|
3970
|
-
if (clone.type === "text")
|
|
3971
|
-
clone = { ...clone, text: "" };
|
|
3972
|
-
else if (clone.type === "frame")
|
|
3973
|
-
clone = { ...clone, name: "" };
|
|
3974
|
-
const tx = this._history.transaction();
|
|
3975
|
-
const added = addElement(this._scene, clone);
|
|
3976
|
-
this._scene = added.scene;
|
|
3977
|
-
tx.add(added.patch);
|
|
3978
3531
|
const linkId = newLinkId(++this.nextId);
|
|
3979
|
-
const
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3532
|
+
const r = computeLinkedElementFromAnchor(this._scene, this._activeLayerId, fromElement, anchorName, newId, linkId);
|
|
3533
|
+
if (!r)
|
|
3534
|
+
return;
|
|
3535
|
+
const tx = this._history.transaction();
|
|
3536
|
+
this._scene = r.scene;
|
|
3537
|
+
tx.add(r.addPatch);
|
|
3538
|
+
tx.add(r.linkPatch);
|
|
3984
3539
|
tx.commit();
|
|
3985
3540
|
this._selection = Selection.single(newId);
|
|
3986
3541
|
if (this._selectedLinks.size > 0)
|
|
@@ -3994,72 +3549,7 @@ export class Editor {
|
|
|
3994
3549
|
* `createLinkedElementFromAnchor`.
|
|
3995
3550
|
*/
|
|
3996
3551
|
previewClickCreate(fromElement, anchorName) {
|
|
3997
|
-
|
|
3998
|
-
if (!src)
|
|
3999
|
-
return null;
|
|
4000
|
-
const anchor = { kind: "named", name: anchorName };
|
|
4001
|
-
const normal = getAnchorOutwardNormal(src, anchor);
|
|
4002
|
-
const b = getElementWorldBounds(src);
|
|
4003
|
-
const extentAlong = Math.abs(normal.x) * b.width + Math.abs(normal.y) * b.height;
|
|
4004
|
-
const dist = extentAlong + ANCHOR_CLICK_NEW_ELEMENT_GAP;
|
|
4005
|
-
const delta = { x: normal.x * dist, y: normal.y * dist };
|
|
4006
|
-
const bounds = { x: b.x + delta.x, y: b.y + delta.y, width: b.width, height: b.height };
|
|
4007
|
-
const fromWorld = getAnchorWorld(src, anchor);
|
|
4008
|
-
// Facing edge of the ghost (toward the source) = its centre pulled back
|
|
4009
|
-
// along the normal by half its extent.
|
|
4010
|
-
const ghostCx = bounds.x + bounds.width / 2;
|
|
4011
|
-
const ghostCy = bounds.y + bounds.height / 2;
|
|
4012
|
-
const nearEdge = {
|
|
4013
|
-
x: ghostCx - normal.x * (extentAlong / 2),
|
|
4014
|
-
y: ghostCy - normal.y * (extentAlong / 2),
|
|
4015
|
-
};
|
|
4016
|
-
// The would-be element itself — a same-kind clone of the source shifted
|
|
4017
|
-
// to the ghost bounds, with blank user text (mirrors
|
|
4018
|
-
// `createLinkedElementFromAnchor`). The overlay renders THIS through the
|
|
4019
|
-
// real renderer so the ghost looks like the actual shape (an ellipse
|
|
4020
|
-
// ghosts as an ellipse), not a bounding rect. Throwaway id — never enters
|
|
4021
|
-
// the real scene.
|
|
4022
|
-
let element = {
|
|
4023
|
-
...src,
|
|
4024
|
-
id: PREVIEW_GHOST_ELEMENT_ID,
|
|
4025
|
-
position: { x: src.position.x + delta.x, y: src.position.y + delta.y },
|
|
4026
|
-
};
|
|
4027
|
-
if (element.type === "text")
|
|
4028
|
-
element = { ...element, text: "" };
|
|
4029
|
-
else if (element.type === "frame")
|
|
4030
|
-
element = { ...element, name: "" };
|
|
4031
|
-
// Build a throwaway scene holding the ghost element + the would-be link so
|
|
4032
|
-
// the connector can be drawn through the REAL link renderer (same routing,
|
|
4033
|
-
// arrowhead and style it'll have once created) — faded — instead of a
|
|
4034
|
-
// dashed preview line. Mirrors the link build in
|
|
4035
|
-
// `createLinkedElementFromAnchor` exactly. The `path` field stays for
|
|
4036
|
-
// callers that just want the straight from→to segment.
|
|
4037
|
-
const srcCenter = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
|
|
4038
|
-
const withGhost = addElement(this._scene, element).scene;
|
|
4039
|
-
const placed = req(getElement(withGhost, PREVIEW_GHOST_ELEMENT_ID));
|
|
4040
|
-
const { ref: toRef } = findNearestAnchor(placed, srcCenter, snapExcludedAnchors(placed));
|
|
4041
|
-
const linkResult = computeCreateLink(withGhost, { kind: "anchor", elementId: fromElement, anchor }, { kind: "anchor", elementId: PREVIEW_GHOST_ELEMENT_ID, anchor: toRef }, PREVIEW_GHOST_LINK_ID, this._activeLayerId);
|
|
4042
|
-
let ghostScene = linkResult.scene;
|
|
4043
|
-
const edge = req(getLink(ghostScene, PREVIEW_GHOST_LINK_ID));
|
|
4044
|
-
if ((edge.routing ?? "straight") === "orthogonal") {
|
|
4045
|
-
const routedPoints = routeElbowLink(ghostScene, edge);
|
|
4046
|
-
ghostScene = updateLink(ghostScene, PREVIEW_GHOST_LINK_ID, (e) => ({
|
|
4047
|
-
...e,
|
|
4048
|
-
routedPoints,
|
|
4049
|
-
})).scene;
|
|
4050
|
-
}
|
|
4051
|
-
// Render only the ghost link (the shapes stay for endpoint resolution).
|
|
4052
|
-
ghostScene = {
|
|
4053
|
-
...ghostScene,
|
|
4054
|
-
links: new Map([[PREVIEW_GHOST_LINK_ID, req(getLink(ghostScene, PREVIEW_GHOST_LINK_ID))]]),
|
|
4055
|
-
};
|
|
4056
|
-
return {
|
|
4057
|
-
bounds,
|
|
4058
|
-
path: [fromWorld, nearEdge],
|
|
4059
|
-
element,
|
|
4060
|
-
ghostScene,
|
|
4061
|
-
ghostLinkId: PREVIEW_GHOST_LINK_ID,
|
|
4062
|
-
};
|
|
3552
|
+
return previewClickCreatePure(this._scene, this._activeLayerId, fromElement, anchorName);
|
|
4063
3553
|
}
|
|
4064
3554
|
/**
|
|
4065
3555
|
* Build an `LinkEndpoint` for a draw-edge / re-bind gesture. Runs the
|
|
@@ -4073,61 +3563,10 @@ export class Editor {
|
|
|
4073
3563
|
* shapes when the user clearly aimed for this one).
|
|
4074
3564
|
*/
|
|
4075
3565
|
snapLinkEndpoint(pressTargetElement, worldPoint) {
|
|
4076
|
-
|
|
4077
|
-
scene: this._scene,
|
|
4078
|
-
probe: worldPoint,
|
|
4079
|
-
threshold: this.snapThreshold,
|
|
4080
|
-
gesture: "draw-edge",
|
|
4081
|
-
});
|
|
4082
|
-
// Attach contract: dropping on a port dot → *fixed* anchor; dropping
|
|
4083
|
-
// near an EDGE (not a dot) → *fixed* outline point (a ratio along the
|
|
4084
|
-
// perimeter — survives move/resize); dropping on the body interior (no
|
|
4085
|
-
// snap to a dot or edge) → *floating* against the whole shape, so the
|
|
4086
|
-
// connection re-aims at the partner as either shape moves; dropping on
|
|
4087
|
-
// empty canvas → a free point.
|
|
4088
|
-
//
|
|
4089
|
-
// Pick a candidate ON the pressed shape if there is one, otherwise the
|
|
4090
|
-
// nearest overall. The "nearest overall" branch matters because the
|
|
4091
|
-
// attach DOTS are drawn OUTSIDE the body — a release on a dot finds no
|
|
4092
|
-
// element under it (hit-test = empty), so `pressTargetElement` is null,
|
|
4093
|
-
// yet the snap engine still reports the dot's anchor within threshold.
|
|
4094
|
-
// Binding it makes the endpoint enter PERPENDICULAR to that edge instead
|
|
4095
|
-
// of staying a free point that aims at the partner (jumping between the
|
|
4096
|
-
// four sides).
|
|
4097
|
-
const pick = (kind) => {
|
|
4098
|
-
if (pressTargetElement !== null) {
|
|
4099
|
-
const onTarget = result.all.find((c) => c.kind === kind && c.metadata?.elementId === pressTargetElement);
|
|
4100
|
-
if (onTarget)
|
|
4101
|
-
return onTarget;
|
|
4102
|
-
}
|
|
4103
|
-
return result.all.find((c) => c.kind === kind);
|
|
4104
|
-
};
|
|
4105
|
-
const boundFrom = (cand, want) => {
|
|
4106
|
-
if (!cand)
|
|
4107
|
-
return null;
|
|
4108
|
-
const elId = cand.metadata?.elementId;
|
|
4109
|
-
if (elId === undefined)
|
|
4110
|
-
return null;
|
|
4111
|
-
const shp = getElement(this._scene, elId);
|
|
4112
|
-
if (!shp)
|
|
4113
|
-
return null;
|
|
4114
|
-
const ep = endpointFromSnap(elId, cand, shp);
|
|
4115
|
-
return ep.kind === want ? ep : null;
|
|
4116
|
-
};
|
|
4117
|
-
const anchorEp = boundFrom(pick("anchor"), "anchor");
|
|
4118
|
-
if (anchorEp)
|
|
4119
|
-
return anchorEp;
|
|
4120
|
-
const outlineEp = boundFrom(pick("outline"), "outline");
|
|
4121
|
-
if (outlineEp)
|
|
4122
|
-
return outlineEp;
|
|
4123
|
-
// No dot/edge snap. Over a shape body → floating; else a free point.
|
|
4124
|
-
if (pressTargetElement !== null && getElement(this._scene, pressTargetElement)) {
|
|
4125
|
-
return { kind: "floating", elementId: pressTargetElement };
|
|
4126
|
-
}
|
|
4127
|
-
return { kind: "point", position: worldPoint };
|
|
3566
|
+
return snapLinkEndpointPure(this._scene, this.snapEngine, this.snapThreshold, pressTargetElement, worldPoint);
|
|
4128
3567
|
}
|
|
4129
|
-
//
|
|
4130
|
-
//
|
|
3568
|
+
// The wrappers here own the side effects (`_selectedLink`
|
|
3569
|
+
// clearing, notify).
|
|
4131
3570
|
applySelectByBounds(bounds, mode) {
|
|
4132
3571
|
const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode);
|
|
4133
3572
|
const nextLinks = selectLinksByBoundsLivePure(this._scene, mode === "add" ? this._selectedLinks : LinkSelection.EMPTY, (id) => this.isLayerLocked(id), bounds, mode);
|
|
@@ -4151,9 +3590,8 @@ export class Editor {
|
|
|
4151
3590
|
this._selectedLinks = nextLinks;
|
|
4152
3591
|
this._selection = next;
|
|
4153
3592
|
}
|
|
4154
|
-
//
|
|
4155
|
-
//
|
|
4156
|
-
// notify).
|
|
3593
|
+
// The wrapper here owns the side effects (history push,
|
|
3594
|
+
// drag-state clearing, notify).
|
|
4157
3595
|
/**
|
|
4158
3596
|
* Live endpoint-rebind move: re-point the dragged end to the cursor in the
|
|
4159
3597
|
* scene (a free `point` endpoint), recorded in the gesture transaction so the
|
|
@@ -4277,7 +3715,7 @@ export class Editor {
|
|
|
4277
3715
|
const collapse = WAYPOINT_COLLAPSE_RADIUS / (this._scene.viewport.zoom || 1);
|
|
4278
3716
|
const a = path?.[drag.index];
|
|
4279
3717
|
const b = path?.[drag.index + 2];
|
|
4280
|
-
if (a && b &&
|
|
3718
|
+
if (a && b && hitTest.distanceToSegment(wp, a, b) <= collapse) {
|
|
4281
3719
|
const wps = edge.waypoints.filter((_, i) => i !== drag.index);
|
|
4282
3720
|
const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, waypoints: wps }));
|
|
4283
3721
|
this._scene = r.scene;
|
|
@@ -4346,7 +3784,7 @@ export class Editor {
|
|
|
4346
3784
|
const now = performance.now();
|
|
4347
3785
|
const isDouble = now - this.lastHandleClickAt < DOUBLE_CLICK_MS &&
|
|
4348
3786
|
this.lastHandleClickWorld !== null &&
|
|
4349
|
-
|
|
3787
|
+
vec2.distance(this.lastHandleClickWorld, world) <= DOUBLE_CLICK_TOLERANCE_PX;
|
|
4350
3788
|
this.lastHandleClickAt = now;
|
|
4351
3789
|
this.lastHandleClickWorld = world;
|
|
4352
3790
|
return isDouble;
|
|
@@ -4474,7 +3912,6 @@ export class Editor {
|
|
|
4474
3912
|
this.hoveredLinkTarget = { elementId: shape.id, activeAnchor: activeName, outlinePoint, mode };
|
|
4475
3913
|
this.notify();
|
|
4476
3914
|
}
|
|
4477
|
-
// Pure body in `./editor/applies/edge.ts`.
|
|
4478
3915
|
applyLinkPreview(fromElement, fromPoint, toPoint) {
|
|
4479
3916
|
const ep = computeLinkPreviewEndpoints(this._scene, fromElement, fromPoint, toPoint);
|
|
4480
3917
|
// Match the preview to the connector that will be committed: when new
|
|
@@ -4491,9 +3928,7 @@ export class Editor {
|
|
|
4491
3928
|
this.notify();
|
|
4492
3929
|
}
|
|
4493
3930
|
// Gesture lifecycle — recordGesturePatch / commitGesture /
|
|
4494
|
-
// cancelGesture / finalizeOpenGestureTx / maybeRevertModeAfterCreate
|
|
4495
|
-
// live in `./editor/gesture-tx.ts`. The thin instance methods below
|
|
4496
|
-
// preserve the original call sites.
|
|
3931
|
+
// cancelGesture / finalizeOpenGestureTx / maybeRevertModeAfterCreate.
|
|
4497
3932
|
recordGesturePatch(patch) {
|
|
4498
3933
|
// Snapshot the pre-gesture scene the moment the transaction opens, so a
|
|
4499
3934
|
// later cancel/Escape can restore it (the history tx only records undo data,
|
|
@@ -4505,6 +3940,7 @@ export class Editor {
|
|
|
4505
3940
|
}
|
|
4506
3941
|
commitGesture() {
|
|
4507
3942
|
this._resizeOriginElement = null;
|
|
3943
|
+
this.rotateGestureOrigin = null;
|
|
4508
3944
|
this.gestures.commit();
|
|
4509
3945
|
this.gestureStartScene = null;
|
|
4510
3946
|
}
|
|
@@ -4525,9 +3961,9 @@ export class Editor {
|
|
|
4525
3961
|
* - Cycles (a container inside its own descendant) are prevented by the
|
|
4526
3962
|
* `containerHover` pipeline above — the exclude set rules them out.
|
|
4527
3963
|
*/
|
|
4528
|
-
//
|
|
4529
|
-
//
|
|
4530
|
-
//
|
|
3964
|
+
// Editor exposes a small `ContainerOpsRef` bridge so the pure
|
|
3965
|
+
// helper can mutate scene + push patches into the running gesture
|
|
3966
|
+
// transaction.
|
|
4531
3967
|
applyContainerDrop(worldPoint) {
|
|
4532
3968
|
applyContainerDropPure(this.containerOpsRef, worldPoint);
|
|
4533
3969
|
}
|
|
@@ -4535,7 +3971,6 @@ export class Editor {
|
|
|
4535
3971
|
maybeGrowContainer(containerId, childId) {
|
|
4536
3972
|
maybeGrowContainerPure(this.containerOpsRef, containerId, childId);
|
|
4537
3973
|
}
|
|
4538
|
-
// Pure body in `./editor/container-ops.ts`.
|
|
4539
3974
|
clampContainerToChildren(shape, raw, handle) {
|
|
4540
3975
|
return clampContainerToChildrenPure(this._scene, shape, raw, handle);
|
|
4541
3976
|
}
|
|
@@ -4548,9 +3983,9 @@ export class Editor {
|
|
|
4548
3983
|
this.gestureTx ??= this._history.transaction();
|
|
4549
3984
|
return this.gestureTx;
|
|
4550
3985
|
}
|
|
4551
|
-
// Body moved to `./editor/gesture-tx.ts`.
|
|
4552
3986
|
cancelGesture() {
|
|
4553
3987
|
this._resizeOriginElement = null;
|
|
3988
|
+
this.rotateGestureOrigin = null;
|
|
4554
3989
|
this.gestures.cancel();
|
|
4555
3990
|
// Roll the scene back to the pre-gesture snapshot — cancelling the history
|
|
4556
3991
|
// transaction alone leaves the live drag mutations in `_scene`.
|
|
@@ -4642,9 +4077,8 @@ export class Editor {
|
|
|
4642
4077
|
/**
|
|
4643
4078
|
* Typed event surface — subscribe to a specific slice (`mode`,
|
|
4644
4079
|
* `selection`, `scene`, `history`, `viewport`) or the umbrella
|
|
4645
|
-
* `change`.
|
|
4646
|
-
*
|
|
4647
|
-
* `subscribe()` still works and fires in lock-step.
|
|
4080
|
+
* `change`. For callers that only care about one dimension;
|
|
4081
|
+
* `subscribe()` fires in lock-step with these.
|
|
4648
4082
|
*/
|
|
4649
4083
|
on(event, fn) {
|
|
4650
4084
|
// Cast through `never`: TS can't prove that EditorEvents[K]
|
|
@@ -4683,33 +4117,6 @@ export class Editor {
|
|
|
4683
4117
|
* between frames the A* route is reused — see `rerouteElbows`.
|
|
4684
4118
|
*/
|
|
4685
4119
|
elbowRouteSig = new Map();
|
|
4686
|
-
elbowSignature(edge) {
|
|
4687
|
-
const part = (ep) => {
|
|
4688
|
-
if (ep.kind === "point")
|
|
4689
|
-
return `p:${ep.position.x},${ep.position.y}`;
|
|
4690
|
-
const s = getElement(this._scene, ep.elementId);
|
|
4691
|
-
const b = s ? getElementWorldBounds(s) : null;
|
|
4692
|
-
const ref = ep.kind === "anchor"
|
|
4693
|
-
? JSON.stringify(ep.anchor)
|
|
4694
|
-
: ep.kind === "outline"
|
|
4695
|
-
? `o:${ep.ratio}`
|
|
4696
|
-
: "f";
|
|
4697
|
-
return `${ep.kind}:${ep.elementId}:${ref}:${b ? `${b.x},${b.y},${b.width},${b.height}` : "x"}`;
|
|
4698
|
-
};
|
|
4699
|
-
const base = `${part(edge.from)}|${part(edge.to)}|${JSON.stringify(edge.fixedSegments ?? null)}`;
|
|
4700
|
-
// Avoid-obstacles links depend on EVERY shape's geometry, so their route
|
|
4701
|
-
// must invalidate when any obstacle moves — fold a digest of all element
|
|
4702
|
-
// bboxes into the signature. Only paid by links that opt in.
|
|
4703
|
-
if (edge.avoidObstacles === true) {
|
|
4704
|
-
let digest = "|avoid:";
|
|
4705
|
-
for (const el of this._scene.elements.values()) {
|
|
4706
|
-
const bb = getElementWorldBounds(el);
|
|
4707
|
-
digest += `${el.id},${bb.x},${bb.y},${bb.width},${bb.height};`;
|
|
4708
|
-
}
|
|
4709
|
-
return base + digest;
|
|
4710
|
-
}
|
|
4711
|
-
return base;
|
|
4712
|
-
}
|
|
4713
4120
|
/**
|
|
4714
4121
|
* Choke-point reroute (standard model): recompute `routedPoints` for
|
|
4715
4122
|
* every orthogonal link whose inputs changed since the last pass, and
|
|
@@ -4722,7 +4129,7 @@ export class Editor {
|
|
|
4722
4129
|
for (const [id, edge] of this._scene.links) {
|
|
4723
4130
|
if ((edge.routing ?? "straight") !== "orthogonal")
|
|
4724
4131
|
continue;
|
|
4725
|
-
const sig = this.
|
|
4132
|
+
const sig = elbowSignature(this._scene, edge);
|
|
4726
4133
|
if (this.elbowRouteSig.get(id) === sig)
|
|
4727
4134
|
continue;
|
|
4728
4135
|
this.elbowRouteSig.set(id, sig);
|
|
@@ -4731,32 +4138,19 @@ export class Editor {
|
|
|
4731
4138
|
}
|
|
4732
4139
|
this._scene = next;
|
|
4733
4140
|
}
|
|
4734
|
-
// Pure body in `./editor/render-orchestrator.ts` (~130 lines).
|
|
4735
4141
|
render() {
|
|
4736
4142
|
this.rerouteElbows();
|
|
4737
4143
|
// Feed the renderer's animation clock our per-shape playback state
|
|
4738
4144
|
// so paused / reduced-motion GIFs freeze and resumed ones continue
|
|
4739
4145
|
// from the right frame. Set immediately before the synchronous
|
|
4740
4146
|
// render pass (the shape-renderer has no options channel).
|
|
4741
|
-
setAnimationClock((shape) => this.
|
|
4147
|
+
setAnimationClock((shape) => this.gifPlayback.clock(shape.id));
|
|
4742
4148
|
renderEditor(this);
|
|
4743
4149
|
// Present AFTER the paint, on the same tick — deferred-submission
|
|
4744
4150
|
// surfaces (WebGL2 / OffscreenCanvas) would otherwise lag one frame.
|
|
4745
4151
|
this.onAfterRender?.();
|
|
4746
4152
|
}
|
|
4747
4153
|
}
|
|
4748
|
-
/**
|
|
4749
|
-
* Throwaway id for the transient click-create ghost preview element built by
|
|
4750
|
-
* `previewClickCreate`. Never enters the scene / history — it lives only for
|
|
4751
|
-
* the duration of one overlay paint, so any stable constant is fine.
|
|
4752
|
-
*/
|
|
4753
|
-
const PREVIEW_GHOST_ELEMENT_ID = "__ghost-preview__";
|
|
4754
|
-
/** Throwaway link id for the click-create ghost preview. See above. */
|
|
4755
|
-
const PREVIEW_GHOST_LINK_ID = "__ghost-preview-link__";
|
|
4756
|
-
const distanceTo = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
4757
|
-
// `coverageRatio` moved to `./editor/container-ops.ts`.
|
|
4758
|
-
// `hasWidthHeight` moved to `./editor/shape-traits.ts` for shared
|
|
4759
|
-
// use by container-ops and the future applies/resize module.
|
|
4760
4154
|
/**
|
|
4761
4155
|
* Type guard — `true` when the value already implements the
|
|
4762
4156
|
* `HistoryProvider` surface. Used to decide between "host supplied
|
|
@@ -4772,37 +4166,5 @@ const isHistoryProvider = (value) => {
|
|
|
4772
4166
|
typeof value.redo === "function" &&
|
|
4773
4167
|
typeof value.transaction === "function");
|
|
4774
4168
|
};
|
|
4775
|
-
// `describeNudge` moved to `./editor/public/selection-ops.ts`.
|
|
4776
4169
|
/** Distance from point `p` to the finite segment `a`–`b` (world space). */
|
|
4777
|
-
function distanceToSegmentPt(p, a, b) {
|
|
4778
|
-
const dx = b.x - a.x;
|
|
4779
|
-
const dy = b.y - a.y;
|
|
4780
|
-
const lenSq = dx * dx + dy * dy;
|
|
4781
|
-
if (lenSq === 0)
|
|
4782
|
-
return Math.hypot(p.x - a.x, p.y - a.y);
|
|
4783
|
-
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
|
|
4784
|
-
t = Math.max(0, Math.min(1, t));
|
|
4785
|
-
return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t));
|
|
4786
|
-
}
|
|
4787
|
-
/**
|
|
4788
|
-
* Convert a snap candidate into an `LinkEndpoint`. Anchor snap → named
|
|
4789
|
-
* anchor ref; outline snap → outline ref with the sampled ratio. Falls
|
|
4790
|
-
* back to a free point if the metadata isn't recognised.
|
|
4791
|
-
*/
|
|
4792
|
-
const endpointFromSnap = (elementId, candidate, shape) => {
|
|
4793
|
-
if (candidate.kind === "anchor") {
|
|
4794
|
-
const ref = candidate.metadata?.ref;
|
|
4795
|
-
if (ref)
|
|
4796
|
-
return { kind: "anchor", elementId, anchor: ref };
|
|
4797
|
-
}
|
|
4798
|
-
if (candidate.kind === "outline" && typeof candidate.metadata?.ratio === "number") {
|
|
4799
|
-
return { kind: "outline", elementId, ratio: candidate.metadata.ratio };
|
|
4800
|
-
}
|
|
4801
|
-
// Defensive fallback — should not happen with built-in contributors.
|
|
4802
|
-
void shape;
|
|
4803
|
-
return { kind: "point", position: candidate.snapped };
|
|
4804
|
-
};
|
|
4805
|
-
// `resizeFromHandle`, `applyResizeConstraints`, the four handle-
|
|
4806
|
-
// quadrant predicates moved to `./editor/resize-helpers.ts` so
|
|
4807
|
-
// they're shared between applies/resize and the container clamp.
|
|
4808
4170
|
//# sourceMappingURL=editor.js.map
|