@oh-just-another/state 0.57.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 +90 -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 +36 -4
- package/dist/actions/actionClipboard.js.map +1 -1
- package/dist/actions/actionGrouping.d.ts.map +1 -1
- package/dist/actions/actionGrouping.js +3 -1
- package/dist/actions/actionGrouping.js.map +1 -1
- package/dist/actions/actionKeyboard.d.ts.map +1 -1
- package/dist/actions/actionKeyboard.js +6 -2
- package/dist/actions/actionKeyboard.js.map +1 -1
- package/dist/actions/actionLayout.d.ts.map +1 -1
- package/dist/actions/actionLayout.js +12 -4
- package/dist/actions/actionLayout.js.map +1 -1
- package/dist/actions/actionMode.d.ts.map +1 -1
- package/dist/actions/actionMode.js +30 -10
- package/dist/actions/actionMode.js.map +1 -1
- package/dist/actions/actionSelection.d.ts.map +1 -1
- package/dist/actions/actionSelection.js +18 -6
- package/dist/actions/actionSelection.js.map +1 -1
- package/dist/actions/actionText.d.ts +5 -0
- package/dist/actions/actionText.d.ts.map +1 -0
- package/dist/actions/actionText.js +37 -0
- package/dist/actions/actionText.js.map +1 -0
- package/dist/actions/actionView.d.ts.map +1 -1
- package/dist/actions/actionView.js +4 -2
- package/dist/actions/actionView.js.map +1 -1
- package/dist/actions/actionZOrder.d.ts.map +1 -1
- package/dist/actions/actionZOrder.js +12 -4
- package/dist/actions/actionZOrder.js.map +1 -1
- package/dist/actions/actionZoom.d.ts.map +1 -1
- package/dist/actions/actionZoom.js +17 -17
- package/dist/actions/actionZoom.js.map +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 +5 -3
- 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 +30 -7
- package/dist/built-in-handlers.js.map +1 -1
- package/dist/clipboard.d.ts.map +1 -1
- package/dist/clipboard.js +6 -7
- package/dist/clipboard.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/create.d.ts.map +1 -1
- package/dist/editor/applies/create.js +1 -1
- package/dist/editor/applies/create.js.map +1 -1
- package/dist/editor/applies/edge.d.ts +8 -1
- package/dist/editor/applies/edge.d.ts.map +1 -1
- package/dist/editor/applies/edge.js +31 -1
- package/dist/editor/applies/edge.js.map +1 -1
- package/dist/editor/applies/link-move.d.ts.map +1 -1
- package/dist/editor/applies/link-move.js +5 -2
- package/dist/editor/applies/link-move.js.map +1 -1
- package/dist/editor/applies/move.d.ts +6 -0
- package/dist/editor/applies/move.d.ts.map +1 -1
- package/dist/editor/applies/move.js +6 -0
- package/dist/editor/applies/move.js.map +1 -1
- package/dist/editor/applies/resize.d.ts +26 -5
- package/dist/editor/applies/resize.d.ts.map +1 -1
- package/dist/editor/applies/resize.js +92 -14
- package/dist/editor/applies/resize.js.map +1 -1
- package/dist/editor/applies/snap-grid.d.ts.map +1 -1
- package/dist/editor/applies/snap-grid.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 -7
- 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/annotations.d.ts.map +1 -1
- package/dist/editor/public/annotations.js.map +1 -1
- package/dist/editor/public/arrange-group.d.ts +3 -9
- package/dist/editor/public/arrange-group.d.ts.map +1 -1
- package/dist/editor/public/arrange-group.js +4 -6
- package/dist/editor/public/arrange-group.js.map +1 -1
- package/dist/editor/public/brush.d.ts.map +1 -1
- package/dist/editor/public/brush.js +1 -3
- package/dist/editor/public/brush.js.map +1 -1
- package/dist/editor/public/clipboard.d.ts +2 -4
- package/dist/editor/public/clipboard.d.ts.map +1 -1
- package/dist/editor/public/clipboard.js +2 -4
- package/dist/editor/public/clipboard.js.map +1 -1
- package/dist/editor/public/cursor.d.ts +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 +71 -5
- 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 +81 -8
- 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 +5 -5
- 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 +5 -11
- 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 -130
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +352 -985
- package/dist/editor.js.map +1 -1
- package/dist/file-drop.d.ts.map +1 -1
- package/dist/file-drop.js +7 -4
- 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 +142 -38
- 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 +9 -9
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,17 +2447,28 @@ export class Editor {
|
|
|
2667
2447
|
this.snapSuppressed = suppressed;
|
|
2668
2448
|
}
|
|
2669
2449
|
/**
|
|
2670
|
-
*
|
|
2671
|
-
*
|
|
2672
|
-
*
|
|
2673
|
-
*
|
|
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.
|
|
2674
2464
|
*/
|
|
2675
2465
|
snapActive() {
|
|
2676
|
-
|
|
2466
|
+
const viewport = this._scene.viewport;
|
|
2467
|
+
return !this.snapSuppressed && viewport.gridEnabled && isSnapToGridEnabled(viewport);
|
|
2677
2468
|
}
|
|
2678
2469
|
/** World-unit spacing the current gesture snaps to. */
|
|
2679
2470
|
snapSpacing() {
|
|
2680
|
-
return resolveSnapSpacing(
|
|
2471
|
+
return resolveSnapSpacing();
|
|
2681
2472
|
}
|
|
2682
2473
|
/**
|
|
2683
2474
|
* Replace the entire scene (e.g. after `parseScene`). Clears history,
|
|
@@ -2708,7 +2499,7 @@ export class Editor {
|
|
|
2708
2499
|
}
|
|
2709
2500
|
// Restore transient animationData (GIF bytes) from Scene.files
|
|
2710
2501
|
// before the tick so the animation adapter can decode frames.
|
|
2711
|
-
|
|
2502
|
+
animScene.rehydrateAnimatedImages(this);
|
|
2712
2503
|
this.notify();
|
|
2713
2504
|
// Loaded scene may carry animated shapes (e.g. GIF re-imported
|
|
2714
2505
|
// from saved JSON). Re-arm the tick — `metadata.animated` survives
|
|
@@ -2726,20 +2517,14 @@ export class Editor {
|
|
|
2726
2517
|
this.cursorListeners.clear();
|
|
2727
2518
|
this.longPressListeners.clear();
|
|
2728
2519
|
this.announceListeners.clear();
|
|
2729
|
-
this.
|
|
2520
|
+
this.animation.detach();
|
|
2730
2521
|
this.animationContentOff?.();
|
|
2731
|
-
if (typeof document !== "undefined") {
|
|
2732
|
-
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
2733
|
-
}
|
|
2734
2522
|
if (this.renderRafId !== null && typeof cancelAnimationFrame !== "undefined") {
|
|
2735
2523
|
cancelAnimationFrame(this.renderRafId);
|
|
2736
2524
|
this.renderRafId = null;
|
|
2737
2525
|
}
|
|
2738
2526
|
}
|
|
2739
2527
|
// --- Internal ---
|
|
2740
|
-
// Body moved to `./editor/pointer-binding.ts` (~700 lines of
|
|
2741
|
-
// pointer / wheel / keyboard dispatch). The thin wrapper here
|
|
2742
|
-
// preserves the original constructor call site.
|
|
2743
2528
|
bindPointerEvents() {
|
|
2744
2529
|
return bindPointerEventsExternal(this);
|
|
2745
2530
|
}
|
|
@@ -2795,14 +2580,14 @@ export class Editor {
|
|
|
2795
2580
|
isDrawingPhase(ctx) {
|
|
2796
2581
|
return ctx.mode === "draw-rect" || ctx.mode === "draw-ellipse" || ctx.mode === "draw-edge";
|
|
2797
2582
|
}
|
|
2798
|
-
// --- Long-press ---
|
|
2583
|
+
// --- Long-press ---
|
|
2799
2584
|
startLongPress(screenPoint) {
|
|
2800
2585
|
this.longPress.start(screenPoint);
|
|
2801
2586
|
}
|
|
2802
2587
|
cancelLongPress() {
|
|
2803
2588
|
this.longPress.cancel();
|
|
2804
2589
|
}
|
|
2805
|
-
// --- Pinch gesture ---
|
|
2590
|
+
// --- Pinch gesture ---
|
|
2806
2591
|
beginPinch() {
|
|
2807
2592
|
this.pinch.begin([...this.activePointers.values()]);
|
|
2808
2593
|
}
|
|
@@ -2817,9 +2602,9 @@ export class Editor {
|
|
|
2817
2602
|
screenToWorld(point) {
|
|
2818
2603
|
return matrix.applyToPoint(getScreenToWorld(this._scene.viewport), point);
|
|
2819
2604
|
}
|
|
2820
|
-
//
|
|
2821
|
-
//
|
|
2822
|
-
//
|
|
2605
|
+
// Editor passes a narrow context bundle that closes over its
|
|
2606
|
+
// private state + accel helpers (acceleratedElementAt,
|
|
2607
|
+
// isElementInteractable, …).
|
|
2823
2608
|
/**
|
|
2824
2609
|
* Attach target under `worldPoint` for an endpoint-rebind drop: the topmost
|
|
2825
2610
|
* interactable ELEMENT (group-promoted), ignoring link bodies and the dragged
|
|
@@ -2862,87 +2647,10 @@ export class Editor {
|
|
|
2862
2647
|
* out of sync. `worldPoint` defaults to the last known pointer position.
|
|
2863
2648
|
*/
|
|
2864
2649
|
refreshCursor(worldPoint) {
|
|
2865
|
-
const next =
|
|
2650
|
+
const next = computeCursor(this, worldPoint ?? this.lastPointerWorld);
|
|
2866
2651
|
if (this.host.style.cursor !== next)
|
|
2867
2652
|
this.host.style.cursor = next;
|
|
2868
2653
|
}
|
|
2869
|
-
/**
|
|
2870
|
-
* The CSS cursor for the current state. Priority: active gesture → text edit → pan affordance →
|
|
2871
|
-
* draw tool → idle hover hit-test. Pure read of editor state; no side effects.
|
|
2872
|
-
*/
|
|
2873
|
-
computeCursor(p) {
|
|
2874
|
-
// Each outcome is a (role, fallback-keyword) pair; `resolveCursor` returns
|
|
2875
|
-
// a host-registered custom image for that role if one exists, else the
|
|
2876
|
-
// keyword. Roles are the stable override keys (see `setCursorOverride`).
|
|
2877
|
-
const r = (role, keyword) => this.resolveCursor(role, keyword);
|
|
2878
|
-
const resizeRole = (h) => r(RESIZE_ROLE[h], cursorForHandle(h));
|
|
2879
|
-
// 1. Active gestures (highest priority — what the pointer is doing now).
|
|
2880
|
-
if (this.panGesture)
|
|
2881
|
-
return r("pan-active", "grabbing");
|
|
2882
|
-
if (this.linkDragFromAnchor?.moved === true)
|
|
2883
|
-
return r("draw", "crosshair");
|
|
2884
|
-
if (this.isDraggingWaypoint || this.isDraggingSegment)
|
|
2885
|
-
return r("move", "grabbing");
|
|
2886
|
-
if (this.annotationDrag?.moved === true)
|
|
2887
|
-
return r("move", "grabbing");
|
|
2888
|
-
if (this.brushStroke)
|
|
2889
|
-
return r("draw", "crosshair");
|
|
2890
|
-
// Machine-driven drag past the threshold (`gestureTx` opens then): resize
|
|
2891
|
-
// shows the handle's arrow; element / link move shows grabbing.
|
|
2892
|
-
if (this.gestureTx) {
|
|
2893
|
-
const t = this.actor.getSnapshot().context.pressTarget;
|
|
2894
|
-
if (t && (t.kind === "handle" || t.kind === "group-handle"))
|
|
2895
|
-
return resizeRole(t.handle);
|
|
2896
|
-
if (t && (t.kind === "element" || t.kind === "link" || t.kind === "edge-endpoint")) {
|
|
2897
|
-
return r("move", "grabbing");
|
|
2898
|
-
}
|
|
2899
|
-
}
|
|
2900
|
-
// 2. In-canvas text editing → I-beam.
|
|
2901
|
-
if (this.editingTextElement !== null)
|
|
2902
|
-
return r("text", "text");
|
|
2903
|
-
// 3. Pan affordance (idle): Space held or hand tool.
|
|
2904
|
-
if (this.spaceHeld || this.mode === "hand")
|
|
2905
|
-
return r("pan-ready", "grab");
|
|
2906
|
-
// 4. Draw tools (idle, before a gesture starts).
|
|
2907
|
-
switch (this.mode) {
|
|
2908
|
-
case "draw-rect":
|
|
2909
|
-
case "draw-ellipse":
|
|
2910
|
-
case "draw-frame":
|
|
2911
|
-
case "draw-edge":
|
|
2912
|
-
case "brush":
|
|
2913
|
-
return r("draw", "crosshair");
|
|
2914
|
-
case "draw-text":
|
|
2915
|
-
return r("text", "text");
|
|
2916
|
-
default:
|
|
2917
|
-
break;
|
|
2918
|
-
}
|
|
2919
|
-
// 5. Idle hover in select mode — key off the hit-test target.
|
|
2920
|
-
if (p) {
|
|
2921
|
-
if (this.isOverLinkStartDot(p))
|
|
2922
|
-
return r("link-start", "crosshair");
|
|
2923
|
-
const t = this.hitTest(p);
|
|
2924
|
-
switch (t.kind) {
|
|
2925
|
-
case "handle":
|
|
2926
|
-
case "group-handle":
|
|
2927
|
-
return resizeRole(t.handle);
|
|
2928
|
-
case "edge-endpoint":
|
|
2929
|
-
return r("link-handle", "grab");
|
|
2930
|
-
case "annotation":
|
|
2931
|
-
return r("annotation", "pointer");
|
|
2932
|
-
default:
|
|
2933
|
-
return r("default", "default");
|
|
2934
|
-
}
|
|
2935
|
-
}
|
|
2936
|
-
return r("default", "default");
|
|
2937
|
-
}
|
|
2938
|
-
/**
|
|
2939
|
-
* Resolve a cursor role to a CSS `cursor` value: a host-registered custom
|
|
2940
|
-
* image (via {@link setCursorOverride}) if present, else `fallbackKeyword`.
|
|
2941
|
-
*/
|
|
2942
|
-
resolveCursor(role, fallbackKeyword) {
|
|
2943
|
-
const spec = this.cursorOverrides.get(role);
|
|
2944
|
-
return spec === undefined ? fallbackKeyword : cssCursor(spec, fallbackKeyword);
|
|
2945
|
-
}
|
|
2946
2654
|
/**
|
|
2947
2655
|
* Register (or clear, with `null`) a custom cursor image for a state role.
|
|
2948
2656
|
* The image is shown wherever `computeCursor` resolves that role; pass a
|
|
@@ -2958,31 +2666,12 @@ export class Editor {
|
|
|
2958
2666
|
this.refreshCursor();
|
|
2959
2667
|
}
|
|
2960
2668
|
/**
|
|
2961
|
-
*
|
|
2962
|
-
*
|
|
2963
|
-
*
|
|
2964
|
-
* 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).
|
|
2965
2672
|
*/
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
return false;
|
|
2969
|
-
const id = [...this._selection][0];
|
|
2970
|
-
if (id === undefined)
|
|
2971
|
-
return false;
|
|
2972
|
-
const shape = getElement(this._scene, id);
|
|
2973
|
-
if (!shape)
|
|
2974
|
-
return false;
|
|
2975
|
-
const zoom = this._scene.viewport.zoom || 1;
|
|
2976
|
-
const { worldPoints } = anchorOverlayPoints(shape, LINK_START_ANCHOR_OUTSET / zoom);
|
|
2977
|
-
const grab = (ANCHOR_DOT_ACTIVE_RADIUS + this.anchorStartHitSlop) / zoom;
|
|
2978
|
-
const grab2 = grab * grab;
|
|
2979
|
-
for (const wp of worldPoints) {
|
|
2980
|
-
const dx = wp.x - p.x;
|
|
2981
|
-
const dy = wp.y - p.y;
|
|
2982
|
-
if (dx * dx + dy * dy <= grab2)
|
|
2983
|
-
return true;
|
|
2984
|
-
}
|
|
2985
|
-
return false;
|
|
2673
|
+
getCursorOverride(role) {
|
|
2674
|
+
return this.cursorOverrides.get(role);
|
|
2986
2675
|
}
|
|
2987
2676
|
/** True when the given layer exists and is marked `locked`. */
|
|
2988
2677
|
isLayerLocked(layerId) {
|
|
@@ -3070,8 +2759,7 @@ export class Editor {
|
|
|
3070
2759
|
* Topmost group ancestor of `shape` (walks parentId chain, returns
|
|
3071
2760
|
* the highest `type === "group"` parent). `null` if `shape` has no
|
|
3072
2761
|
* group ancestor. Used by drill-down: a double-click on a shape
|
|
3073
|
-
* with a group ancestor enters that group.
|
|
3074
|
-
* `./group-helpers.ts`.
|
|
2762
|
+
* with a group ancestor enters that group.
|
|
3075
2763
|
*/
|
|
3076
2764
|
topGroupAncestor(shape) {
|
|
3077
2765
|
return topGroupAncestorHelper(this._scene, shape);
|
|
@@ -3098,7 +2786,6 @@ export class Editor {
|
|
|
3098
2786
|
* practice, but the guard keeps the contract simple — "what you've
|
|
3099
2787
|
* selected, you can see".
|
|
3100
2788
|
*/
|
|
3101
|
-
// Body moved to `./editor/shape-filters.ts`.
|
|
3102
2789
|
computeHiddenElements() {
|
|
3103
2790
|
return computeHiddenElementsPure(this._scene);
|
|
3104
2791
|
}
|
|
@@ -3128,7 +2815,7 @@ export class Editor {
|
|
|
3128
2815
|
const el = getElement(this._scene, id);
|
|
3129
2816
|
if (!el)
|
|
3130
2817
|
return;
|
|
3131
|
-
const isFrameEl = el
|
|
2818
|
+
const isFrameEl = isFrame(el);
|
|
3132
2819
|
const members = [];
|
|
3133
2820
|
for (const s of this._scene.elements.values()) {
|
|
3134
2821
|
if (s.parentId === id || (isFrameEl && s.frameId === id))
|
|
@@ -3136,7 +2823,7 @@ export class Editor {
|
|
|
3136
2823
|
}
|
|
3137
2824
|
if (members.length === 0)
|
|
3138
2825
|
return;
|
|
3139
|
-
if (el
|
|
2826
|
+
if (isGroup(el))
|
|
3140
2827
|
this._enteredGroup = id;
|
|
3141
2828
|
this.setSelection(members);
|
|
3142
2829
|
}
|
|
@@ -3230,7 +2917,7 @@ export class Editor {
|
|
|
3230
2917
|
const now = performance.now();
|
|
3231
2918
|
const isDouble = now - this.lastClickAt < DOUBLE_CLICK_MS &&
|
|
3232
2919
|
this.lastClickWorldPoint !== null &&
|
|
3233
|
-
|
|
2920
|
+
vec2.distance(this.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
|
|
3234
2921
|
this.lastClickAt = now;
|
|
3235
2922
|
this.lastClickWorldPoint = worldPoint;
|
|
3236
2923
|
// Double-click the frame HEADER (label strip above the body) → rename.
|
|
@@ -3277,11 +2964,11 @@ export class Editor {
|
|
|
3277
2964
|
if (isDouble &&
|
|
3278
2965
|
(clickEffect.type === "SELECT_REPLACE" || clickEffect.type === "SELECT_TOGGLE")) {
|
|
3279
2966
|
const raw = this.acceleratedElementAt(worldPoint);
|
|
3280
|
-
if (raw
|
|
2967
|
+
if (raw !== undefined && isText(raw)) {
|
|
3281
2968
|
this.beginTextEdit(raw.id);
|
|
3282
2969
|
return true;
|
|
3283
2970
|
}
|
|
3284
|
-
if (raw
|
|
2971
|
+
if (raw !== undefined && isFrame(raw)) {
|
|
3285
2972
|
this.beginFrameNameEdit(raw.id);
|
|
3286
2973
|
return true;
|
|
3287
2974
|
}
|
|
@@ -3396,6 +3083,9 @@ export class Editor {
|
|
|
3396
3083
|
case "RESIZE_SHAPE":
|
|
3397
3084
|
this.applyResize(emit.id, emit.handle, emit.delta, emit.originalBounds);
|
|
3398
3085
|
return;
|
|
3086
|
+
case "ROTATE":
|
|
3087
|
+
this.applyRotate(emit.deltaAngle);
|
|
3088
|
+
return;
|
|
3399
3089
|
case "CREATE_SHAPE":
|
|
3400
3090
|
this.applyCreate(emit.shapeType, emit.bounds);
|
|
3401
3091
|
return;
|
|
@@ -3442,7 +3132,6 @@ export class Editor {
|
|
|
3442
3132
|
* Wrapped in a single gestureTx so per-move updates collapse
|
|
3443
3133
|
* into one undo step.
|
|
3444
3134
|
*/
|
|
3445
|
-
// Pure body in `./editor/applies/move.ts`.
|
|
3446
3135
|
applyAnnotationMove(id, delta, origin) {
|
|
3447
3136
|
const result = computeAnnotationMovePatch(this._scene, id, delta, origin);
|
|
3448
3137
|
if (!result)
|
|
@@ -3478,13 +3167,14 @@ export class Editor {
|
|
|
3478
3167
|
dispatchTemplateDrop(emit) {
|
|
3479
3168
|
this.applyEmit(emit);
|
|
3480
3169
|
}
|
|
3481
|
-
// Pure body in `./editor/applies/move.ts`.
|
|
3482
3170
|
applyMove(id, delta, originalBounds) {
|
|
3483
3171
|
// Locked / layer-locked elements are selectable but don't move.
|
|
3484
3172
|
const el = getElement(this._scene, id);
|
|
3485
3173
|
if (el && !this.isElementManipulable(el))
|
|
3486
3174
|
return;
|
|
3487
|
-
|
|
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;
|
|
3488
3178
|
const patch = computeElementMovePatch(this._scene, id, d, originalBounds);
|
|
3489
3179
|
if (!patch)
|
|
3490
3180
|
return;
|
|
@@ -3495,9 +3185,11 @@ export class Editor {
|
|
|
3495
3185
|
applyGroupMove(delta) {
|
|
3496
3186
|
if (!this.groupMoveOrigin)
|
|
3497
3187
|
return;
|
|
3188
|
+
// Shift constrains the drag to one axis before snapping.
|
|
3189
|
+
const moved = this.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
|
|
3498
3190
|
const d = this.snapActive()
|
|
3499
|
-
? snapGroupDelta(this.groupMoveOrigin,
|
|
3500
|
-
:
|
|
3191
|
+
? snapGroupDelta(this.groupMoveOrigin, moved, this.snapSpacing())
|
|
3192
|
+
: moved;
|
|
3501
3193
|
const patches = computeGroupMovePatches(this._scene, this.groupMoveOrigin, d);
|
|
3502
3194
|
for (const patch of patches) {
|
|
3503
3195
|
this._scene = apply(this._scene, patch);
|
|
@@ -3515,7 +3207,6 @@ export class Editor {
|
|
|
3515
3207
|
}
|
|
3516
3208
|
this.notify();
|
|
3517
3209
|
}
|
|
3518
|
-
// Body moved to `./editor/viewport-helpers.ts`.
|
|
3519
3210
|
computeViewportWorld() {
|
|
3520
3211
|
return computeViewportWorldPure(this._scene);
|
|
3521
3212
|
}
|
|
@@ -3584,125 +3275,16 @@ export class Editor {
|
|
|
3584
3275
|
// repaint when the entered-group identity changes between frames.
|
|
3585
3276
|
if (this.lastRenderedEnteredGroup !== this._enteredGroup)
|
|
3586
3277
|
return null;
|
|
3587
|
-
//
|
|
3588
|
-
//
|
|
3589
|
-
//
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
const add = (b) => {
|
|
3595
|
-
acc = acc ? B.union(acc, b) : b;
|
|
3596
|
-
};
|
|
3597
|
-
// Track shapes that changed (added / removed / mutated). Links
|
|
3598
|
-
// attached to any of these have stale rendered paths even when
|
|
3599
|
-
// the edge object itself is reference-equal — the path resolves
|
|
3600
|
-
// through the shape's new position, but the old path stays on
|
|
3601
|
-
// screen as a "ghost" trail unless we explicitly invalidate it.
|
|
3602
|
-
const changedElementIds = new Set();
|
|
3603
|
-
for (const [id, shape] of next.elements) {
|
|
3604
|
-
const old = prev.elements.get(id);
|
|
3605
|
-
if (old === shape)
|
|
3606
|
-
continue;
|
|
3607
|
-
changedElementIds.add(id);
|
|
3608
|
-
// Render bounds (not geometric) so overpaint — a frame's header
|
|
3609
|
-
// strip, confetti particles — is cleared too, no ghost trail.
|
|
3610
|
-
const afterBounds = getElementRenderBounds(shape);
|
|
3611
|
-
const beforeBounds = old ? getElementRenderBounds(old) : null;
|
|
3612
|
-
add(afterBounds);
|
|
3613
|
-
if (beforeBounds)
|
|
3614
|
-
add(beforeBounds);
|
|
3615
|
-
// Stash for the tile-cache path — covers add + move via
|
|
3616
|
-
// before/after pair; pure mutation re-uses the single
|
|
3617
|
-
// afterBounds rect.
|
|
3618
|
-
if (this.tileComposeFn !== null) {
|
|
3619
|
-
this.tileDirtyElements.set(id, { before: beforeBounds, after: afterBounds });
|
|
3620
|
-
}
|
|
3621
|
-
}
|
|
3622
|
-
for (const [id, shape] of prev.elements) {
|
|
3623
|
-
if (!next.elements.has(id)) {
|
|
3624
|
-
changedElementIds.add(id);
|
|
3625
|
-
// Render bounds so a removed frame/confetti clears its overpaint.
|
|
3626
|
-
const beforeBounds = getElementRenderBounds(shape);
|
|
3627
|
-
add(beforeBounds);
|
|
3628
|
-
if (this.tileComposeFn !== null) {
|
|
3629
|
-
this.tileDirtyElements.set(id, { before: beforeBounds, after: null });
|
|
3630
|
-
}
|
|
3631
|
-
}
|
|
3632
|
-
}
|
|
3633
|
-
const linkTouchesChangedElement = (edge) => {
|
|
3634
|
-
for (const ep of [edge.from, edge.to]) {
|
|
3635
|
-
if (ep.kind !== "point") {
|
|
3636
|
-
if (changedElementIds.has(ep.elementId))
|
|
3637
|
-
return true;
|
|
3638
|
-
}
|
|
3639
|
-
}
|
|
3640
|
-
return false;
|
|
3641
|
-
};
|
|
3642
|
-
for (const [id, edge] of next.links) {
|
|
3643
|
-
const old = prev.links.get(id);
|
|
3644
|
-
// Refresh edge dirty-rect when: edge object changed, OR an
|
|
3645
|
-
// endpoint references a shape that moved this frame (path is
|
|
3646
|
-
// re-resolved every render but the old screen pixels persist).
|
|
3647
|
-
if (old === edge && !linkTouchesChangedElement(edge))
|
|
3648
|
-
continue;
|
|
3649
|
-
const b = computeLinkWorldBounds(next, edge);
|
|
3650
|
-
if (b)
|
|
3651
|
-
add(b);
|
|
3652
|
-
const oldLink = old ?? edge; // prev scene resolves with prev shapes for ghost-clear
|
|
3653
|
-
const ob = computeLinkWorldBounds(prev, oldLink);
|
|
3654
|
-
if (ob)
|
|
3655
|
-
add(ob);
|
|
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);
|
|
3656
3285
|
}
|
|
3657
|
-
|
|
3658
|
-
if (!next.links.has(id)) {
|
|
3659
|
-
const b = computeLinkWorldBounds(prev, edge);
|
|
3660
|
-
if (b)
|
|
3661
|
-
add(b);
|
|
3662
|
-
}
|
|
3663
|
-
}
|
|
3664
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- `acc` is mutated via the `add` closure; TS flow analysis can't see it and narrows to null
|
|
3665
|
-
if (acc === null)
|
|
3666
|
-
return { x: -1e9, y: -1e9, width: 0, height: 0 };
|
|
3667
|
-
// Transitive expansion: any shape whose bounds intersect the
|
|
3668
|
-
// current dirty rect must be repainted, AND its bounds added
|
|
3669
|
-
// to the dirty rect so any shape ABOVE it that overlaps gets
|
|
3670
|
-
// included too. Repeat until the set stabilises.
|
|
3671
|
-
//
|
|
3672
|
-
// Without this, dragging A through a B/C stack produces
|
|
3673
|
-
// visual jitter: B intersects the dirty rect and gets
|
|
3674
|
-
// repainted, but C — sitting above B and partially overlapping
|
|
3675
|
-
// it — doesn't intersect the original dirty, so B re-emerges
|
|
3676
|
-
// on top of where C should still be drawn. Z-order is correct
|
|
3677
|
-
// in `getElementsInLayer`; the issue is missed shapes, not
|
|
3678
|
-
// wrong order.
|
|
3679
|
-
const visited = new Set();
|
|
3680
|
-
let expanded = acc;
|
|
3681
|
-
let grew = true;
|
|
3682
|
-
while (grew) {
|
|
3683
|
-
grew = false;
|
|
3684
|
-
for (const shape of next.elements.values()) {
|
|
3685
|
-
if (visited.has(shape.id))
|
|
3686
|
-
continue;
|
|
3687
|
-
const bb = getElementWorldBounds(shape);
|
|
3688
|
-
if (!B.intersects(bb, expanded))
|
|
3689
|
-
continue;
|
|
3690
|
-
visited.add(shape.id);
|
|
3691
|
-
const merged = B.union(expanded, bb);
|
|
3692
|
-
if (merged.x !== expanded.x ||
|
|
3693
|
-
merged.y !== expanded.y ||
|
|
3694
|
-
merged.width !== expanded.width ||
|
|
3695
|
-
merged.height !== expanded.height) {
|
|
3696
|
-
expanded = merged;
|
|
3697
|
-
grew = true;
|
|
3698
|
-
}
|
|
3699
|
-
}
|
|
3700
|
-
}
|
|
3701
|
-
// Inflate by a couple pixels to cover anti-aliased stroke fuzz
|
|
3702
|
-
// around the geometry edges.
|
|
3703
|
-
return B.expand(expanded, 4);
|
|
3286
|
+
return world;
|
|
3704
3287
|
}
|
|
3705
|
-
// Bodies moved to `./editor/viewport-helpers.ts`.
|
|
3706
3288
|
combinedSelectionBounds() {
|
|
3707
3289
|
let acc = combinedSelectionBoundsPure(this._scene, this._selection);
|
|
3708
3290
|
// Selected links join the selection box (standard parity) — union in
|
|
@@ -3758,19 +3340,42 @@ export class Editor {
|
|
|
3758
3340
|
// Multi-selection: lock when every selected shape is an image — they
|
|
3759
3341
|
// must never be stretched out of ratio, only scaled together.
|
|
3760
3342
|
for (const id of this._selection) {
|
|
3761
|
-
|
|
3343
|
+
const el = getElement(this._scene, id);
|
|
3344
|
+
if (el === undefined || !isImage(el))
|
|
3762
3345
|
return false;
|
|
3763
3346
|
}
|
|
3764
3347
|
return true;
|
|
3765
3348
|
}
|
|
3766
|
-
|
|
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
|
+
}
|
|
3767
3369
|
applyGroupResize(handle, delta, originalBounds) {
|
|
3768
3370
|
if (!this.groupResizeOrigin)
|
|
3769
3371
|
return;
|
|
3770
3372
|
const d = this.snapActive()
|
|
3771
3373
|
? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
|
|
3772
3374
|
: delta;
|
|
3773
|
-
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);
|
|
3774
3379
|
this._scene = result.scene;
|
|
3775
3380
|
for (const patch of result.patches)
|
|
3776
3381
|
this.recordGesturePatch(patch);
|
|
@@ -3778,16 +3383,32 @@ export class Editor {
|
|
|
3778
3383
|
}
|
|
3779
3384
|
applyResize(id, handle, delta, originalBounds) {
|
|
3780
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
|
+
}
|
|
3781
3402
|
const d = this.snapActive()
|
|
3782
3403
|
? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
|
|
3783
3404
|
: delta;
|
|
3784
3405
|
// Text: aspect-locked font scaling. Snapshot the pristine shape on
|
|
3785
3406
|
// the gesture's first tick so the scale base never compounds.
|
|
3786
|
-
if (shape
|
|
3407
|
+
if (shape !== undefined && isText(shape)) {
|
|
3787
3408
|
if (this._resizeOriginElement?.id !== id) {
|
|
3788
3409
|
this._resizeOriginElement = shape;
|
|
3789
3410
|
}
|
|
3790
|
-
const result = computeTextResize(this._scene, this._resizeOriginElement, handle, d, originalBounds);
|
|
3411
|
+
const result = computeTextResize(this._scene, this._resizeOriginElement, handle, d, originalBounds, this.transformAltKey);
|
|
3791
3412
|
if (!result)
|
|
3792
3413
|
return;
|
|
3793
3414
|
this._scene = result.scene;
|
|
@@ -3795,14 +3416,13 @@ export class Editor {
|
|
|
3795
3416
|
this.notify();
|
|
3796
3417
|
return;
|
|
3797
3418
|
}
|
|
3798
|
-
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);
|
|
3799
3420
|
if (!result)
|
|
3800
3421
|
return;
|
|
3801
3422
|
this._scene = result.scene;
|
|
3802
3423
|
this.recordGesturePatch(result.patch);
|
|
3803
3424
|
this.notify();
|
|
3804
3425
|
}
|
|
3805
|
-
// Pure body in `./editor/applies/create.ts`.
|
|
3806
3426
|
applyCreate(kind, bounds) {
|
|
3807
3427
|
const id = newElementId(++this.nextId);
|
|
3808
3428
|
const b = this.snapActive() ? snapCreateBounds(bounds, this.snapSpacing()) : bounds;
|
|
@@ -3842,8 +3462,7 @@ export class Editor {
|
|
|
3842
3462
|
reconcileFrameMembership() {
|
|
3843
3463
|
this._scene = reconcileFrameMembershipHelper(this._scene, this._history);
|
|
3844
3464
|
}
|
|
3845
|
-
//
|
|
3846
|
-
// stays here because it needs the snap engine.
|
|
3465
|
+
// Endpoint snapping stays here because it needs the snap engine.
|
|
3847
3466
|
applyCreateLink(emit) {
|
|
3848
3467
|
const from = this.snapLinkEndpoint(emit.fromElement, emit.fromPoint);
|
|
3849
3468
|
const to = this.snapLinkEndpoint(emit.toElement, emit.toPoint);
|
|
@@ -3882,35 +3501,11 @@ export class Editor {
|
|
|
3882
3501
|
return;
|
|
3883
3502
|
}
|
|
3884
3503
|
const newId = newElementId(++this.nextId);
|
|
3885
|
-
const
|
|
3886
|
-
.filter((sh) => sh.layerId === this._activeLayerId)
|
|
3887
|
-
.map((sh) => sh.order));
|
|
3888
|
-
const built = factory({
|
|
3889
|
-
id: newId,
|
|
3890
|
-
layerId: this._activeLayerId,
|
|
3891
|
-
position: pending.world,
|
|
3892
|
-
order,
|
|
3893
|
-
});
|
|
3894
|
-
// Centre the element on the drop point regardless of how the factory
|
|
3895
|
-
// anchored it at `position`.
|
|
3896
|
-
const wb = getElementWorldBounds(built);
|
|
3897
|
-
const shape = {
|
|
3898
|
-
...built,
|
|
3899
|
-
position: {
|
|
3900
|
-
x: built.position.x + (pending.world.x - (wb.x + wb.width / 2)),
|
|
3901
|
-
y: built.position.y + (pending.world.y - (wb.y + wb.height / 2)),
|
|
3902
|
-
},
|
|
3903
|
-
};
|
|
3504
|
+
const r = computeShapeAtLinkDrop(this._scene, this._activeLayerId, pending, newId, factory);
|
|
3904
3505
|
const tx = this._history.transaction();
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
tx.add(
|
|
3908
|
-
const upd = updateLink(this._scene, pending.linkId, (e) => ({
|
|
3909
|
-
...e,
|
|
3910
|
-
[pending.side]: { kind: "floating", elementId: newId },
|
|
3911
|
-
}));
|
|
3912
|
-
this._scene = upd.scene;
|
|
3913
|
-
tx.add(upd.patch);
|
|
3506
|
+
this._scene = r.scene;
|
|
3507
|
+
tx.add(r.addPatch);
|
|
3508
|
+
tx.add(r.linkPatch);
|
|
3914
3509
|
tx.commit();
|
|
3915
3510
|
this.pendingLinkDropMenu = null;
|
|
3916
3511
|
this._selection = Selection.single(newId);
|
|
@@ -3932,50 +3527,15 @@ export class Editor {
|
|
|
3932
3527
|
* selection. Element + link land in one undo step.
|
|
3933
3528
|
*/
|
|
3934
3529
|
createLinkedElementFromAnchor(fromElement, anchorName) {
|
|
3935
|
-
const src = getElement(this._scene, fromElement);
|
|
3936
|
-
if (!src)
|
|
3937
|
-
return;
|
|
3938
|
-
const anchor = { kind: "named", name: anchorName };
|
|
3939
|
-
const normal = getAnchorOutwardNormal(src, anchor);
|
|
3940
|
-
const bounds = getElementWorldBounds(src);
|
|
3941
|
-
const srcCenter = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
|
|
3942
|
-
// Same-size clone → centre-to-centre distance = the source's extent
|
|
3943
|
-
// along the normal + the gap, leaving exactly
|
|
3944
|
-
// ANCHOR_CLICK_NEW_ELEMENT_GAP between the facing edges. `extentAlong`
|
|
3945
|
-
// resolves to width for a horizontal normal, height for a vertical one
|
|
3946
|
-
// (link-start dots are the four edge midpoints).
|
|
3947
|
-
const extentAlong = Math.abs(normal.x) * bounds.width + Math.abs(normal.y) * bounds.height;
|
|
3948
|
-
const dist = extentAlong + ANCHOR_CLICK_NEW_ELEMENT_GAP;
|
|
3949
|
-
const delta = { x: normal.x * dist, y: normal.y * dist };
|
|
3950
3530
|
const newId = newElementId(++this.nextId);
|
|
3951
|
-
const order = orderForTop([...this._scene.elements.values()]
|
|
3952
|
-
.filter((sh) => sh.layerId === src.layerId)
|
|
3953
|
-
.map((sh) => sh.order));
|
|
3954
|
-
let clone = {
|
|
3955
|
-
...src,
|
|
3956
|
-
id: newId,
|
|
3957
|
-
position: { x: src.position.x + delta.x, y: src.position.y + delta.y },
|
|
3958
|
-
order,
|
|
3959
|
-
};
|
|
3960
|
-
// Blank user text — the new element is a fresh same-kind shape, not a
|
|
3961
|
-
// content copy (standard). Only `text` (TextElement) and `name`
|
|
3962
|
-
// (FrameElement) carry user-entered text. Cast through `Element` because
|
|
3963
|
-
// `exactOptionalPropertyTypes` rejects the bare object literal against
|
|
3964
|
-
// the union (TS2375), even though the narrowed branch is sound.
|
|
3965
|
-
if (clone.type === "text")
|
|
3966
|
-
clone = { ...clone, text: "" };
|
|
3967
|
-
else if (clone.type === "frame")
|
|
3968
|
-
clone = { ...clone, name: "" };
|
|
3969
|
-
const tx = this._history.transaction();
|
|
3970
|
-
const added = addElement(this._scene, clone);
|
|
3971
|
-
this._scene = added.scene;
|
|
3972
|
-
tx.add(added.patch);
|
|
3973
3531
|
const linkId = newLinkId(++this.nextId);
|
|
3974
|
-
const
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
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);
|
|
3979
3539
|
tx.commit();
|
|
3980
3540
|
this._selection = Selection.single(newId);
|
|
3981
3541
|
if (this._selectedLinks.size > 0)
|
|
@@ -3989,72 +3549,7 @@ export class Editor {
|
|
|
3989
3549
|
* `createLinkedElementFromAnchor`.
|
|
3990
3550
|
*/
|
|
3991
3551
|
previewClickCreate(fromElement, anchorName) {
|
|
3992
|
-
|
|
3993
|
-
if (!src)
|
|
3994
|
-
return null;
|
|
3995
|
-
const anchor = { kind: "named", name: anchorName };
|
|
3996
|
-
const normal = getAnchorOutwardNormal(src, anchor);
|
|
3997
|
-
const b = getElementWorldBounds(src);
|
|
3998
|
-
const extentAlong = Math.abs(normal.x) * b.width + Math.abs(normal.y) * b.height;
|
|
3999
|
-
const dist = extentAlong + ANCHOR_CLICK_NEW_ELEMENT_GAP;
|
|
4000
|
-
const delta = { x: normal.x * dist, y: normal.y * dist };
|
|
4001
|
-
const bounds = { x: b.x + delta.x, y: b.y + delta.y, width: b.width, height: b.height };
|
|
4002
|
-
const fromWorld = getAnchorWorld(src, anchor);
|
|
4003
|
-
// Facing edge of the ghost (toward the source) = its centre pulled back
|
|
4004
|
-
// along the normal by half its extent.
|
|
4005
|
-
const ghostCx = bounds.x + bounds.width / 2;
|
|
4006
|
-
const ghostCy = bounds.y + bounds.height / 2;
|
|
4007
|
-
const nearEdge = {
|
|
4008
|
-
x: ghostCx - normal.x * (extentAlong / 2),
|
|
4009
|
-
y: ghostCy - normal.y * (extentAlong / 2),
|
|
4010
|
-
};
|
|
4011
|
-
// The would-be element itself — a same-kind clone of the source shifted
|
|
4012
|
-
// to the ghost bounds, with blank user text (mirrors
|
|
4013
|
-
// `createLinkedElementFromAnchor`). The overlay renders THIS through the
|
|
4014
|
-
// real renderer so the ghost looks like the actual shape (an ellipse
|
|
4015
|
-
// ghosts as an ellipse), not a bounding rect. Throwaway id — never enters
|
|
4016
|
-
// the real scene.
|
|
4017
|
-
let element = {
|
|
4018
|
-
...src,
|
|
4019
|
-
id: PREVIEW_GHOST_ELEMENT_ID,
|
|
4020
|
-
position: { x: src.position.x + delta.x, y: src.position.y + delta.y },
|
|
4021
|
-
};
|
|
4022
|
-
if (element.type === "text")
|
|
4023
|
-
element = { ...element, text: "" };
|
|
4024
|
-
else if (element.type === "frame")
|
|
4025
|
-
element = { ...element, name: "" };
|
|
4026
|
-
// Build a throwaway scene holding the ghost element + the would-be link so
|
|
4027
|
-
// the connector can be drawn through the REAL link renderer (same routing,
|
|
4028
|
-
// arrowhead and style it'll have once created) — faded — instead of a
|
|
4029
|
-
// dashed preview line. Mirrors the link build in
|
|
4030
|
-
// `createLinkedElementFromAnchor` exactly. The `path` field stays for
|
|
4031
|
-
// callers that just want the straight from→to segment.
|
|
4032
|
-
const srcCenter = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
|
|
4033
|
-
const withGhost = addElement(this._scene, element).scene;
|
|
4034
|
-
const placed = req(getElement(withGhost, PREVIEW_GHOST_ELEMENT_ID));
|
|
4035
|
-
const { ref: toRef } = findNearestAnchor(placed, srcCenter, snapExcludedAnchors(placed));
|
|
4036
|
-
const linkResult = computeCreateLink(withGhost, { kind: "anchor", elementId: fromElement, anchor }, { kind: "anchor", elementId: PREVIEW_GHOST_ELEMENT_ID, anchor: toRef }, PREVIEW_GHOST_LINK_ID, this._activeLayerId);
|
|
4037
|
-
let ghostScene = linkResult.scene;
|
|
4038
|
-
const edge = req(getLink(ghostScene, PREVIEW_GHOST_LINK_ID));
|
|
4039
|
-
if ((edge.routing ?? "straight") === "orthogonal") {
|
|
4040
|
-
const routedPoints = routeElbowLink(ghostScene, edge);
|
|
4041
|
-
ghostScene = updateLink(ghostScene, PREVIEW_GHOST_LINK_ID, (e) => ({
|
|
4042
|
-
...e,
|
|
4043
|
-
routedPoints,
|
|
4044
|
-
})).scene;
|
|
4045
|
-
}
|
|
4046
|
-
// Render only the ghost link (the shapes stay for endpoint resolution).
|
|
4047
|
-
ghostScene = {
|
|
4048
|
-
...ghostScene,
|
|
4049
|
-
links: new Map([[PREVIEW_GHOST_LINK_ID, req(getLink(ghostScene, PREVIEW_GHOST_LINK_ID))]]),
|
|
4050
|
-
};
|
|
4051
|
-
return {
|
|
4052
|
-
bounds,
|
|
4053
|
-
path: [fromWorld, nearEdge],
|
|
4054
|
-
element,
|
|
4055
|
-
ghostScene,
|
|
4056
|
-
ghostLinkId: PREVIEW_GHOST_LINK_ID,
|
|
4057
|
-
};
|
|
3552
|
+
return previewClickCreatePure(this._scene, this._activeLayerId, fromElement, anchorName);
|
|
4058
3553
|
}
|
|
4059
3554
|
/**
|
|
4060
3555
|
* Build an `LinkEndpoint` for a draw-edge / re-bind gesture. Runs the
|
|
@@ -4068,61 +3563,10 @@ export class Editor {
|
|
|
4068
3563
|
* shapes when the user clearly aimed for this one).
|
|
4069
3564
|
*/
|
|
4070
3565
|
snapLinkEndpoint(pressTargetElement, worldPoint) {
|
|
4071
|
-
|
|
4072
|
-
scene: this._scene,
|
|
4073
|
-
probe: worldPoint,
|
|
4074
|
-
threshold: this.snapThreshold,
|
|
4075
|
-
gesture: "draw-edge",
|
|
4076
|
-
});
|
|
4077
|
-
// Attach contract: dropping on a port dot → *fixed* anchor; dropping
|
|
4078
|
-
// near an EDGE (not a dot) → *fixed* outline point (a ratio along the
|
|
4079
|
-
// perimeter — survives move/resize); dropping on the body interior (no
|
|
4080
|
-
// snap to a dot or edge) → *floating* against the whole shape, so the
|
|
4081
|
-
// connection re-aims at the partner as either shape moves; dropping on
|
|
4082
|
-
// empty canvas → a free point.
|
|
4083
|
-
//
|
|
4084
|
-
// Pick a candidate ON the pressed shape if there is one, otherwise the
|
|
4085
|
-
// nearest overall. The "nearest overall" branch matters because the
|
|
4086
|
-
// attach DOTS are drawn OUTSIDE the body — a release on a dot finds no
|
|
4087
|
-
// element under it (hit-test = empty), so `pressTargetElement` is null,
|
|
4088
|
-
// yet the snap engine still reports the dot's anchor within threshold.
|
|
4089
|
-
// Binding it makes the endpoint enter PERPENDICULAR to that edge instead
|
|
4090
|
-
// of staying a free point that aims at the partner (jumping between the
|
|
4091
|
-
// four sides).
|
|
4092
|
-
const pick = (kind) => {
|
|
4093
|
-
if (pressTargetElement !== null) {
|
|
4094
|
-
const onTarget = result.all.find((c) => c.kind === kind && c.metadata?.elementId === pressTargetElement);
|
|
4095
|
-
if (onTarget)
|
|
4096
|
-
return onTarget;
|
|
4097
|
-
}
|
|
4098
|
-
return result.all.find((c) => c.kind === kind);
|
|
4099
|
-
};
|
|
4100
|
-
const boundFrom = (cand, want) => {
|
|
4101
|
-
if (!cand)
|
|
4102
|
-
return null;
|
|
4103
|
-
const elId = cand.metadata?.elementId;
|
|
4104
|
-
if (elId === undefined)
|
|
4105
|
-
return null;
|
|
4106
|
-
const shp = getElement(this._scene, elId);
|
|
4107
|
-
if (!shp)
|
|
4108
|
-
return null;
|
|
4109
|
-
const ep = endpointFromSnap(elId, cand, shp);
|
|
4110
|
-
return ep.kind === want ? ep : null;
|
|
4111
|
-
};
|
|
4112
|
-
const anchorEp = boundFrom(pick("anchor"), "anchor");
|
|
4113
|
-
if (anchorEp)
|
|
4114
|
-
return anchorEp;
|
|
4115
|
-
const outlineEp = boundFrom(pick("outline"), "outline");
|
|
4116
|
-
if (outlineEp)
|
|
4117
|
-
return outlineEp;
|
|
4118
|
-
// No dot/edge snap. Over a shape body → floating; else a free point.
|
|
4119
|
-
if (pressTargetElement !== null && getElement(this._scene, pressTargetElement)) {
|
|
4120
|
-
return { kind: "floating", elementId: pressTargetElement };
|
|
4121
|
-
}
|
|
4122
|
-
return { kind: "point", position: worldPoint };
|
|
3566
|
+
return snapLinkEndpointPure(this._scene, this.snapEngine, this.snapThreshold, pressTargetElement, worldPoint);
|
|
4123
3567
|
}
|
|
4124
|
-
//
|
|
4125
|
-
//
|
|
3568
|
+
// The wrappers here own the side effects (`_selectedLink`
|
|
3569
|
+
// clearing, notify).
|
|
4126
3570
|
applySelectByBounds(bounds, mode) {
|
|
4127
3571
|
const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode);
|
|
4128
3572
|
const nextLinks = selectLinksByBoundsLivePure(this._scene, mode === "add" ? this._selectedLinks : LinkSelection.EMPTY, (id) => this.isLayerLocked(id), bounds, mode);
|
|
@@ -4146,9 +3590,8 @@ export class Editor {
|
|
|
4146
3590
|
this._selectedLinks = nextLinks;
|
|
4147
3591
|
this._selection = next;
|
|
4148
3592
|
}
|
|
4149
|
-
//
|
|
4150
|
-
//
|
|
4151
|
-
// notify).
|
|
3593
|
+
// The wrapper here owns the side effects (history push,
|
|
3594
|
+
// drag-state clearing, notify).
|
|
4152
3595
|
/**
|
|
4153
3596
|
* Live endpoint-rebind move: re-point the dragged end to the cursor in the
|
|
4154
3597
|
* scene (a free `point` endpoint), recorded in the gesture transaction so the
|
|
@@ -4272,7 +3715,7 @@ export class Editor {
|
|
|
4272
3715
|
const collapse = WAYPOINT_COLLAPSE_RADIUS / (this._scene.viewport.zoom || 1);
|
|
4273
3716
|
const a = path?.[drag.index];
|
|
4274
3717
|
const b = path?.[drag.index + 2];
|
|
4275
|
-
if (a && b &&
|
|
3718
|
+
if (a && b && hitTest.distanceToSegment(wp, a, b) <= collapse) {
|
|
4276
3719
|
const wps = edge.waypoints.filter((_, i) => i !== drag.index);
|
|
4277
3720
|
const r = updateLink(this._scene, drag.linkId, (e) => ({ ...e, waypoints: wps }));
|
|
4278
3721
|
this._scene = r.scene;
|
|
@@ -4341,7 +3784,7 @@ export class Editor {
|
|
|
4341
3784
|
const now = performance.now();
|
|
4342
3785
|
const isDouble = now - this.lastHandleClickAt < DOUBLE_CLICK_MS &&
|
|
4343
3786
|
this.lastHandleClickWorld !== null &&
|
|
4344
|
-
|
|
3787
|
+
vec2.distance(this.lastHandleClickWorld, world) <= DOUBLE_CLICK_TOLERANCE_PX;
|
|
4345
3788
|
this.lastHandleClickAt = now;
|
|
4346
3789
|
this.lastHandleClickWorld = world;
|
|
4347
3790
|
return isDouble;
|
|
@@ -4469,7 +3912,6 @@ export class Editor {
|
|
|
4469
3912
|
this.hoveredLinkTarget = { elementId: shape.id, activeAnchor: activeName, outlinePoint, mode };
|
|
4470
3913
|
this.notify();
|
|
4471
3914
|
}
|
|
4472
|
-
// Pure body in `./editor/applies/edge.ts`.
|
|
4473
3915
|
applyLinkPreview(fromElement, fromPoint, toPoint) {
|
|
4474
3916
|
const ep = computeLinkPreviewEndpoints(this._scene, fromElement, fromPoint, toPoint);
|
|
4475
3917
|
// Match the preview to the connector that will be committed: when new
|
|
@@ -4486,9 +3928,7 @@ export class Editor {
|
|
|
4486
3928
|
this.notify();
|
|
4487
3929
|
}
|
|
4488
3930
|
// Gesture lifecycle — recordGesturePatch / commitGesture /
|
|
4489
|
-
// cancelGesture / finalizeOpenGestureTx / maybeRevertModeAfterCreate
|
|
4490
|
-
// live in `./editor/gesture-tx.ts`. The thin instance methods below
|
|
4491
|
-
// preserve the original call sites.
|
|
3931
|
+
// cancelGesture / finalizeOpenGestureTx / maybeRevertModeAfterCreate.
|
|
4492
3932
|
recordGesturePatch(patch) {
|
|
4493
3933
|
// Snapshot the pre-gesture scene the moment the transaction opens, so a
|
|
4494
3934
|
// later cancel/Escape can restore it (the history tx only records undo data,
|
|
@@ -4500,6 +3940,7 @@ export class Editor {
|
|
|
4500
3940
|
}
|
|
4501
3941
|
commitGesture() {
|
|
4502
3942
|
this._resizeOriginElement = null;
|
|
3943
|
+
this.rotateGestureOrigin = null;
|
|
4503
3944
|
this.gestures.commit();
|
|
4504
3945
|
this.gestureStartScene = null;
|
|
4505
3946
|
}
|
|
@@ -4520,9 +3961,9 @@ export class Editor {
|
|
|
4520
3961
|
* - Cycles (a container inside its own descendant) are prevented by the
|
|
4521
3962
|
* `containerHover` pipeline above — the exclude set rules them out.
|
|
4522
3963
|
*/
|
|
4523
|
-
//
|
|
4524
|
-
//
|
|
4525
|
-
//
|
|
3964
|
+
// Editor exposes a small `ContainerOpsRef` bridge so the pure
|
|
3965
|
+
// helper can mutate scene + push patches into the running gesture
|
|
3966
|
+
// transaction.
|
|
4526
3967
|
applyContainerDrop(worldPoint) {
|
|
4527
3968
|
applyContainerDropPure(this.containerOpsRef, worldPoint);
|
|
4528
3969
|
}
|
|
@@ -4530,7 +3971,6 @@ export class Editor {
|
|
|
4530
3971
|
maybeGrowContainer(containerId, childId) {
|
|
4531
3972
|
maybeGrowContainerPure(this.containerOpsRef, containerId, childId);
|
|
4532
3973
|
}
|
|
4533
|
-
// Pure body in `./editor/container-ops.ts`.
|
|
4534
3974
|
clampContainerToChildren(shape, raw, handle) {
|
|
4535
3975
|
return clampContainerToChildrenPure(this._scene, shape, raw, handle);
|
|
4536
3976
|
}
|
|
@@ -4543,9 +3983,9 @@ export class Editor {
|
|
|
4543
3983
|
this.gestureTx ??= this._history.transaction();
|
|
4544
3984
|
return this.gestureTx;
|
|
4545
3985
|
}
|
|
4546
|
-
// Body moved to `./editor/gesture-tx.ts`.
|
|
4547
3986
|
cancelGesture() {
|
|
4548
3987
|
this._resizeOriginElement = null;
|
|
3988
|
+
this.rotateGestureOrigin = null;
|
|
4549
3989
|
this.gestures.cancel();
|
|
4550
3990
|
// Roll the scene back to the pre-gesture snapshot — cancelling the history
|
|
4551
3991
|
// transaction alone leaves the live drag mutations in `_scene`.
|
|
@@ -4637,9 +4077,8 @@ export class Editor {
|
|
|
4637
4077
|
/**
|
|
4638
4078
|
* Typed event surface — subscribe to a specific slice (`mode`,
|
|
4639
4079
|
* `selection`, `scene`, `history`, `viewport`) or the umbrella
|
|
4640
|
-
* `change`.
|
|
4641
|
-
*
|
|
4642
|
-
* `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.
|
|
4643
4082
|
*/
|
|
4644
4083
|
on(event, fn) {
|
|
4645
4084
|
// Cast through `never`: TS can't prove that EditorEvents[K]
|
|
@@ -4678,33 +4117,6 @@ export class Editor {
|
|
|
4678
4117
|
* between frames the A* route is reused — see `rerouteElbows`.
|
|
4679
4118
|
*/
|
|
4680
4119
|
elbowRouteSig = new Map();
|
|
4681
|
-
elbowSignature(edge) {
|
|
4682
|
-
const part = (ep) => {
|
|
4683
|
-
if (ep.kind === "point")
|
|
4684
|
-
return `p:${ep.position.x},${ep.position.y}`;
|
|
4685
|
-
const s = getElement(this._scene, ep.elementId);
|
|
4686
|
-
const b = s ? getElementWorldBounds(s) : null;
|
|
4687
|
-
const ref = ep.kind === "anchor"
|
|
4688
|
-
? JSON.stringify(ep.anchor)
|
|
4689
|
-
: ep.kind === "outline"
|
|
4690
|
-
? `o:${ep.ratio}`
|
|
4691
|
-
: "f";
|
|
4692
|
-
return `${ep.kind}:${ep.elementId}:${ref}:${b ? `${b.x},${b.y},${b.width},${b.height}` : "x"}`;
|
|
4693
|
-
};
|
|
4694
|
-
const base = `${part(edge.from)}|${part(edge.to)}|${JSON.stringify(edge.fixedSegments ?? null)}`;
|
|
4695
|
-
// Avoid-obstacles links depend on EVERY shape's geometry, so their route
|
|
4696
|
-
// must invalidate when any obstacle moves — fold a digest of all element
|
|
4697
|
-
// bboxes into the signature. Only paid by links that opt in.
|
|
4698
|
-
if (edge.avoidObstacles === true) {
|
|
4699
|
-
let digest = "|avoid:";
|
|
4700
|
-
for (const el of this._scene.elements.values()) {
|
|
4701
|
-
const bb = getElementWorldBounds(el);
|
|
4702
|
-
digest += `${el.id},${bb.x},${bb.y},${bb.width},${bb.height};`;
|
|
4703
|
-
}
|
|
4704
|
-
return base + digest;
|
|
4705
|
-
}
|
|
4706
|
-
return base;
|
|
4707
|
-
}
|
|
4708
4120
|
/**
|
|
4709
4121
|
* Choke-point reroute (standard model): recompute `routedPoints` for
|
|
4710
4122
|
* every orthogonal link whose inputs changed since the last pass, and
|
|
@@ -4717,7 +4129,7 @@ export class Editor {
|
|
|
4717
4129
|
for (const [id, edge] of this._scene.links) {
|
|
4718
4130
|
if ((edge.routing ?? "straight") !== "orthogonal")
|
|
4719
4131
|
continue;
|
|
4720
|
-
const sig = this.
|
|
4132
|
+
const sig = elbowSignature(this._scene, edge);
|
|
4721
4133
|
if (this.elbowRouteSig.get(id) === sig)
|
|
4722
4134
|
continue;
|
|
4723
4135
|
this.elbowRouteSig.set(id, sig);
|
|
@@ -4726,32 +4138,19 @@ export class Editor {
|
|
|
4726
4138
|
}
|
|
4727
4139
|
this._scene = next;
|
|
4728
4140
|
}
|
|
4729
|
-
// Pure body in `./editor/render-orchestrator.ts` (~130 lines).
|
|
4730
4141
|
render() {
|
|
4731
4142
|
this.rerouteElbows();
|
|
4732
4143
|
// Feed the renderer's animation clock our per-shape playback state
|
|
4733
4144
|
// so paused / reduced-motion GIFs freeze and resumed ones continue
|
|
4734
4145
|
// from the right frame. Set immediately before the synchronous
|
|
4735
4146
|
// render pass (the shape-renderer has no options channel).
|
|
4736
|
-
setAnimationClock((shape) => this.
|
|
4147
|
+
setAnimationClock((shape) => this.gifPlayback.clock(shape.id));
|
|
4737
4148
|
renderEditor(this);
|
|
4738
4149
|
// Present AFTER the paint, on the same tick — deferred-submission
|
|
4739
4150
|
// surfaces (WebGL2 / OffscreenCanvas) would otherwise lag one frame.
|
|
4740
4151
|
this.onAfterRender?.();
|
|
4741
4152
|
}
|
|
4742
4153
|
}
|
|
4743
|
-
/**
|
|
4744
|
-
* Throwaway id for the transient click-create ghost preview element built by
|
|
4745
|
-
* `previewClickCreate`. Never enters the scene / history — it lives only for
|
|
4746
|
-
* the duration of one overlay paint, so any stable constant is fine.
|
|
4747
|
-
*/
|
|
4748
|
-
const PREVIEW_GHOST_ELEMENT_ID = "__ghost-preview__";
|
|
4749
|
-
/** Throwaway link id for the click-create ghost preview. See above. */
|
|
4750
|
-
const PREVIEW_GHOST_LINK_ID = "__ghost-preview-link__";
|
|
4751
|
-
const distanceTo = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
4752
|
-
// `coverageRatio` moved to `./editor/container-ops.ts`.
|
|
4753
|
-
// `hasWidthHeight` moved to `./editor/shape-traits.ts` for shared
|
|
4754
|
-
// use by container-ops and the future applies/resize module.
|
|
4755
4154
|
/**
|
|
4756
4155
|
* Type guard — `true` when the value already implements the
|
|
4757
4156
|
* `HistoryProvider` surface. Used to decide between "host supplied
|
|
@@ -4767,37 +4166,5 @@ const isHistoryProvider = (value) => {
|
|
|
4767
4166
|
typeof value.redo === "function" &&
|
|
4768
4167
|
typeof value.transaction === "function");
|
|
4769
4168
|
};
|
|
4770
|
-
// `describeNudge` moved to `./editor/public/selection-ops.ts`.
|
|
4771
4169
|
/** Distance from point `p` to the finite segment `a`–`b` (world space). */
|
|
4772
|
-
function distanceToSegmentPt(p, a, b) {
|
|
4773
|
-
const dx = b.x - a.x;
|
|
4774
|
-
const dy = b.y - a.y;
|
|
4775
|
-
const lenSq = dx * dx + dy * dy;
|
|
4776
|
-
if (lenSq === 0)
|
|
4777
|
-
return Math.hypot(p.x - a.x, p.y - a.y);
|
|
4778
|
-
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
|
|
4779
|
-
t = Math.max(0, Math.min(1, t));
|
|
4780
|
-
return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t));
|
|
4781
|
-
}
|
|
4782
|
-
/**
|
|
4783
|
-
* Convert a snap candidate into an `LinkEndpoint`. Anchor snap → named
|
|
4784
|
-
* anchor ref; outline snap → outline ref with the sampled ratio. Falls
|
|
4785
|
-
* back to a free point if the metadata isn't recognised.
|
|
4786
|
-
*/
|
|
4787
|
-
const endpointFromSnap = (elementId, candidate, shape) => {
|
|
4788
|
-
if (candidate.kind === "anchor") {
|
|
4789
|
-
const ref = candidate.metadata?.ref;
|
|
4790
|
-
if (ref)
|
|
4791
|
-
return { kind: "anchor", elementId, anchor: ref };
|
|
4792
|
-
}
|
|
4793
|
-
if (candidate.kind === "outline" && typeof candidate.metadata?.ratio === "number") {
|
|
4794
|
-
return { kind: "outline", elementId, ratio: candidate.metadata.ratio };
|
|
4795
|
-
}
|
|
4796
|
-
// Defensive fallback — should not happen with built-in contributors.
|
|
4797
|
-
void shape;
|
|
4798
|
-
return { kind: "point", position: candidate.snapped };
|
|
4799
|
-
};
|
|
4800
|
-
// `resizeFromHandle`, `applyResizeConstraints`, the four handle-
|
|
4801
|
-
// quadrant predicates moved to `./editor/resize-helpers.ts` so
|
|
4802
|
-
// they're shared between applies/resize and the container clamp.
|
|
4803
4170
|
//# sourceMappingURL=editor.js.map
|