@oh-just-another/state 0.62.0 → 0.63.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 +77 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/actions/registry.js +1 -1
- package/dist/actions/registry.js.map +1 -1
- package/dist/constants.d.ts +162 -8
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +132 -8
- package/dist/constants.js.map +1 -1
- package/dist/editor/animation-scene.d.ts.map +1 -1
- package/dist/editor/animation-scene.js +27 -39
- package/dist/editor/animation-scene.js.map +1 -1
- package/dist/editor/animation.js +1 -1
- package/dist/editor/animation.js.map +1 -1
- package/dist/editor/applies/arrange-units.d.ts +24 -0
- package/dist/editor/applies/arrange-units.d.ts.map +1 -0
- package/dist/editor/applies/arrange-units.js +56 -0
- package/dist/editor/applies/arrange-units.js.map +1 -0
- package/dist/editor/applies/arrange.d.ts +16 -15
- package/dist/editor/applies/arrange.d.ts.map +1 -1
- package/dist/editor/applies/arrange.js +33 -48
- package/dist/editor/applies/arrange.js.map +1 -1
- package/dist/editor/applies/create.d.ts +16 -10
- package/dist/editor/applies/create.d.ts.map +1 -1
- package/dist/editor/applies/create.js +32 -12
- package/dist/editor/applies/create.js.map +1 -1
- package/dist/editor/applies/edge.d.ts +1 -1
- package/dist/editor/applies/edge.d.ts.map +1 -1
- package/dist/editor/applies/object-snap.d.ts +87 -0
- package/dist/editor/applies/object-snap.d.ts.map +1 -0
- package/dist/editor/applies/object-snap.js +198 -0
- package/dist/editor/applies/object-snap.js.map +1 -0
- package/dist/editor/applies/resize.d.ts +1 -1
- package/dist/editor/applies/resize.d.ts.map +1 -1
- package/dist/editor/applies/resize.js +6 -4
- package/dist/editor/applies/resize.js.map +1 -1
- package/dist/editor/applies/selection.d.ts +5 -5
- package/dist/editor/applies/selection.d.ts.map +1 -1
- package/dist/editor/applies/selection.js +15 -7
- package/dist/editor/applies/selection.js.map +1 -1
- package/dist/editor/applies/snap-grid.d.ts +1 -1
- package/dist/editor/applies/snap-grid.d.ts.map +1 -1
- package/dist/editor/container-ops.d.ts +1 -1
- package/dist/editor/container-ops.d.ts.map +1 -1
- package/dist/{editor-events.d.ts → editor/editor-events.d.ts} +2 -2
- package/dist/editor/editor-events.d.ts.map +1 -0
- package/dist/{editor-events.js.map → editor/editor-events.js.map} +1 -1
- package/dist/editor/event-fanout.d.ts +3 -3
- package/dist/editor/event-fanout.d.ts.map +1 -1
- package/dist/editor/gesture-tx.d.ts +1 -1
- package/dist/editor/gesture-tx.d.ts.map +1 -1
- package/dist/editor/hit-test.d.ts +5 -3
- package/dist/editor/hit-test.d.ts.map +1 -1
- package/dist/editor/hit-test.js +18 -10
- package/dist/editor/hit-test.js.map +1 -1
- package/dist/editor/interaction-state.d.ts +29 -2
- package/dist/editor/interaction-state.d.ts.map +1 -1
- package/dist/editor/interaction-state.js +34 -0
- package/dist/editor/interaction-state.js.map +1 -1
- package/dist/editor/link-handle-drag.d.ts +1 -1
- package/dist/editor/link-handle-drag.d.ts.map +1 -1
- package/dist/editor/link-handle-drag.js +1 -1
- package/dist/editor/link-handle-drag.js.map +1 -1
- package/dist/editor/pointer-binding.d.ts.map +1 -1
- package/dist/editor/pointer-binding.js +86 -24
- package/dist/editor/pointer-binding.js.map +1 -1
- package/dist/editor/public/arrange-group.d.ts +6 -5
- package/dist/editor/public/arrange-group.d.ts.map +1 -1
- package/dist/editor/public/arrange-group.js +39 -23
- package/dist/editor/public/arrange-group.js.map +1 -1
- package/dist/editor/public/clipboard.d.ts +1 -1
- package/dist/editor/public/clipboard.d.ts.map +1 -1
- package/dist/editor/public/clipboard.js +2 -2
- package/dist/editor/public/clipboard.js.map +1 -1
- package/dist/editor/public/cursor.js +1 -1
- package/dist/editor/public/cursor.js.map +1 -1
- package/dist/editor/public/eraser.js +1 -1
- package/dist/editor/public/eraser.js.map +1 -1
- package/dist/editor/public/image-insert.d.ts.map +1 -1
- package/dist/editor/public/image-insert.js +4 -1
- package/dist/editor/public/image-insert.js.map +1 -1
- package/dist/editor/public/label-seed.d.ts +12 -0
- package/dist/editor/public/label-seed.d.ts.map +1 -0
- package/dist/editor/public/label-seed.js +20 -0
- package/dist/editor/public/label-seed.js.map +1 -0
- package/dist/editor/public/layers.d.ts +1 -1
- package/dist/editor/public/layers.d.ts.map +1 -1
- package/dist/editor/public/placement.d.ts +6 -1
- package/dist/editor/public/placement.d.ts.map +1 -1
- package/dist/editor/public/placement.js +47 -17
- package/dist/editor/public/placement.js.map +1 -1
- package/dist/editor/public/selection-ops.d.ts +1 -1
- package/dist/editor/public/selection-ops.d.ts.map +1 -1
- package/dist/editor/public/selection-ops.js +31 -2
- package/dist/editor/public/selection-ops.js.map +1 -1
- package/dist/editor/public/stroke-eraser-coverage.js +1 -1
- package/dist/editor/public/stroke-eraser-coverage.js.map +1 -1
- package/dist/editor/public/stroke-eraser.js +1 -1
- package/dist/editor/public/stroke-eraser.js.map +1 -1
- package/dist/editor/public/text-edit.d.ts +4 -2
- package/dist/editor/public/text-edit.d.ts.map +1 -1
- package/dist/editor/public/text-edit.js +8 -4
- package/dist/editor/public/text-edit.js.map +1 -1
- package/dist/editor/public/tool-ops.d.ts +10 -7
- package/dist/editor/public/tool-ops.d.ts.map +1 -1
- package/dist/editor/public/tool-ops.js +141 -14
- package/dist/editor/public/tool-ops.js.map +1 -1
- package/dist/editor/public/zoom-pan.d.ts +10 -1
- package/dist/editor/public/zoom-pan.d.ts.map +1 -1
- package/dist/editor/public/zoom-pan.js +49 -0
- package/dist/editor/public/zoom-pan.js.map +1 -1
- package/dist/editor/render-orchestrator.d.ts +17 -11
- package/dist/editor/render-orchestrator.d.ts.map +1 -1
- package/dist/editor/render-orchestrator.js +45 -31
- package/dist/editor/render-orchestrator.js.map +1 -1
- package/dist/editor/resize-helpers.d.ts +1 -1
- package/dist/editor/resize-helpers.d.ts.map +1 -1
- package/dist/editor/shape-traits.d.ts +1 -1
- package/dist/editor/shape-traits.d.ts.map +1 -1
- package/dist/editor/shape-traits.js +1 -1
- package/dist/editor/shape-traits.js.map +1 -1
- package/dist/editor/text-edit.d.ts +24 -11
- package/dist/editor/text-edit.d.ts.map +1 -1
- package/dist/editor/text-edit.js +225 -45
- package/dist/editor/text-edit.js.map +1 -1
- package/dist/editor.d.ts +306 -18
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +1233 -67
- package/dist/editor.js.map +1 -1
- package/dist/features/built-in-handlers.d.ts.map +1 -0
- package/dist/{built-in-handlers.js → features/built-in-handlers.js} +7 -1
- package/dist/features/built-in-handlers.js.map +1 -0
- package/dist/{clipboard.d.ts → features/clipboard.d.ts} +14 -0
- package/dist/features/clipboard.d.ts.map +1 -0
- package/dist/{clipboard.js → features/clipboard.js} +1 -1
- package/dist/features/clipboard.js.map +1 -0
- package/dist/{file-drop.d.ts → features/file-drop.d.ts} +20 -1
- package/dist/features/file-drop.d.ts.map +1 -0
- package/dist/{file-drop.js → features/file-drop.js} +47 -19
- package/dist/features/file-drop.js.map +1 -0
- package/dist/features/insert-scene.d.ts +19 -0
- package/dist/features/insert-scene.d.ts.map +1 -0
- package/dist/features/insert-scene.js +145 -0
- package/dist/features/insert-scene.js.map +1 -0
- package/dist/features/search.d.ts.map +1 -0
- package/dist/features/search.js.map +1 -0
- package/dist/helpers/auto-compact.d.ts.map +1 -0
- package/dist/{auto-compact.js → helpers/auto-compact.js} +1 -1
- package/dist/helpers/auto-compact.js.map +1 -0
- package/dist/helpers/auto-layout-scheduler.d.ts.map +1 -0
- package/dist/helpers/auto-layout-scheduler.js.map +1 -0
- package/dist/helpers/frame-helpers.d.ts.map +1 -0
- package/dist/helpers/frame-helpers.js.map +1 -0
- package/dist/helpers/group-helpers.d.ts.map +1 -0
- package/dist/helpers/group-helpers.js.map +1 -0
- package/dist/helpers/util.d.ts.map +1 -0
- package/dist/helpers/util.js.map +1 -0
- package/dist/index.d.ts +23 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -12
- package/dist/index.js.map +1 -1
- package/dist/input/dom-events.d.ts.map +1 -0
- package/dist/input/dom-events.js.map +1 -0
- package/dist/{hotkeys-binding.d.ts → input/hotkeys-binding.d.ts} +2 -2
- package/dist/input/hotkeys-binding.d.ts.map +1 -0
- package/dist/{hotkeys-binding.js → input/hotkeys-binding.js} +1 -1
- package/dist/input/hotkeys-binding.js.map +1 -0
- package/dist/input/platform.d.ts.map +1 -0
- package/dist/input/platform.js.map +1 -0
- package/dist/{handle.d.ts → interaction/handle.d.ts} +1 -1
- package/dist/interaction/handle.d.ts.map +1 -0
- package/dist/{handle.js → interaction/handle.js} +1 -1
- package/dist/interaction/handle.js.map +1 -0
- package/dist/interaction/interactive.d.ts.map +1 -0
- package/dist/interaction/interactive.js.map +1 -0
- package/dist/interaction/machine.d.ts.map +1 -0
- package/dist/interaction/machine.js.map +1 -0
- package/dist/interaction/modes.d.ts.map +1 -0
- package/dist/interaction/modes.js.map +1 -0
- package/dist/render/animation-tick.d.ts.map +1 -0
- package/dist/render/animation-tick.js.map +1 -0
- package/dist/{overlay.d.ts → render/overlay.d.ts} +42 -38
- package/dist/render/overlay.d.ts.map +1 -0
- package/dist/{overlay.js → render/overlay.js} +174 -59
- package/dist/render/overlay.js.map +1 -0
- package/dist/selection/link-selection.d.ts.map +1 -0
- package/dist/{link-selection.js → selection/link-selection.js} +1 -1
- package/dist/selection/link-selection.js.map +1 -0
- package/dist/selection/selection.d.ts.map +1 -0
- package/dist/{selection.js → selection/selection.js} +1 -1
- package/dist/selection/selection.js.map +1 -0
- package/package.json +5 -5
- package/dist/animation-tick.d.ts.map +0 -1
- package/dist/animation-tick.js.map +0 -1
- package/dist/auto-compact.d.ts.map +0 -1
- package/dist/auto-compact.js.map +0 -1
- package/dist/auto-layout-scheduler.d.ts.map +0 -1
- package/dist/auto-layout-scheduler.js.map +0 -1
- package/dist/built-in-handlers.d.ts.map +0 -1
- package/dist/built-in-handlers.js.map +0 -1
- package/dist/clipboard.d.ts.map +0 -1
- package/dist/clipboard.js.map +0 -1
- package/dist/dom-events.d.ts.map +0 -1
- package/dist/dom-events.js.map +0 -1
- package/dist/editor-events.d.ts.map +0 -1
- package/dist/file-drop.d.ts.map +0 -1
- package/dist/file-drop.js.map +0 -1
- package/dist/frame-helpers.d.ts.map +0 -1
- package/dist/frame-helpers.js.map +0 -1
- package/dist/group-helpers.d.ts.map +0 -1
- package/dist/group-helpers.js.map +0 -1
- package/dist/handle.d.ts.map +0 -1
- package/dist/handle.js.map +0 -1
- package/dist/hotkeys-binding.d.ts.map +0 -1
- package/dist/hotkeys-binding.js.map +0 -1
- package/dist/interactive.d.ts.map +0 -1
- package/dist/interactive.js.map +0 -1
- package/dist/link-selection.d.ts.map +0 -1
- package/dist/link-selection.js.map +0 -1
- package/dist/machine.d.ts.map +0 -1
- package/dist/machine.js.map +0 -1
- package/dist/modes.d.ts.map +0 -1
- package/dist/modes.js.map +0 -1
- package/dist/overlay.d.ts.map +0 -1
- package/dist/overlay.js.map +0 -1
- package/dist/platform.d.ts.map +0 -1
- package/dist/platform.js.map +0 -1
- package/dist/search.d.ts.map +0 -1
- package/dist/search.js.map +0 -1
- package/dist/selection.d.ts.map +0 -1
- package/dist/selection.js.map +0 -1
- package/dist/util.d.ts.map +0 -1
- package/dist/util.js.map +0 -1
- /package/dist/{editor-events.js → editor/editor-events.js} +0 -0
- /package/dist/{built-in-handlers.d.ts → features/built-in-handlers.d.ts} +0 -0
- /package/dist/{search.d.ts → features/search.d.ts} +0 -0
- /package/dist/{search.js → features/search.js} +0 -0
- /package/dist/{auto-compact.d.ts → helpers/auto-compact.d.ts} +0 -0
- /package/dist/{auto-layout-scheduler.d.ts → helpers/auto-layout-scheduler.d.ts} +0 -0
- /package/dist/{auto-layout-scheduler.js → helpers/auto-layout-scheduler.js} +0 -0
- /package/dist/{frame-helpers.d.ts → helpers/frame-helpers.d.ts} +0 -0
- /package/dist/{frame-helpers.js → helpers/frame-helpers.js} +0 -0
- /package/dist/{group-helpers.d.ts → helpers/group-helpers.d.ts} +0 -0
- /package/dist/{group-helpers.js → helpers/group-helpers.js} +0 -0
- /package/dist/{util.d.ts → helpers/util.d.ts} +0 -0
- /package/dist/{util.js → helpers/util.js} +0 -0
- /package/dist/{dom-events.d.ts → input/dom-events.d.ts} +0 -0
- /package/dist/{dom-events.js → input/dom-events.js} +0 -0
- /package/dist/{platform.d.ts → input/platform.d.ts} +0 -0
- /package/dist/{platform.js → input/platform.js} +0 -0
- /package/dist/{interactive.d.ts → interaction/interactive.d.ts} +0 -0
- /package/dist/{interactive.js → interaction/interactive.js} +0 -0
- /package/dist/{machine.d.ts → interaction/machine.d.ts} +0 -0
- /package/dist/{machine.js → interaction/machine.js} +0 -0
- /package/dist/{modes.d.ts → interaction/modes.d.ts} +0 -0
- /package/dist/{modes.js → interaction/modes.js} +0 -0
- /package/dist/{animation-tick.d.ts → render/animation-tick.d.ts} +0 -0
- /package/dist/{animation-tick.js → render/animation-tick.js} +0 -0
- /package/dist/{link-selection.d.ts → selection/link-selection.d.ts} +0 -0
- /package/dist/{selection.d.ts → selection/selection.d.ts} +0 -0
package/dist/editor.js
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
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, getLinkCurvePoints, linkLabelAnchor, addLink, endpointElementId, anchorSnapper, apply, buildSpatialIndex, isElementHidden, isElementLocked, runAutoLayout, DEFAULT_LAYER_ID, routeElbowLink, routeElbowPreview, getLink, getLinkPath, getElement, getElementAt, getElementAtIndexed, getElementLocalBounds, localToWorld, worldToLocal, isFrame, isGroup, isText, isImage, isBrush, brushBodyColor, getElementWorldBounds, setTextMeasurer, getScreenToWorld, gridSnapper, outlineSnapper, SnapEngine, invert, updateLink, updateElement, isAnchorRef, isSnapToGridEnabled, resolveSnapSpacing, } from "@oh-just-another/scene";
|
|
5
|
-
import { layerId as castLayerId, } from "@oh-just-another/types";
|
|
6
|
-
import { bounds as B, matrix
|
|
4
|
+
import { addElement, getLinkCurvePoints, linkLabelAnchor, addLink, endpointElementId, anchorSnapper, apply, buildSpatialIndex, isElementHidden, isElementLocked, runAutoLayout, DEFAULT_LAYER_ID, routeElbowLink, routeElbowPreview, getLink, getLinkPath, getElement, getElementAt, getElementAtIndexed, getElementLocalBounds, localToWorld, worldToLocal, isFrame, isGroup, isText, isImage, isBrush, brushBodyColor, canCarryLabel, isSticky, isEmoji, getBinaryFile, paragraphAt, paragraphCount, paragraphRangeForOffsets, normalizeParagraphs, getElementWorldBounds, setTextMeasurer, getScreenToWorld, gridSnapper, outlineSnapper, SnapEngine, invert, updateLink, updateElement, isAnchorRef, isSnapToGridEnabled, resolveSnapSpacing, } from "@oh-just-another/scene";
|
|
5
|
+
import { fileId as castFileId, layerId as castLayerId, linkId as castLinkId, } from "@oh-just-another/types";
|
|
6
|
+
import { bounds as B, matrix } from "@oh-just-another/math";
|
|
7
7
|
import { onAnimationContentReady, setActiveRasterizer, setActiveTextShaper, ElementCache, } from "@oh-just-another/renderer-core";
|
|
8
8
|
import { History, } from "@oh-just-another/history";
|
|
9
9
|
import { DEFAULT_LINK_ROUTING } from "./constants.js";
|
|
10
|
-
import { FileDropRegistry } from "./file-drop.js";
|
|
11
|
-
import { imageFileDropHandler, videoFileDropHandler } from "./built-in-handlers.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
10
|
+
import { FileDropRegistry, } from "./features/file-drop.js";
|
|
11
|
+
import { imageFileDropHandler, videoFileDropHandler, createHiddenLoopingVideo, } from "./features/built-in-handlers.js";
|
|
12
|
+
import { insertSceneFragment } from "./features/insert-scene.js";
|
|
13
|
+
import { computeDimElements as computeDimElementsHelper, isDescendantOfGroup as isDescendantOfGroupHelper, pickDrillTarget as pickDrillTargetHelper, promoteToGroupRoot as promoteToGroupRootHelper, topGroupAncestor as topGroupAncestorHelper, } from "./helpers/group-helpers.js";
|
|
14
|
+
import { assignFrameMembers as assignFrameMembersHelper, nextFrameName as nextFrameNameHelper, reconcileFrameMembership as reconcileFrameMembershipHelper, } from "./helpers/frame-helpers.js";
|
|
15
|
+
import { AutoCompactScheduler } from "./helpers/auto-compact.js";
|
|
16
|
+
import { AutoLayoutScheduler } from "./helpers/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, TOUCH_ANCHOR_START_HIT_SLOP, TOUCH_ANCHOR_DOT_CLICK_RADIUS, WHEEL_ZOOM_STEP, ROTATE_SNAP_RADIANS, CROP_HANDLE_HIT_RADIUS, FLOWCHART_MAX_SIBLINGS, ERASER_TRAIL_TTL_MS, MAX_LIST_INDENT, IMAGE_ASPECT_PRESETS, LINK_DRAW_PRESETS, OBJECT_SNAP_MAX_CANDIDATES, OBJECT_SNAP_MIN_SIZE_PX, OBJECT_SNAP_THRESHOLD_PX, SIZE_SUGGEST_THRESHOLD_PX, DEFAULT_EDITOR_PREFERENCES, } from "./constants.js";
|
|
18
|
+
import { HANDLE_HIT_SLOP } from "./interaction/handle.js";
|
|
19
|
+
import { req } from "./helpers/util.js";
|
|
20
|
+
import { interactionMachine, } from "./interaction/machine.js";
|
|
21
|
+
import { DEFAULT_MODE } from "./interaction/modes.js";
|
|
21
22
|
import { createEventCache, fanOutEvents, primeEventCache, } from "./editor/event-fanout.js";
|
|
22
23
|
import { AnimationController } from "./editor/animation.js";
|
|
23
24
|
import { GestureController } from "./editor/gesture-tx.js";
|
|
@@ -38,7 +39,7 @@ import { coveredLength } from "./editor/public/stroke-eraser-coverage.js";
|
|
|
38
39
|
import { beginLaserStroke as beginLaserStrokePure, extendLaserStroke as extendLaserStrokePure, pruneLaserStrokes, } from "./editor/public/laser.js";
|
|
39
40
|
import { copySelected as copySelectedPure, pasteFromClipboard, selectionFromPasted, } from "./editor/public/clipboard.js";
|
|
40
41
|
import { computeCreateLayer, computeMoveSelectionToLayer, computeRemoveLayer, computeRenameLayer, computeToggleLayerLock, computeToggleLayerVisibility, newLayerId, } from "./editor/public/layers.js";
|
|
41
|
-
import { computePan, computeResetZoom, computeSetGrid, computeViewportResize, computeZoomAt, computeZoomToFit, computeZoomToBounds, computeRevealBounds, } from "./editor/public/zoom-pan.js";
|
|
42
|
+
import { computePan, clampZoom, computeResetZoom, computeSetGrid, computeViewportResize, computeZoomAt, computeZoomToFit, computeZoomToBounds, computeRevealBounds, computeRevealNearest, } from "./editor/public/zoom-pan.js";
|
|
42
43
|
import { computeAddAnnotation, computeAddComment, computeRemoveAnnotation, computeRemoveComment, computeToggleAnnotationResolved, hitAnnotation as hitAnnotationPure, } from "./editor/public/annotations.js";
|
|
43
44
|
import { frameHeaderAt as computeFrameHeaderAt, computeFrameNameCommit, } from "./editor/public/frame-name.js";
|
|
44
45
|
import { computeCursor } from "./editor/public/cursor.js";
|
|
@@ -47,7 +48,8 @@ import { computeArrangeAsGrid, computeArrangeAsStack, computeGroupSelected, comp
|
|
|
47
48
|
import { buildImageElement, computeAddBinaryFile, hasAnimatedElement, } from "./editor/public/image-insert.js";
|
|
48
49
|
import { computeDeleteSelection, computeDuplicateSelection, computeMoveSelectionBy, computeSelectAll, computeSelectAllLinks, computeAdjustFontSize, computeApplyTextRunStyle, computeSetSelection, computeUpdateStyle, computeUpdateTextProps, describeNudge as describeNudgePure, findClosestInDirection, selectionFromNewIds, } from "./editor/public/selection-ops.js";
|
|
49
50
|
import { computeSetLink, normalizeHref, safeHref, snapLinkEndpoint as snapLinkEndpointPure, } from "./editor/public/link.js";
|
|
50
|
-
import { beginPlacementState, buildElementAtCursor, buildTextElementAt, computePlacementCancel, computePlacementContainerDrop, computePlacementUpdate, computeLinkedElementFromAnchor, computeDuplicateInPlace, computeShapeAtLinkDrop, newElementIdAtCursor, previewClickCreate as previewClickCreatePure, } from "./editor/public/placement.js";
|
|
51
|
+
import { beginPlacementState, buildElementAtCursor, buildStickyElementAt, buildTextElementAt, computePlacementCancel, computePlacementContainerDrop, computePlacementUpdate, computeLinkedElementFromAnchor, computeDuplicateInPlace, computeShapeAtLinkDrop, newElementIdAtCursor, previewClickCreate as previewClickCreatePure, } from "./editor/public/placement.js";
|
|
52
|
+
import { withLabel } from "./editor/public/label-seed.js";
|
|
51
53
|
import { computeConvertType, computeCommitImageCrop, computeCropBodyPan, computeCropHandleDrag, computeSpawnConnectedNode, computeSpawnConnectedNodes, cropFullImageLocalRect, cropHandleWorldPoints, CROP_HANDLES, FULL_CROP, pickColorAt, } from "./editor/public/tool-ops.js";
|
|
52
54
|
import { renderEditor, } from "./editor/render-orchestrator.js";
|
|
53
55
|
import { TextEditController } from "./editor/text-edit.js";
|
|
@@ -61,9 +63,10 @@ import { computeAlignPatches, computeDistributePatches, computeFlipPatches, comp
|
|
|
61
63
|
import { computeMovingLinkPatches, computeMovingLinkForNudge } from "./editor/applies/link-move.js";
|
|
62
64
|
import { computeCreateLink, computeCreateElement, newLinkId, newElementId, } from "./editor/applies/create.js";
|
|
63
65
|
import { snapCreateBounds, snapGroupDelta, snapMoveDelta, snapResizeDelta, } from "./editor/applies/snap-grid.js";
|
|
64
|
-
import {} from "./
|
|
65
|
-
import
|
|
66
|
-
import * as
|
|
66
|
+
import { snapMoveDeltaToObjects, snapResizeDeltaToObjects, } from "./editor/applies/object-snap.js";
|
|
67
|
+
import {} from "./render/overlay.js";
|
|
68
|
+
import * as Selection from "./selection/selection.js";
|
|
69
|
+
import * as LinkSelection from "./selection/link-selection.js";
|
|
67
70
|
/**
|
|
68
71
|
* Machine-emit types dropped while the editor is in read-only mode — every
|
|
69
72
|
* scene mutation the interaction machine can produce. Selection / lasso /
|
|
@@ -86,6 +89,24 @@ const READ_ONLY_BLOCKED_EMITS = new Set([
|
|
|
86
89
|
const EMPTY_ELEMENT_SET = Object.freeze(new Set());
|
|
87
90
|
/** Monotonic wall-clock in ms, matching the domain used by the overlay fade. */
|
|
88
91
|
const nowMs = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
92
|
+
/** `rotation` is a multiple of 90° (within float noise) — the only poses that snap. */
|
|
93
|
+
const isRightAngle = (rotation) => {
|
|
94
|
+
const q = rotation / (Math.PI / 2);
|
|
95
|
+
return Math.abs(q - Math.round(q)) < 1e-6;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Camera at the scene's saved start view (pan + zoom), or the scene itself
|
|
99
|
+
* when no start view is set or the camera is already there.
|
|
100
|
+
*/
|
|
101
|
+
const withStartViewApplied = (scene) => {
|
|
102
|
+
const sv = scene.viewport.startView;
|
|
103
|
+
if (sv === undefined)
|
|
104
|
+
return scene;
|
|
105
|
+
const { pan, zoom } = scene.viewport;
|
|
106
|
+
if (pan.x === sv.pan.x && pan.y === sv.pan.y && zoom === sv.zoom)
|
|
107
|
+
return scene;
|
|
108
|
+
return { ...scene, viewport: { ...scene.viewport, pan: { ...sv.pan }, zoom: sv.zoom } };
|
|
109
|
+
};
|
|
89
110
|
export class Editor {
|
|
90
111
|
host;
|
|
91
112
|
mainTarget;
|
|
@@ -397,6 +418,14 @@ export class Editor {
|
|
|
397
418
|
* this lets that active→inactive transition force one full repaint.
|
|
398
419
|
*/
|
|
399
420
|
lastRenderedEraseActive = false;
|
|
421
|
+
/**
|
|
422
|
+
* Last-painted hovered sticky — paired with `lastRenderedScene` like
|
|
423
|
+
* {@link lastRenderedEnteredGroup}. Hovering changes only chrome (the
|
|
424
|
+
* "+" add-reaction button), not the scene, so the dirty-rect diff is
|
|
425
|
+
* empty and the button would never appear / disappear without this
|
|
426
|
+
* forcing a full repaint on hover transitions.
|
|
427
|
+
*/
|
|
428
|
+
lastRenderedHoveredSticky = null;
|
|
400
429
|
/**
|
|
401
430
|
* Set whenever an eraser move actually CHANGES the marked / cut set (a new
|
|
402
431
|
* shape marked, un-marked, or a brush point cut). Gates the forced full
|
|
@@ -593,6 +622,13 @@ export class Editor {
|
|
|
593
622
|
set panGesture(v) {
|
|
594
623
|
this.interaction.panGesture = v;
|
|
595
624
|
}
|
|
625
|
+
/** Element being placed by a library drag, or null (see `beginPlacement`). */
|
|
626
|
+
get placementId() {
|
|
627
|
+
return this.interaction.placementId;
|
|
628
|
+
}
|
|
629
|
+
set placementId(v) {
|
|
630
|
+
this.interaction.placementId = v;
|
|
631
|
+
}
|
|
596
632
|
/**
|
|
597
633
|
* Set on a right-click pointerdown so the upcoming native
|
|
598
634
|
* `contextmenu` event can be unconditionally preventDefault'ed
|
|
@@ -670,7 +706,8 @@ export class Editor {
|
|
|
670
706
|
this.overlayTarget = options.overlayTarget;
|
|
671
707
|
this.backgroundTarget = options.backgroundTarget ?? null;
|
|
672
708
|
this.onAfterRender = options.onAfterRender ?? null;
|
|
673
|
-
this.
|
|
709
|
+
this._preferences = { ...DEFAULT_EDITOR_PREFERENCES, ...options.preferences };
|
|
710
|
+
this._scene = withStartViewApplied(options.initialScene);
|
|
674
711
|
this._history = isHistoryProvider(options.history)
|
|
675
712
|
? options.history
|
|
676
713
|
: new History(options.history ?? {});
|
|
@@ -842,8 +879,7 @@ export class Editor {
|
|
|
842
879
|
// Long-press controller — fired on touch-hold; fans out to
|
|
843
880
|
// host-registered listeners (mobile alt to right-click).
|
|
844
881
|
this.longPress = new LongPressController((p) => this.screenToWorld(p), (payload) => {
|
|
845
|
-
|
|
846
|
-
fn(payload);
|
|
882
|
+
this.fireContextPress(payload);
|
|
847
883
|
});
|
|
848
884
|
// Pinch gesture controller — two-finger pan + zoom. Hooks into
|
|
849
885
|
// the editor's own zoomAt / panBy / screenToWorld.
|
|
@@ -1230,6 +1266,88 @@ export class Editor {
|
|
|
1230
1266
|
toggleGrid() {
|
|
1231
1267
|
this.setGrid({ enabled: !this.gridEnabled });
|
|
1232
1268
|
}
|
|
1269
|
+
_preferences;
|
|
1270
|
+
/** Per-user preferences — see {@link EditorPreferences}. */
|
|
1271
|
+
get preferences() {
|
|
1272
|
+
return this._preferences;
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Merge `patch` over the current preferences. Not part of the document
|
|
1276
|
+
* and never recorded in history; hosts persist the result per browser.
|
|
1277
|
+
* Subscribers are notified (the object identity changes only when a
|
|
1278
|
+
* value actually changed).
|
|
1279
|
+
*/
|
|
1280
|
+
setPreferences(patch) {
|
|
1281
|
+
const next = { ...this._preferences, ...patch };
|
|
1282
|
+
if (next.snapObjects === this._preferences.snapObjects &&
|
|
1283
|
+
next.showObjectSize === this._preferences.showObjectSize &&
|
|
1284
|
+
next.suggestObjectSize === this._preferences.suggestObjectSize &&
|
|
1285
|
+
next.wheelMode === this._preferences.wheelMode) {
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
this._preferences = next;
|
|
1289
|
+
this.notify();
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Clear the `locked` flag on every locked element (one undo step).
|
|
1293
|
+
* The canvas menu's "Unlock all"; no-op when nothing is locked.
|
|
1294
|
+
*/
|
|
1295
|
+
unlockAll() {
|
|
1296
|
+
if (this.readOnly)
|
|
1297
|
+
return;
|
|
1298
|
+
const ids = [...this._scene.elements.values()]
|
|
1299
|
+
.filter((s) => s.locked === true)
|
|
1300
|
+
.map((s) => s.id);
|
|
1301
|
+
if (ids.length === 0)
|
|
1302
|
+
return;
|
|
1303
|
+
const tx = this._history.transaction();
|
|
1304
|
+
for (const id of ids) {
|
|
1305
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
1306
|
+
const copy = { ...s };
|
|
1307
|
+
delete copy.locked;
|
|
1308
|
+
return copy;
|
|
1309
|
+
});
|
|
1310
|
+
this._scene = r.scene;
|
|
1311
|
+
tx.add(r.patch);
|
|
1312
|
+
}
|
|
1313
|
+
tx.commit();
|
|
1314
|
+
this.notify();
|
|
1315
|
+
this.announce(`Unlocked ${String(ids.length)} element(s)`);
|
|
1316
|
+
}
|
|
1317
|
+
/** The document's saved start view, or `null` when none is set. */
|
|
1318
|
+
get startView() {
|
|
1319
|
+
return this._scene.viewport.startView ?? null;
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Save the current camera (pan + zoom) as the document's start view —
|
|
1323
|
+
* where the document opens and where {@link goToStartView} jumps.
|
|
1324
|
+
* Persists in the viewport (exported with the scene), not in history.
|
|
1325
|
+
*/
|
|
1326
|
+
setCurrentViewAsStart() {
|
|
1327
|
+
const { pan, zoom } = this._scene.viewport;
|
|
1328
|
+
this._scene = {
|
|
1329
|
+
...this._scene,
|
|
1330
|
+
viewport: { ...this._scene.viewport, startView: { pan: { ...pan }, zoom } },
|
|
1331
|
+
};
|
|
1332
|
+
this.notify();
|
|
1333
|
+
}
|
|
1334
|
+
/** Forget the saved start view. No-op when none is set. */
|
|
1335
|
+
clearStartView() {
|
|
1336
|
+
if (this._scene.viewport.startView === undefined)
|
|
1337
|
+
return;
|
|
1338
|
+
const { startView: _dropped, ...viewport } = this._scene.viewport;
|
|
1339
|
+
void _dropped;
|
|
1340
|
+
this._scene = { ...this._scene, viewport };
|
|
1341
|
+
this.notify();
|
|
1342
|
+
}
|
|
1343
|
+
/** Move the camera to the saved start view. No-op when none is set. */
|
|
1344
|
+
goToStartView() {
|
|
1345
|
+
const next = withStartViewApplied(this._scene);
|
|
1346
|
+
if (next === this._scene)
|
|
1347
|
+
return;
|
|
1348
|
+
this._scene = next;
|
|
1349
|
+
this.notify();
|
|
1350
|
+
}
|
|
1233
1351
|
/** All currently-selected link (connector) ids. */
|
|
1234
1352
|
get selectedLinks() {
|
|
1235
1353
|
return this._selectedLinks;
|
|
@@ -1275,6 +1393,10 @@ export class Editor {
|
|
|
1275
1393
|
unregisterFileDropHandler(id) {
|
|
1276
1394
|
this.fileDropRegistry.unregister(id);
|
|
1277
1395
|
}
|
|
1396
|
+
/** Registered file-drop handlers, in dispatch order — the drop overlay lists the labelled ones. */
|
|
1397
|
+
getFileDropHandlers() {
|
|
1398
|
+
return this.fileDropRegistry.getAll();
|
|
1399
|
+
}
|
|
1278
1400
|
/**
|
|
1279
1401
|
* Dispatch a dropped file (or pasted file from clipboard) into
|
|
1280
1402
|
* the registered handlers. Returns `true` when a handler accepted
|
|
@@ -1307,10 +1429,46 @@ export class Editor {
|
|
|
1307
1429
|
* toolbar buttons and hotkeys reach it through the action registry.
|
|
1308
1430
|
* Records the outgoing tool in `activeTool.lastActiveTool`.
|
|
1309
1431
|
*/
|
|
1432
|
+
/**
|
|
1433
|
+
* Shape kind the next rubber-band draw creates while `draw-rect` is
|
|
1434
|
+
* armed (the "Shapes and lines" flyout arms diamond / triangle through
|
|
1435
|
+
* the same mode). Reset to "rect" on every tool switch so hotkeys and
|
|
1436
|
+
* plain toolbar buttons keep their stock behaviour.
|
|
1437
|
+
*/
|
|
1438
|
+
_drawShapeKind = "rect";
|
|
1439
|
+
get drawShapeKind() {
|
|
1440
|
+
return this._drawShapeKind;
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Connector preset for NEW links drawn in `draw-edge` mode (see
|
|
1444
|
+
* `LINK_DRAW_PRESETS`); `null` = stock defaults. Reset on tool switch.
|
|
1445
|
+
*/
|
|
1446
|
+
_linkDrawPreset = null;
|
|
1447
|
+
get linkDrawPreset() {
|
|
1448
|
+
return this._linkDrawPreset;
|
|
1449
|
+
}
|
|
1450
|
+
/** Arm the shape-drawing tool for `kind` (the flyout's shape rows). */
|
|
1451
|
+
armShapeTool(kind) {
|
|
1452
|
+
this.setActiveTool(kind === "ellipse" ? "draw-ellipse" : "draw-rect");
|
|
1453
|
+
this._drawShapeKind = kind;
|
|
1454
|
+
// `setActiveTool` already notified with the reset variant — notify again
|
|
1455
|
+
// so subscribers (the flyout's active row) see the armed kind.
|
|
1456
|
+
this.notify();
|
|
1457
|
+
}
|
|
1458
|
+
/** Arm the connector tool with a line preset (the flyout's line rows). */
|
|
1459
|
+
armLineTool(preset) {
|
|
1460
|
+
this.setActiveTool("draw-edge");
|
|
1461
|
+
this._linkDrawPreset = preset;
|
|
1462
|
+
this.notify();
|
|
1463
|
+
}
|
|
1310
1464
|
setActiveTool(mode) {
|
|
1311
1465
|
const prev = this.actor.getSnapshot().context.mode;
|
|
1312
1466
|
if (prev !== mode)
|
|
1313
1467
|
this._lastActiveTool = prev;
|
|
1468
|
+
// Flyout-armed variants reset on every switch — `armShapeTool` /
|
|
1469
|
+
// `armLineTool` re-set them right after when they are the caller.
|
|
1470
|
+
this._drawShapeKind = "rect";
|
|
1471
|
+
this._linkDrawPreset = null;
|
|
1314
1472
|
// A tool switch cancels any armed colour-picker pipette.
|
|
1315
1473
|
this.pendingEyedropperPick = null;
|
|
1316
1474
|
// Switching tools commits any in-flight text edit (standard: leaving the
|
|
@@ -1326,6 +1484,18 @@ export class Editor {
|
|
|
1326
1484
|
if (mode !== "draw-edge" && this.hoveredLinkTarget !== null) {
|
|
1327
1485
|
this.hoveredLinkTarget = null;
|
|
1328
1486
|
}
|
|
1487
|
+
// Creation / ink / laser tools work on a blank slate: the selection (and
|
|
1488
|
+
// with it the selection toolbar) goes away. `select` and `hand` keep it —
|
|
1489
|
+
// panning is not editing — and `crop` is entered WITH the image selected.
|
|
1490
|
+
if (mode !== "select" && mode !== "hand" && mode !== "crop") {
|
|
1491
|
+
this._selection = Selection.EMPTY;
|
|
1492
|
+
this._selectedLinks = LinkSelection.EMPTY;
|
|
1493
|
+
}
|
|
1494
|
+
// Leaving crop for any other tool abandons the pending crop box (the
|
|
1495
|
+
// commit / cancel paths route through here with the session already
|
|
1496
|
+
// cleared), so no crop frame lingers over the image.
|
|
1497
|
+
if (prev === "crop" && mode !== "crop")
|
|
1498
|
+
this.cropSession = null;
|
|
1329
1499
|
this.actor.send({ type: "SET_MODE", mode });
|
|
1330
1500
|
// Cursor affordance follows the new mode (hand → grab, draw tools →
|
|
1331
1501
|
// crosshair, etc.) — recompute through the single chokepoint.
|
|
@@ -1459,6 +1629,21 @@ export class Editor {
|
|
|
1459
1629
|
this.scheduleRender();
|
|
1460
1630
|
}
|
|
1461
1631
|
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Sticky under the idle cursor (its render bounds, so the reaction row
|
|
1634
|
+
* below the card counts too) — drives the canvas-drawn hover-only "+"
|
|
1635
|
+
* add-reaction button. Set by the pointer-move path; `null` clears.
|
|
1636
|
+
*/
|
|
1637
|
+
_hoveredStickyId = null;
|
|
1638
|
+
get hoveredStickyId() {
|
|
1639
|
+
return this._hoveredStickyId;
|
|
1640
|
+
}
|
|
1641
|
+
setHoveredSticky(id) {
|
|
1642
|
+
if (this._hoveredStickyId === id)
|
|
1643
|
+
return;
|
|
1644
|
+
this._hoveredStickyId = id;
|
|
1645
|
+
this.notify();
|
|
1646
|
+
}
|
|
1462
1647
|
/** True when the shape's GIF is paused (drives the overlay badge). */
|
|
1463
1648
|
isPlaybackPaused(id) {
|
|
1464
1649
|
return this.gifPlayback.isPaused(id);
|
|
@@ -1502,6 +1687,7 @@ export class Editor {
|
|
|
1502
1687
|
const initial = add(this._scene);
|
|
1503
1688
|
this._scene = initial.scene;
|
|
1504
1689
|
this._selection = Selection.single(shape.id);
|
|
1690
|
+
this.placementId = shape.id;
|
|
1505
1691
|
this.notify();
|
|
1506
1692
|
const state = { ...initialState };
|
|
1507
1693
|
return {
|
|
@@ -1519,6 +1705,7 @@ export class Editor {
|
|
|
1519
1705
|
}
|
|
1520
1706
|
tx.add({ kind: "element", id: shape.id, before: null, after: state.current });
|
|
1521
1707
|
tx.commit();
|
|
1708
|
+
this.placementId = null;
|
|
1522
1709
|
// Notify is mandatory here. The dragover snapshots carried the
|
|
1523
1710
|
// placement preview WITHOUT `parentId`, so the
|
|
1524
1711
|
// AutoLayoutScheduler's `signatureFor(parent)` did not include the
|
|
@@ -1532,6 +1719,7 @@ export class Editor {
|
|
|
1532
1719
|
const { scene } = computePlacementCancel(this._scene, shape.id);
|
|
1533
1720
|
this._scene = scene;
|
|
1534
1721
|
tx.cancel();
|
|
1722
|
+
this.placementId = null;
|
|
1535
1723
|
this._selection = Selection.EMPTY;
|
|
1536
1724
|
this.notify();
|
|
1537
1725
|
},
|
|
@@ -1849,6 +2037,25 @@ export class Editor {
|
|
|
1849
2037
|
this.beginTextEdit(id);
|
|
1850
2038
|
return id;
|
|
1851
2039
|
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Drop a default sticky note centred on `worldPoint`, select it and open
|
|
2042
|
+
* its inline editor (the canvas menu's "Add sticky note"). One undo step.
|
|
2043
|
+
*/
|
|
2044
|
+
createStickyAt(worldPoint) {
|
|
2045
|
+
if (this.readOnly)
|
|
2046
|
+
return null;
|
|
2047
|
+
const id = newElementIdAtCursor(++this.nextId);
|
|
2048
|
+
const shape = buildStickyElementAt(this._scene, worldPoint, this._activeLayerId, id);
|
|
2049
|
+
const r = addElement(this._scene, shape);
|
|
2050
|
+
this._scene = r.scene;
|
|
2051
|
+
this._history.push(r.patch);
|
|
2052
|
+
this._selection = Selection.single(id);
|
|
2053
|
+
this._selectedLinks = LinkSelection.EMPTY;
|
|
2054
|
+
this.notify();
|
|
2055
|
+
this.announce(`Created sticky ${id}`);
|
|
2056
|
+
this.beginTextEdit(id);
|
|
2057
|
+
return id;
|
|
2058
|
+
}
|
|
1852
2059
|
/** Current brush paint settings (line colour, fill, opacity, width). */
|
|
1853
2060
|
_brushSettings = DEFAULT_BRUSH_SETTINGS;
|
|
1854
2061
|
get brushSettings() {
|
|
@@ -2377,6 +2584,31 @@ export class Editor {
|
|
|
2377
2584
|
this.notify();
|
|
2378
2585
|
this.announce(`Pasted ${result.newIds.length} shapes`);
|
|
2379
2586
|
}
|
|
2587
|
+
/**
|
|
2588
|
+
* Insert a whole scene fragment (an imported diagram — elements, links,
|
|
2589
|
+
* binary files) into the current scene, centred on `targetWorld` (default:
|
|
2590
|
+
* the last pointer position, else the fragment stays where it is). Ids are
|
|
2591
|
+
* remapped, everything lands on the active layer, one undo step; the new
|
|
2592
|
+
* elements become the selection. Returns their ids.
|
|
2593
|
+
*/
|
|
2594
|
+
insertScene(fragment, targetWorld) {
|
|
2595
|
+
if (this.readOnly)
|
|
2596
|
+
return [];
|
|
2597
|
+
this.finalizeOpenGestureTx();
|
|
2598
|
+
const target = targetWorld ?? this.lastPointerWorld;
|
|
2599
|
+
const result = insertSceneFragment(this._scene, this._history, fragment, target ?? null, this._activeLayerId, () => castElementId(`shape-${String(++this.nextId)}-${Date.now().toString(36)}`), () => castLinkId(`link-${String(++this.nextId)}-${Date.now().toString(36)}`), () => castFileId(`file-${String(++this.nextId)}-${Date.now().toString(36)}`));
|
|
2600
|
+
this._scene = result.scene;
|
|
2601
|
+
this._selection = selectionFromPasted(result.newIds);
|
|
2602
|
+
// Same as `loadScene`: rebuild live handles for the inserted media from
|
|
2603
|
+
// `Scene.files` (importers hand images over as data URLs — persisted as
|
|
2604
|
+
// files above — and a native fragment carries its own files).
|
|
2605
|
+
animScene.rehydrateAnimatedImages(this);
|
|
2606
|
+
void animScene.rehydrateStaticImages(this);
|
|
2607
|
+
this.notify();
|
|
2608
|
+
this.maybeAnimate();
|
|
2609
|
+
this.announce(`Inserted ${String(result.newIds.length)} shapes`);
|
|
2610
|
+
return result.newIds;
|
|
2611
|
+
}
|
|
2380
2612
|
/**
|
|
2381
2613
|
* Merge `partial` into the `style` of every shape in `ids`. Useful
|
|
2382
2614
|
* for the inspector / PropertyPanel: flipping `roundness`, swapping
|
|
@@ -2453,6 +2685,610 @@ export class Editor {
|
|
|
2453
2685
|
this._history.push(result.patch);
|
|
2454
2686
|
this.notify();
|
|
2455
2687
|
}
|
|
2688
|
+
/**
|
|
2689
|
+
* Merge a partial text style into the embedded label's style on every
|
|
2690
|
+
* shape in `ids` that carries a label. One undo step. The counterpart
|
|
2691
|
+
* of `updateStyle` for shape labels (which must not collide with the
|
|
2692
|
+
* shape body's own fill / stroke).
|
|
2693
|
+
*/
|
|
2694
|
+
updateLabelStyle(ids, partial) {
|
|
2695
|
+
if (this.readOnly)
|
|
2696
|
+
return;
|
|
2697
|
+
const tx = this._history.transaction();
|
|
2698
|
+
let changed = false;
|
|
2699
|
+
for (const id of ids) {
|
|
2700
|
+
const shape = getElement(this._scene, id);
|
|
2701
|
+
// A labelable shape without a label yet gets one so the style sticks
|
|
2702
|
+
// (create-on-write); non-labelable shapes are skipped.
|
|
2703
|
+
if (shape === undefined || withLabel(shape).label === undefined)
|
|
2704
|
+
continue;
|
|
2705
|
+
const r = updateElement(this._scene, id, (raw) => {
|
|
2706
|
+
const s = withLabel(raw);
|
|
2707
|
+
return s.label === undefined
|
|
2708
|
+
? s
|
|
2709
|
+
: { ...s, label: { ...s.label, style: { ...s.label.style, ...partial } } };
|
|
2710
|
+
});
|
|
2711
|
+
this._scene = r.scene;
|
|
2712
|
+
tx.add(r.patch);
|
|
2713
|
+
changed = true;
|
|
2714
|
+
}
|
|
2715
|
+
if (!changed)
|
|
2716
|
+
return;
|
|
2717
|
+
tx.commit();
|
|
2718
|
+
this.notify();
|
|
2719
|
+
}
|
|
2720
|
+
/**
|
|
2721
|
+
* Merge font family / size into the embedded label. Picking an
|
|
2722
|
+
* explicit `fontSize` also leaves auto-fit mode (reference behaviour —
|
|
2723
|
+
* a concrete size wins over the automatic one). One undo step.
|
|
2724
|
+
*/
|
|
2725
|
+
updateLabelProps(ids, partial) {
|
|
2726
|
+
if (this.readOnly)
|
|
2727
|
+
return;
|
|
2728
|
+
const tx = this._history.transaction();
|
|
2729
|
+
let changed = false;
|
|
2730
|
+
for (const id of ids) {
|
|
2731
|
+
const shape = getElement(this._scene, id);
|
|
2732
|
+
if (shape === undefined || withLabel(shape).label === undefined)
|
|
2733
|
+
continue;
|
|
2734
|
+
const r = updateElement(this._scene, id, (raw) => {
|
|
2735
|
+
const s = withLabel(raw);
|
|
2736
|
+
if (s.label === undefined)
|
|
2737
|
+
return s;
|
|
2738
|
+
const label = { ...s.label, ...partial };
|
|
2739
|
+
if (partial.fontSize !== undefined)
|
|
2740
|
+
delete label.autoFit;
|
|
2741
|
+
return { ...s, label };
|
|
2742
|
+
});
|
|
2743
|
+
this._scene = r.scene;
|
|
2744
|
+
tx.add(r.patch);
|
|
2745
|
+
changed = true;
|
|
2746
|
+
}
|
|
2747
|
+
if (!changed)
|
|
2748
|
+
return;
|
|
2749
|
+
tx.commit();
|
|
2750
|
+
this.notify();
|
|
2751
|
+
}
|
|
2752
|
+
/** Toggle auto-fit label sizing (sticky notes). One undo step. */
|
|
2753
|
+
setLabelAutoFit(ids, on) {
|
|
2754
|
+
if (this.readOnly)
|
|
2755
|
+
return;
|
|
2756
|
+
const tx = this._history.transaction();
|
|
2757
|
+
let changed = false;
|
|
2758
|
+
for (const id of ids) {
|
|
2759
|
+
const shape = getElement(this._scene, id);
|
|
2760
|
+
if (shape === undefined || withLabel(shape).label === undefined)
|
|
2761
|
+
continue;
|
|
2762
|
+
const r = updateElement(this._scene, id, (raw) => {
|
|
2763
|
+
const s = withLabel(raw);
|
|
2764
|
+
if (s.label === undefined)
|
|
2765
|
+
return s;
|
|
2766
|
+
const label = { ...s.label };
|
|
2767
|
+
if (on)
|
|
2768
|
+
label.autoFit = true;
|
|
2769
|
+
else
|
|
2770
|
+
delete label.autoFit;
|
|
2771
|
+
return { ...s, label };
|
|
2772
|
+
});
|
|
2773
|
+
this._scene = r.scene;
|
|
2774
|
+
tx.add(r.patch);
|
|
2775
|
+
changed = true;
|
|
2776
|
+
}
|
|
2777
|
+
if (!changed)
|
|
2778
|
+
return;
|
|
2779
|
+
tx.commit();
|
|
2780
|
+
this.notify();
|
|
2781
|
+
}
|
|
2782
|
+
/**
|
|
2783
|
+
* Resize sticky notes to a square preset side (`STICKY_SIZE_PRESETS`).
|
|
2784
|
+
* One undo step; non-sticky ids are skipped.
|
|
2785
|
+
*/
|
|
2786
|
+
setStickySize(ids, side) {
|
|
2787
|
+
if (this.readOnly)
|
|
2788
|
+
return;
|
|
2789
|
+
const tx = this._history.transaction();
|
|
2790
|
+
let changed = false;
|
|
2791
|
+
for (const id of ids) {
|
|
2792
|
+
const shape = getElement(this._scene, id);
|
|
2793
|
+
if (!shape || !isSticky(shape))
|
|
2794
|
+
continue;
|
|
2795
|
+
const r = updateElement(this._scene, id, (s) => ({ ...s, width: side, height: side }));
|
|
2796
|
+
this._scene = r.scene;
|
|
2797
|
+
tx.add(r.patch);
|
|
2798
|
+
changed = true;
|
|
2799
|
+
}
|
|
2800
|
+
if (!changed)
|
|
2801
|
+
return;
|
|
2802
|
+
tx.commit();
|
|
2803
|
+
this.notify();
|
|
2804
|
+
}
|
|
2805
|
+
/**
|
|
2806
|
+
* Toggle the author-name strip on sticky notes. Turning it on fills a
|
|
2807
|
+
* missing `authorName` from the editor's comment author (the local
|
|
2808
|
+
* user). One undo step.
|
|
2809
|
+
*/
|
|
2810
|
+
toggleStickyAuthor(ids) {
|
|
2811
|
+
if (this.readOnly)
|
|
2812
|
+
return;
|
|
2813
|
+
const tx = this._history.transaction();
|
|
2814
|
+
let changed = false;
|
|
2815
|
+
for (const id of ids) {
|
|
2816
|
+
const shape = getElement(this._scene, id);
|
|
2817
|
+
if (!shape || !isSticky(shape))
|
|
2818
|
+
continue;
|
|
2819
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
2820
|
+
const sticky = s;
|
|
2821
|
+
const showAuthor = sticky.showAuthor !== true;
|
|
2822
|
+
return {
|
|
2823
|
+
...s,
|
|
2824
|
+
showAuthor,
|
|
2825
|
+
authorName: sticky.authorName ?? this.commentAuthor.name,
|
|
2826
|
+
};
|
|
2827
|
+
});
|
|
2828
|
+
this._scene = r.scene;
|
|
2829
|
+
tx.add(r.patch);
|
|
2830
|
+
changed = true;
|
|
2831
|
+
}
|
|
2832
|
+
if (!changed)
|
|
2833
|
+
return;
|
|
2834
|
+
tx.commit();
|
|
2835
|
+
this.notify();
|
|
2836
|
+
}
|
|
2837
|
+
/** Replace the tag list on sticky notes. One undo step. */
|
|
2838
|
+
setStickyTags(ids, tags) {
|
|
2839
|
+
if (this.readOnly)
|
|
2840
|
+
return;
|
|
2841
|
+
const tx = this._history.transaction();
|
|
2842
|
+
let changed = false;
|
|
2843
|
+
for (const id of ids) {
|
|
2844
|
+
const shape = getElement(this._scene, id);
|
|
2845
|
+
if (!shape || !isSticky(shape))
|
|
2846
|
+
continue;
|
|
2847
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
2848
|
+
const copy = { ...s };
|
|
2849
|
+
if (tags.length === 0)
|
|
2850
|
+
delete copy.tags;
|
|
2851
|
+
else
|
|
2852
|
+
copy.tags = tags;
|
|
2853
|
+
return copy;
|
|
2854
|
+
});
|
|
2855
|
+
this._scene = r.scene;
|
|
2856
|
+
tx.add(r.patch);
|
|
2857
|
+
changed = true;
|
|
2858
|
+
}
|
|
2859
|
+
if (!changed)
|
|
2860
|
+
return;
|
|
2861
|
+
tx.commit();
|
|
2862
|
+
this.notify();
|
|
2863
|
+
}
|
|
2864
|
+
/**
|
|
2865
|
+
* Toggle the local user's emoji reaction on a sticky note. A glyph the
|
|
2866
|
+
* user has NOT reacted with gains their reaction (+1); one they
|
|
2867
|
+
* already reacted with loses it (-1, removing the glyph at zero).
|
|
2868
|
+
* Other users' reactions are never touched — so counters only grow
|
|
2869
|
+
* through OTHER collaborators. Plain element data → syncs through the
|
|
2870
|
+
* normal scene channel. One undo step.
|
|
2871
|
+
*/
|
|
2872
|
+
toggleStickyReaction(id, glyph) {
|
|
2873
|
+
if (this.readOnly)
|
|
2874
|
+
return;
|
|
2875
|
+
const shape = getElement(this._scene, id);
|
|
2876
|
+
if (!shape || !isSticky(shape))
|
|
2877
|
+
return;
|
|
2878
|
+
const user = this.commentAuthor.id;
|
|
2879
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
2880
|
+
const sticky = s;
|
|
2881
|
+
const reactions = [...(sticky.reactions ?? [])];
|
|
2882
|
+
const i = reactions.findIndex((x) => x.glyph === glyph);
|
|
2883
|
+
const existing = i >= 0 ? reactions[i] : undefined;
|
|
2884
|
+
// Pre-per-user scenes may carry `{glyph, count}` without `users` —
|
|
2885
|
+
// treat those as nobody-having-reacted and rewrite on first toggle.
|
|
2886
|
+
const existingUsers = existing?.users ?? [];
|
|
2887
|
+
if (existing === undefined) {
|
|
2888
|
+
reactions.push({ glyph, users: [user] });
|
|
2889
|
+
}
|
|
2890
|
+
else if (existingUsers.includes(user)) {
|
|
2891
|
+
const users = existingUsers.filter((u) => u !== user);
|
|
2892
|
+
if (users.length === 0)
|
|
2893
|
+
reactions.splice(i, 1);
|
|
2894
|
+
else
|
|
2895
|
+
reactions[i] = { glyph, users };
|
|
2896
|
+
}
|
|
2897
|
+
else {
|
|
2898
|
+
reactions[i] = { glyph, users: [...existingUsers, user] };
|
|
2899
|
+
}
|
|
2900
|
+
const copy = { ...s };
|
|
2901
|
+
if (reactions.length === 0)
|
|
2902
|
+
delete copy.reactions;
|
|
2903
|
+
else
|
|
2904
|
+
copy.reactions = reactions;
|
|
2905
|
+
return copy;
|
|
2906
|
+
});
|
|
2907
|
+
this._scene = r.scene;
|
|
2908
|
+
this._history.push(r.patch);
|
|
2909
|
+
this.notify();
|
|
2910
|
+
}
|
|
2911
|
+
/** Replace an emoji element's glyph (the toolbar picker). One undo step. */
|
|
2912
|
+
setEmojiGlyph(ids, glyph) {
|
|
2913
|
+
if (this.readOnly)
|
|
2914
|
+
return;
|
|
2915
|
+
const tx = this._history.transaction();
|
|
2916
|
+
let changed = false;
|
|
2917
|
+
for (const id of ids) {
|
|
2918
|
+
const shape = getElement(this._scene, id);
|
|
2919
|
+
if (!shape || !isEmoji(shape))
|
|
2920
|
+
continue;
|
|
2921
|
+
const r = updateElement(this._scene, id, (s) => ({ ...s, glyph }));
|
|
2922
|
+
this._scene = r.scene;
|
|
2923
|
+
tx.add(r.patch);
|
|
2924
|
+
changed = true;
|
|
2925
|
+
}
|
|
2926
|
+
if (!changed)
|
|
2927
|
+
return;
|
|
2928
|
+
tx.commit();
|
|
2929
|
+
this.notify();
|
|
2930
|
+
}
|
|
2931
|
+
/**
|
|
2932
|
+
* Resize a frame to one of the `FRAME_SIZE_PRESETS` (top-left corner
|
|
2933
|
+
* stays put; members keep their absolute positions). One undo step.
|
|
2934
|
+
*/
|
|
2935
|
+
applyFramePreset(id, preset) {
|
|
2936
|
+
if (this.readOnly)
|
|
2937
|
+
return;
|
|
2938
|
+
const shape = getElement(this._scene, id);
|
|
2939
|
+
if (!shape || !isFrame(shape))
|
|
2940
|
+
return;
|
|
2941
|
+
const r = updateElement(this._scene, id, (s) => ({
|
|
2942
|
+
...s,
|
|
2943
|
+
width: preset.width,
|
|
2944
|
+
height: preset.height,
|
|
2945
|
+
}));
|
|
2946
|
+
this._scene = r.scene;
|
|
2947
|
+
this._history.push(r.patch);
|
|
2948
|
+
this.notify();
|
|
2949
|
+
}
|
|
2950
|
+
/**
|
|
2951
|
+
* Rename the binary file behind an image (the toolbar's file-name
|
|
2952
|
+
* input). One undo step; no-op when the id is unknown or the name is
|
|
2953
|
+
* unchanged.
|
|
2954
|
+
*/
|
|
2955
|
+
renameBinaryFile(id, name) {
|
|
2956
|
+
if (this.readOnly)
|
|
2957
|
+
return;
|
|
2958
|
+
const file = getBinaryFile(this._scene, id);
|
|
2959
|
+
if (!file || file.name === name)
|
|
2960
|
+
return;
|
|
2961
|
+
const patch = { kind: "file", id, before: file, after: { ...file, name } };
|
|
2962
|
+
this._scene = apply(this._scene, patch);
|
|
2963
|
+
this._history.push(patch);
|
|
2964
|
+
this.notify();
|
|
2965
|
+
}
|
|
2966
|
+
/** Set (or clear, with `null`) the accessible description on image shapes. */
|
|
2967
|
+
setImageAlt(ids, alt) {
|
|
2968
|
+
if (this.readOnly)
|
|
2969
|
+
return;
|
|
2970
|
+
const tx = this._history.transaction();
|
|
2971
|
+
let changed = false;
|
|
2972
|
+
for (const id of ids) {
|
|
2973
|
+
const shape = getElement(this._scene, id);
|
|
2974
|
+
if (!shape || !isImage(shape))
|
|
2975
|
+
continue;
|
|
2976
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
2977
|
+
const copy = { ...s };
|
|
2978
|
+
if (alt === null || alt === "")
|
|
2979
|
+
delete copy.alt;
|
|
2980
|
+
else
|
|
2981
|
+
copy.alt = alt;
|
|
2982
|
+
return copy;
|
|
2983
|
+
});
|
|
2984
|
+
this._scene = r.scene;
|
|
2985
|
+
tx.add(r.patch);
|
|
2986
|
+
changed = true;
|
|
2987
|
+
}
|
|
2988
|
+
if (!changed)
|
|
2989
|
+
return;
|
|
2990
|
+
tx.commit();
|
|
2991
|
+
this.notify();
|
|
2992
|
+
}
|
|
2993
|
+
/**
|
|
2994
|
+
* Set (or clear, with `null`) the shape mask on image elements. The
|
|
2995
|
+
* mask clips the drawn box (renderer-side `RenderTarget.clip`);
|
|
2996
|
+
* normalised coordinates make it survive resizes. One undo step.
|
|
2997
|
+
*/
|
|
2998
|
+
setImageMask(ids, mask) {
|
|
2999
|
+
if (this.readOnly)
|
|
3000
|
+
return;
|
|
3001
|
+
const tx = this._history.transaction();
|
|
3002
|
+
let changed = false;
|
|
3003
|
+
for (const id of ids) {
|
|
3004
|
+
const shape = getElement(this._scene, id);
|
|
3005
|
+
if (!shape || !isImage(shape))
|
|
3006
|
+
continue;
|
|
3007
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
3008
|
+
const copy = { ...s };
|
|
3009
|
+
if (mask === null)
|
|
3010
|
+
delete copy.mask;
|
|
3011
|
+
else
|
|
3012
|
+
copy.mask = mask;
|
|
3013
|
+
return copy;
|
|
3014
|
+
});
|
|
3015
|
+
this._scene = r.scene;
|
|
3016
|
+
tx.add(r.patch);
|
|
3017
|
+
changed = true;
|
|
3018
|
+
}
|
|
3019
|
+
if (!changed)
|
|
3020
|
+
return;
|
|
3021
|
+
tx.commit();
|
|
3022
|
+
this.notify();
|
|
3023
|
+
}
|
|
3024
|
+
/**
|
|
3025
|
+
* Apply an aspect preset from the mask picker to image elements.
|
|
3026
|
+
* `"original"` clears crop AND mask and refits the box to the source's
|
|
3027
|
+
* natural aspect; the ratio presets centre-crop the source to the
|
|
3028
|
+
* target aspect and refit the box height (width kept), with `"circle"`
|
|
3029
|
+
* additionally installing an ellipse mask on the square box. The
|
|
3030
|
+
* source's natural aspect derives from the current box + crop (the box
|
|
3031
|
+
* always displays the crop region undistorted), so no live image
|
|
3032
|
+
* handle is needed. One undo step.
|
|
3033
|
+
*/
|
|
3034
|
+
setImageAspectPreset(ids, preset) {
|
|
3035
|
+
if (this.readOnly)
|
|
3036
|
+
return;
|
|
3037
|
+
const tx = this._history.transaction();
|
|
3038
|
+
let changed = false;
|
|
3039
|
+
for (const id of ids) {
|
|
3040
|
+
const shape = getElement(this._scene, id);
|
|
3041
|
+
if (!shape || !isImage(shape))
|
|
3042
|
+
continue;
|
|
3043
|
+
const crop = shape.crop ?? { x: 0, y: 0, width: 1, height: 1 };
|
|
3044
|
+
const naturalAspect = shape.height > 0 && crop.width > 0
|
|
3045
|
+
? (shape.width / shape.height) * (crop.height / crop.width)
|
|
3046
|
+
: 1;
|
|
3047
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
3048
|
+
const copy = { ...s };
|
|
3049
|
+
if (preset === "original") {
|
|
3050
|
+
delete copy.crop;
|
|
3051
|
+
delete copy.mask;
|
|
3052
|
+
if (naturalAspect > 0)
|
|
3053
|
+
copy.height = copy.width / naturalAspect;
|
|
3054
|
+
return copy;
|
|
3055
|
+
}
|
|
3056
|
+
const aspect = IMAGE_ASPECT_PRESETS[preset];
|
|
3057
|
+
// Centred source crop with the target aspect: trim the longer axis.
|
|
3058
|
+
if (naturalAspect > aspect) {
|
|
3059
|
+
const w = aspect / naturalAspect;
|
|
3060
|
+
copy.crop = { x: (1 - w) / 2, y: 0, width: w, height: 1 };
|
|
3061
|
+
}
|
|
3062
|
+
else {
|
|
3063
|
+
const h = naturalAspect / aspect;
|
|
3064
|
+
copy.crop = { x: 0, y: (1 - h) / 2, width: 1, height: h };
|
|
3065
|
+
}
|
|
3066
|
+
copy.height = copy.width / aspect;
|
|
3067
|
+
if (preset === "circle")
|
|
3068
|
+
copy.mask = { kind: "ellipse" };
|
|
3069
|
+
else
|
|
3070
|
+
delete copy.mask;
|
|
3071
|
+
return copy;
|
|
3072
|
+
});
|
|
3073
|
+
this._scene = r.scene;
|
|
3074
|
+
tx.add(r.patch);
|
|
3075
|
+
changed = true;
|
|
3076
|
+
}
|
|
3077
|
+
if (!changed)
|
|
3078
|
+
return;
|
|
3079
|
+
tx.commit();
|
|
3080
|
+
this.notify();
|
|
3081
|
+
}
|
|
3082
|
+
/**
|
|
3083
|
+
* Swap a media shape's backing file for `blob` while keeping its
|
|
3084
|
+
* position and width (height refits to the new aspect). Accepts static
|
|
3085
|
+
* images, GIFs and videos — the shape's animation fields are rewritten
|
|
3086
|
+
* to match the NEW media kind, and `crop` resets when the media kind
|
|
3087
|
+
* changes (its pixel rect targeted the old content). Registers the new
|
|
3088
|
+
* `BinaryFile`, points the shape at it, drops the stale live handle
|
|
3089
|
+
* (`metadata.image`) and rebuilds handles for the new bytes. One undo
|
|
3090
|
+
* step.
|
|
3091
|
+
*/
|
|
3092
|
+
async replaceImageFile(id, blob, name) {
|
|
3093
|
+
if (this.readOnly)
|
|
3094
|
+
return;
|
|
3095
|
+
const shape = getElement(this._scene, id);
|
|
3096
|
+
if (!shape || !isImage(shape))
|
|
3097
|
+
return;
|
|
3098
|
+
const added = await computeAddBinaryFile(this._scene, blob, name, () => ++this.nextId);
|
|
3099
|
+
const mime = blob.type;
|
|
3100
|
+
const isVideo = mime.startsWith("video/") || /\.(mp4|webm|ogv|mov)$/i.test(name ?? "");
|
|
3101
|
+
const isGif = !isVideo && (mime === "image/gif" || /\.gif$/i.test(name ?? ""));
|
|
3102
|
+
const url = typeof URL !== "undefined" && typeof URL.createObjectURL === "function"
|
|
3103
|
+
? URL.createObjectURL(blob)
|
|
3104
|
+
: null;
|
|
3105
|
+
// The new media's aspect ratio replaces the old one: keep the shape's
|
|
3106
|
+
// width, refit the height — otherwise a differently-proportioned file
|
|
3107
|
+
// renders stretched into the old box. Videos measure through a hidden
|
|
3108
|
+
// looping <video> (which then stays on as the live handle, same as the
|
|
3109
|
+
// video drop handler); images/GIFs through createImageBitmap.
|
|
3110
|
+
let refitHeight = null;
|
|
3111
|
+
let videoHandle = null;
|
|
3112
|
+
if (isVideo) {
|
|
3113
|
+
if (typeof document !== "undefined" && url !== null) {
|
|
3114
|
+
const video = createHiddenLoopingVideo(url);
|
|
3115
|
+
await new Promise((resolve) => {
|
|
3116
|
+
video.onloadedmetadata = () => {
|
|
3117
|
+
resolve();
|
|
3118
|
+
};
|
|
3119
|
+
video.onerror = () => {
|
|
3120
|
+
resolve();
|
|
3121
|
+
};
|
|
3122
|
+
});
|
|
3123
|
+
if (video.videoWidth > 0) {
|
|
3124
|
+
refitHeight = (shape.width * video.videoHeight) / video.videoWidth;
|
|
3125
|
+
}
|
|
3126
|
+
// Best-effort play (muted autoplay can still need a prior gesture).
|
|
3127
|
+
void video.play().catch(() => {
|
|
3128
|
+
/* intentional no-op */
|
|
3129
|
+
});
|
|
3130
|
+
videoHandle = video;
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
else if (typeof createImageBitmap === "function") {
|
|
3134
|
+
try {
|
|
3135
|
+
const bitmap = await createImageBitmap(blob);
|
|
3136
|
+
if (bitmap.width > 0) {
|
|
3137
|
+
refitHeight = (shape.width * bitmap.height) / bitmap.width;
|
|
3138
|
+
}
|
|
3139
|
+
bitmap.close();
|
|
3140
|
+
}
|
|
3141
|
+
catch {
|
|
3142
|
+
/* undecodable here — keep the old box; rehydration reports errors */
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
// GIF frames decode from the raw bytes via the "gif" adapter.
|
|
3146
|
+
const animationBytes = isGif ? await blob.arrayBuffer() : undefined;
|
|
3147
|
+
const oldKind = shape.animationKind
|
|
3148
|
+
? "gif"
|
|
3149
|
+
: shape.metadata?.animated === true
|
|
3150
|
+
? "video"
|
|
3151
|
+
: "static";
|
|
3152
|
+
const newKind = isGif ? "gif" : isVideo ? "video" : "static";
|
|
3153
|
+
const tx = this._history.transaction();
|
|
3154
|
+
tx.add(added.patch);
|
|
3155
|
+
const r = updateElement(added.scene, id, (s) => {
|
|
3156
|
+
const copy = { ...s };
|
|
3157
|
+
copy.fileId = added.id;
|
|
3158
|
+
if (url !== null)
|
|
3159
|
+
copy.src = url;
|
|
3160
|
+
if (refitHeight !== null)
|
|
3161
|
+
copy.height = refitHeight;
|
|
3162
|
+
const meta = { ...copy.metadata };
|
|
3163
|
+
delete meta.image;
|
|
3164
|
+
delete meta.animated;
|
|
3165
|
+
if (newKind !== "static")
|
|
3166
|
+
meta.animated = true;
|
|
3167
|
+
if (videoHandle)
|
|
3168
|
+
meta.image = videoHandle;
|
|
3169
|
+
if (Object.keys(meta).length > 0)
|
|
3170
|
+
copy.metadata = meta;
|
|
3171
|
+
else
|
|
3172
|
+
delete copy.metadata;
|
|
3173
|
+
delete copy.animationKind;
|
|
3174
|
+
delete copy.animationData;
|
|
3175
|
+
if (isGif) {
|
|
3176
|
+
copy.animationKind = "gif";
|
|
3177
|
+
copy.animationData = animationBytes;
|
|
3178
|
+
}
|
|
3179
|
+
// The crop rect addressed the OLD media's pixels — meaningless
|
|
3180
|
+
// across a media-kind switch.
|
|
3181
|
+
if (oldKind !== newKind)
|
|
3182
|
+
delete copy.crop;
|
|
3183
|
+
return copy;
|
|
3184
|
+
});
|
|
3185
|
+
this._scene = r.scene;
|
|
3186
|
+
tx.add(r.patch);
|
|
3187
|
+
tx.commit();
|
|
3188
|
+
if (newKind !== "static") {
|
|
3189
|
+
this.gifPlayback.ensure(id);
|
|
3190
|
+
this.maybeAnimate();
|
|
3191
|
+
}
|
|
3192
|
+
this.notify();
|
|
3193
|
+
if (newKind === "static")
|
|
3194
|
+
await animScene.rehydrateStaticImages(this);
|
|
3195
|
+
}
|
|
3196
|
+
/**
|
|
3197
|
+
* Paragraph targets for the list operations: while inline-editing one
|
|
3198
|
+
* of `ids`, only the paragraphs covered by the caret / selection;
|
|
3199
|
+
* otherwise every paragraph of each text element. Returns `null` for
|
|
3200
|
+
* non-text ids.
|
|
3201
|
+
*/
|
|
3202
|
+
paragraphTargets(id) {
|
|
3203
|
+
const shape = getElement(this._scene, id);
|
|
3204
|
+
if (!shape || !isText(shape))
|
|
3205
|
+
return null;
|
|
3206
|
+
const sel = this.editingTextElement === id ? this.editingTextSelection : null;
|
|
3207
|
+
if (sel)
|
|
3208
|
+
return paragraphRangeForOffsets(shape.text, sel.start, sel.end);
|
|
3209
|
+
return { first: 0, last: paragraphCount(shape.text) - 1 };
|
|
3210
|
+
}
|
|
3211
|
+
/**
|
|
3212
|
+
* Set (or clear, with `null`) the list kind on the targeted paragraphs
|
|
3213
|
+
* of every text element in `ids` — the inline-edit selection's
|
|
3214
|
+
* paragraphs while editing, the whole element otherwise. One undo step.
|
|
3215
|
+
*/
|
|
3216
|
+
setParagraphList(ids, kind) {
|
|
3217
|
+
if (this.readOnly)
|
|
3218
|
+
return;
|
|
3219
|
+
const tx = this._history.transaction();
|
|
3220
|
+
let changed = false;
|
|
3221
|
+
for (const id of ids) {
|
|
3222
|
+
const range = this.paragraphTargets(id);
|
|
3223
|
+
if (!range)
|
|
3224
|
+
continue;
|
|
3225
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
3226
|
+
const shape = s;
|
|
3227
|
+
const count = paragraphCount(shape.text);
|
|
3228
|
+
const next = Array.from({ length: count }, (_, i) => paragraphAt(shape.paragraphs, i)).map((p, i) => {
|
|
3229
|
+
if (i < range.first || i > range.last)
|
|
3230
|
+
return p;
|
|
3231
|
+
const { list: _list, ...rest } = p;
|
|
3232
|
+
return kind === null ? rest : { ...rest, list: kind };
|
|
3233
|
+
});
|
|
3234
|
+
const normalized = normalizeParagraphs(next);
|
|
3235
|
+
const copy = { ...s };
|
|
3236
|
+
if (normalized === undefined)
|
|
3237
|
+
delete copy.paragraphs;
|
|
3238
|
+
else
|
|
3239
|
+
copy.paragraphs = normalized;
|
|
3240
|
+
return copy;
|
|
3241
|
+
});
|
|
3242
|
+
this._scene = r.scene;
|
|
3243
|
+
tx.add(r.patch);
|
|
3244
|
+
changed = true;
|
|
3245
|
+
}
|
|
3246
|
+
if (!changed)
|
|
3247
|
+
return;
|
|
3248
|
+
tx.commit();
|
|
3249
|
+
this.notify();
|
|
3250
|
+
}
|
|
3251
|
+
/**
|
|
3252
|
+
* Shift the targeted paragraphs' list nesting by `delta` (±1), clamped
|
|
3253
|
+
* to `[0, MAX_LIST_INDENT]`. Same targeting rules as
|
|
3254
|
+
* {@link setParagraphList}. One undo step.
|
|
3255
|
+
*/
|
|
3256
|
+
indentParagraphs(ids, delta) {
|
|
3257
|
+
if (this.readOnly)
|
|
3258
|
+
return;
|
|
3259
|
+
const tx = this._history.transaction();
|
|
3260
|
+
let changed = false;
|
|
3261
|
+
for (const id of ids) {
|
|
3262
|
+
const range = this.paragraphTargets(id);
|
|
3263
|
+
if (!range)
|
|
3264
|
+
continue;
|
|
3265
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
3266
|
+
const shape = s;
|
|
3267
|
+
const count = paragraphCount(shape.text);
|
|
3268
|
+
const next = Array.from({ length: count }, (_, i) => paragraphAt(shape.paragraphs, i)).map((p, i) => {
|
|
3269
|
+
if (i < range.first || i > range.last)
|
|
3270
|
+
return p;
|
|
3271
|
+
const level = Math.max(0, Math.min(MAX_LIST_INDENT, (p.indent ?? 0) + delta));
|
|
3272
|
+
const { indent: _indent, ...rest } = p;
|
|
3273
|
+
return level === 0 ? rest : { ...rest, indent: level };
|
|
3274
|
+
});
|
|
3275
|
+
const normalized = normalizeParagraphs(next);
|
|
3276
|
+
const copy = { ...s };
|
|
3277
|
+
if (normalized === undefined)
|
|
3278
|
+
delete copy.paragraphs;
|
|
3279
|
+
else
|
|
3280
|
+
copy.paragraphs = normalized;
|
|
3281
|
+
return copy;
|
|
3282
|
+
});
|
|
3283
|
+
this._scene = r.scene;
|
|
3284
|
+
tx.add(r.patch);
|
|
3285
|
+
changed = true;
|
|
3286
|
+
}
|
|
3287
|
+
if (!changed)
|
|
3288
|
+
return;
|
|
3289
|
+
tx.commit();
|
|
3290
|
+
this.notify();
|
|
3291
|
+
}
|
|
2456
3292
|
// --- F8: Eyedropper ---------------------------------------------------
|
|
2457
3293
|
/**
|
|
2458
3294
|
* The fill (or stroke, per `role`) colour of the top-most shape under the
|
|
@@ -2946,8 +3782,8 @@ export class Editor {
|
|
|
2946
3782
|
* with its world bounds — for the hover link-popup. `null` when none.
|
|
2947
3783
|
*/
|
|
2948
3784
|
linkAt(worldPoint) {
|
|
2949
|
-
const shape = this.acceleratedElementAt(worldPoint);
|
|
2950
|
-
if (!shape
|
|
3785
|
+
const shape = this.acceleratedElementAt(worldPoint, (s) => this.isElementInteractable(s));
|
|
3786
|
+
if (!shape)
|
|
2951
3787
|
return null;
|
|
2952
3788
|
const href = safeHref(shape.href);
|
|
2953
3789
|
if (!href)
|
|
@@ -3026,20 +3862,37 @@ export class Editor {
|
|
|
3026
3862
|
* rotate gesture drives the same maths from a press-time snapshot.
|
|
3027
3863
|
*/
|
|
3028
3864
|
rotateSelection(delta) {
|
|
3029
|
-
|
|
3865
|
+
// Groups carry their subtree: every descendant orbits the shared pivot.
|
|
3030
3866
|
const origin = new Map();
|
|
3031
|
-
for (const id of this.
|
|
3867
|
+
for (const id of this.expandSelectionWithDescendants()) {
|
|
3032
3868
|
const el = getElement(this._scene, id);
|
|
3033
|
-
if (el === undefined)
|
|
3869
|
+
if (el === undefined || isGroup(el))
|
|
3034
3870
|
continue;
|
|
3035
|
-
elements.push(el);
|
|
3036
3871
|
origin.set(id, { position: el.position, rotation: el.rotation });
|
|
3037
3872
|
}
|
|
3038
|
-
|
|
3873
|
+
const box = this.combinedSelectionBounds();
|
|
3874
|
+
if (origin.size === 0 || !box)
|
|
3039
3875
|
return;
|
|
3040
|
-
const pivot =
|
|
3876
|
+
const pivot = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
3041
3877
|
this.commitArrange(computeRotatePatches(this._scene, origin, pivot, delta));
|
|
3042
3878
|
}
|
|
3879
|
+
/**
|
|
3880
|
+
* Straighten the selection: every selected element (and descendants of
|
|
3881
|
+
* selected groups) goes back to `rotation: 0`, each turning about its own
|
|
3882
|
+
* centre so it stays where it is. One undoable step; no-op when nothing
|
|
3883
|
+
* is rotated. Triggered by a double-click on the rotate grip.
|
|
3884
|
+
*/
|
|
3885
|
+
resetSelectionRotation() {
|
|
3886
|
+
const patches = [];
|
|
3887
|
+
for (const id of this.expandSelectionWithDescendants()) {
|
|
3888
|
+
const el = getElement(this._scene, id);
|
|
3889
|
+
if (el === undefined || el.rotation === 0)
|
|
3890
|
+
continue;
|
|
3891
|
+
const origin = new Map([[id, { position: el.position, rotation: el.rotation }]]);
|
|
3892
|
+
patches.push(...computeRotatePatches(this._scene, origin, selectionCenter([el]), -el.rotation));
|
|
3893
|
+
}
|
|
3894
|
+
this.commitArrange(patches);
|
|
3895
|
+
}
|
|
3043
3896
|
/** Apply a batch of arrange patches as a single undoable step. */
|
|
3044
3897
|
commitArrange(patches) {
|
|
3045
3898
|
if (this.readOnly)
|
|
@@ -3191,6 +4044,18 @@ export class Editor {
|
|
|
3191
4044
|
const center = this.screenToWorld({ x: vp.size.width / 2, y: vp.size.height / 2 });
|
|
3192
4045
|
this.zoomAt(factor, center);
|
|
3193
4046
|
}
|
|
4047
|
+
/**
|
|
4048
|
+
* Set an absolute zoom level (1 = 100%), scaling about the viewport
|
|
4049
|
+
* centre and clamped to `MIN_ZOOM..MAX_ZOOM`. The zoom menu's presets.
|
|
4050
|
+
*/
|
|
4051
|
+
setZoom(level) {
|
|
4052
|
+
const vp = this._scene.viewport;
|
|
4053
|
+
const current = vp.zoom || 1;
|
|
4054
|
+
const target = clampZoom(level);
|
|
4055
|
+
if (target === current || vp.size.width <= 0 || vp.size.height <= 0)
|
|
4056
|
+
return;
|
|
4057
|
+
this.zoomStep(target / current);
|
|
4058
|
+
}
|
|
3194
4059
|
resetZoom() {
|
|
3195
4060
|
const next = computeResetZoom(this._scene);
|
|
3196
4061
|
if (!next)
|
|
@@ -3232,6 +4097,20 @@ export class Editor {
|
|
|
3232
4097
|
this._scene = next;
|
|
3233
4098
|
this.notify();
|
|
3234
4099
|
}
|
|
4100
|
+
/**
|
|
4101
|
+
* "Back to content": jump to the element nearest the camera centre, keeping
|
|
4102
|
+
* the current zoom (it only drops when that element doesn't fit). Unlike
|
|
4103
|
+
* {@link zoomToFit} this neither shrinks a large board to a speck nor blows
|
|
4104
|
+
* a single small shape up to full screen. Groups are skipped (their own
|
|
4105
|
+
* bounds are empty — their children count). No-op on an empty scene.
|
|
4106
|
+
*/
|
|
4107
|
+
revealNearestContent(padding = 80) {
|
|
4108
|
+
const next = computeRevealNearest(this._scene, padding, (s) => s.type !== "group");
|
|
4109
|
+
if (!next)
|
|
4110
|
+
return;
|
|
4111
|
+
this._scene = next;
|
|
4112
|
+
this.notify();
|
|
4113
|
+
}
|
|
3235
4114
|
/**
|
|
3236
4115
|
* Select the nearest interactable top-level element in `direction` from the
|
|
3237
4116
|
* current selection's centre (or the viewport centre when nothing is
|
|
@@ -3314,6 +4193,137 @@ export class Editor {
|
|
|
3314
4193
|
snapSpacing() {
|
|
3315
4194
|
return resolveSnapSpacing();
|
|
3316
4195
|
}
|
|
4196
|
+
/**
|
|
4197
|
+
* Object snapping is a per-user preference; the suppress modifier
|
|
4198
|
+
* (Cmd/Ctrl) bypasses it for the current gesture like grid snapping.
|
|
4199
|
+
*/
|
|
4200
|
+
objectSnapActive() {
|
|
4201
|
+
return !this.interaction.snapSuppressed && this._preferences.snapObjects;
|
|
4202
|
+
}
|
|
4203
|
+
sizeSuggestActive() {
|
|
4204
|
+
return !this.interaction.snapSuppressed && this._preferences.suggestObjectSize;
|
|
4205
|
+
}
|
|
4206
|
+
/**
|
|
4207
|
+
* World AABBs of the shapes a gesture may snap to (reference rules): every
|
|
4208
|
+
* visible element on screen that is not being moved / resized, except
|
|
4209
|
+
* groups (no geometry of their own), brush strokes (freehand ink is never
|
|
4210
|
+
* a snap target), shapes rotated off the 90° grid, and shapes too small on
|
|
4211
|
+
* screen (`OBJECT_SNAP_MIN_SIZE_PX`). Returns nothing on a crowded view
|
|
4212
|
+
* (`OBJECT_SNAP_MAX_CANDIDATES`) so a huge board never stalls a drag.
|
|
4213
|
+
*/
|
|
4214
|
+
snapCandidates(moving) {
|
|
4215
|
+
const view = this.computeViewportWorld();
|
|
4216
|
+
const zoom = this._scene.viewport.zoom || 1;
|
|
4217
|
+
const minSize = OBJECT_SNAP_MIN_SIZE_PX / zoom;
|
|
4218
|
+
const out = [];
|
|
4219
|
+
for (const shape of this._scene.elements.values()) {
|
|
4220
|
+
if (moving.has(shape.id) || isGroup(shape) || isBrush(shape))
|
|
4221
|
+
continue;
|
|
4222
|
+
if (!isRightAngle(shape.rotation))
|
|
4223
|
+
continue;
|
|
4224
|
+
if (isElementHidden(this._scene, shape))
|
|
4225
|
+
continue;
|
|
4226
|
+
const b = getElementWorldBounds(shape);
|
|
4227
|
+
if (view && !B.intersects(view, b))
|
|
4228
|
+
continue;
|
|
4229
|
+
if (b.width < minSize && b.height < minSize)
|
|
4230
|
+
continue;
|
|
4231
|
+
out.push(b);
|
|
4232
|
+
if (out.length > OBJECT_SNAP_MAX_CANDIDATES)
|
|
4233
|
+
return [];
|
|
4234
|
+
}
|
|
4235
|
+
return out;
|
|
4236
|
+
}
|
|
4237
|
+
/** Brush strokes never snap (as movers or targets) — freehand ink has no meaningful edges. */
|
|
4238
|
+
snapsAsMover(id) {
|
|
4239
|
+
const shape = getElement(this._scene, id);
|
|
4240
|
+
return shape !== undefined && !isBrush(shape);
|
|
4241
|
+
}
|
|
4242
|
+
/**
|
|
4243
|
+
* Move-delta snapping: object snapping (edges / centres of nearby shapes)
|
|
4244
|
+
* wins when it lands; otherwise grid snapping. Records the guides for the
|
|
4245
|
+
* overlay either way.
|
|
4246
|
+
*/
|
|
4247
|
+
snapMoveDeltaFor(moving, bounds, delta) {
|
|
4248
|
+
this.interaction.snapGuides = [];
|
|
4249
|
+
if (this.objectSnapActive() && [...moving].some((id) => this.snapsAsMover(id))) {
|
|
4250
|
+
const threshold = OBJECT_SNAP_THRESHOLD_PX / (this._scene.viewport.zoom || 1);
|
|
4251
|
+
const r = snapMoveDeltaToObjects(bounds, delta, this.snapCandidates(moving), threshold);
|
|
4252
|
+
if (r.guides.length > 0) {
|
|
4253
|
+
this.interaction.snapGuides = r.guides;
|
|
4254
|
+
return r.delta;
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
4257
|
+
return this.snapActive() ? snapMoveDelta(bounds, delta, this.snapSpacing()) : delta;
|
|
4258
|
+
}
|
|
4259
|
+
/**
|
|
4260
|
+
* Group move: the press-time union AABB of the members (each member's
|
|
4261
|
+
* current geometry at its press-time position; brush strokes excluded)
|
|
4262
|
+
* snaps to nearby shapes by its frame EDGES only — a multi-selection has
|
|
4263
|
+
* no centre line, like the reference. Falls back to the grid
|
|
4264
|
+
* reference-point snap.
|
|
4265
|
+
*/
|
|
4266
|
+
snapGroupMoveDelta(origins, delta) {
|
|
4267
|
+
this.interaction.snapGuides = [];
|
|
4268
|
+
if (this.objectSnapActive()) {
|
|
4269
|
+
let union = null;
|
|
4270
|
+
let members = 0;
|
|
4271
|
+
for (const [id, position] of origins) {
|
|
4272
|
+
const shape = getElement(this._scene, id);
|
|
4273
|
+
if (!shape || isGroup(shape) || isBrush(shape))
|
|
4274
|
+
continue;
|
|
4275
|
+
members += 1;
|
|
4276
|
+
const b = getElementWorldBounds({ ...shape, position });
|
|
4277
|
+
union = union ? B.union(union, b) : b;
|
|
4278
|
+
}
|
|
4279
|
+
if (union) {
|
|
4280
|
+
const threshold = OBJECT_SNAP_THRESHOLD_PX / (this._scene.viewport.zoom || 1);
|
|
4281
|
+
const r = snapMoveDeltaToObjects(union, delta, this.snapCandidates(new Set(origins.keys())), threshold, { centerLines: members === 1 });
|
|
4282
|
+
if (r.guides.length > 0) {
|
|
4283
|
+
this.interaction.snapGuides = r.guides;
|
|
4284
|
+
return r.delta;
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4287
|
+
}
|
|
4288
|
+
return this.snapActive() ? snapGroupDelta(origins, delta, this.snapSpacing()) : delta;
|
|
4289
|
+
}
|
|
4290
|
+
/** Resize-delta counterpart of {@link snapMoveDeltaFor}; also sets `sizeMatch`. */
|
|
4291
|
+
snapResizeDeltaFor(moving, original, handle, delta) {
|
|
4292
|
+
this.interaction.snapGuides = [];
|
|
4293
|
+
this.interaction.sizeMatch = null;
|
|
4294
|
+
const alignEdges = this.objectSnapActive();
|
|
4295
|
+
const matchSizes = this.sizeSuggestActive();
|
|
4296
|
+
if ((alignEdges || matchSizes) && [...moving].some((id) => this.snapsAsMover(id))) {
|
|
4297
|
+
const zoom = this._scene.viewport.zoom || 1;
|
|
4298
|
+
const threshold = Math.max(OBJECT_SNAP_THRESHOLD_PX, SIZE_SUGGEST_THRESHOLD_PX) / zoom;
|
|
4299
|
+
const r = snapResizeDeltaToObjects(original, handle, delta, this.snapCandidates(moving), threshold, { alignEdges, matchSizes });
|
|
4300
|
+
if (r.guides.length > 0 || r.sizeMatch !== null) {
|
|
4301
|
+
this.interaction.snapGuides = r.guides;
|
|
4302
|
+
this.interaction.sizeMatch = r.sizeMatch;
|
|
4303
|
+
return r.delta;
|
|
4304
|
+
}
|
|
4305
|
+
}
|
|
4306
|
+
return this.snapActive() ? snapResizeDelta(original, handle, delta, this.snapSpacing()) : delta;
|
|
4307
|
+
}
|
|
4308
|
+
/** Publish the `W × H` readout for `bounds` when the preference is on. */
|
|
4309
|
+
setSizeReadout(bounds) {
|
|
4310
|
+
this.interaction.sizeReadout =
|
|
4311
|
+
bounds && this._preferences.showObjectSize
|
|
4312
|
+
? { bounds, width: bounds.width, height: bounds.height }
|
|
4313
|
+
: null;
|
|
4314
|
+
}
|
|
4315
|
+
/** Alignment guides of the current gesture tick (object snapping). */
|
|
4316
|
+
get snapGuides() {
|
|
4317
|
+
return this.interaction.snapGuides;
|
|
4318
|
+
}
|
|
4319
|
+
/** `W × H` readout of the shape being resized, when `showObjectSize` is on. */
|
|
4320
|
+
get sizeReadout() {
|
|
4321
|
+
return this.interaction.sizeReadout;
|
|
4322
|
+
}
|
|
4323
|
+
/** Bounds of the shape whose size the current resize matched, if any. */
|
|
4324
|
+
get sizeMatch() {
|
|
4325
|
+
return this.interaction.sizeMatch;
|
|
4326
|
+
}
|
|
3317
4327
|
/**
|
|
3318
4328
|
* Replace the entire scene (e.g. after `parseScene`). Clears history,
|
|
3319
4329
|
* selection and any open gesture. Use to load a saved document.
|
|
@@ -3323,7 +4333,9 @@ export class Editor {
|
|
|
3323
4333
|
this.gestureTx.cancel();
|
|
3324
4334
|
this.gestureTx = null;
|
|
3325
4335
|
}
|
|
3326
|
-
|
|
4336
|
+
// A saved start view is where a freshly opened document lands; a
|
|
4337
|
+
// peer update (`preserveHistory`) must not yank the local camera.
|
|
4338
|
+
this._scene = options.preserveHistory ? scene : withStartViewApplied(scene);
|
|
3327
4339
|
// Snap active layer back into the loaded scene's layer set.
|
|
3328
4340
|
if (!scene.layers.has(this._activeLayerId)) {
|
|
3329
4341
|
const first = scene.layers.keys().next().value;
|
|
@@ -3358,6 +4370,7 @@ export class Editor {
|
|
|
3358
4370
|
}
|
|
3359
4371
|
/** Detach all DOM listeners and stop the actor. */
|
|
3360
4372
|
dispose() {
|
|
4373
|
+
this.disposed = true;
|
|
3361
4374
|
this.cancelLongPress();
|
|
3362
4375
|
this.unbind();
|
|
3363
4376
|
this.actor.stop();
|
|
@@ -3410,10 +4423,10 @@ export class Editor {
|
|
|
3410
4423
|
// Right-click without a drag → trigger the context-menu listeners.
|
|
3411
4424
|
// Same payload as touch long-press so existing UI (e.g.
|
|
3412
4425
|
// `@react-ui/ContextMenu`) works without changes.
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
}
|
|
4426
|
+
this.fireContextPress({
|
|
4427
|
+
screenPoint: gesture.startPoint,
|
|
4428
|
+
worldPoint: this.screenToWorld(gesture.startPoint),
|
|
4429
|
+
});
|
|
3417
4430
|
}
|
|
3418
4431
|
else {
|
|
3419
4432
|
// Either it was a real drag, or Space + left drag. In both cases the
|
|
@@ -3424,9 +4437,48 @@ export class Editor {
|
|
|
3424
4437
|
// Pan over — recompute (→ "grab" if Space/hand still armed, else the
|
|
3425
4438
|
// idle hover cursor).
|
|
3426
4439
|
this.refreshCursor();
|
|
4440
|
+
// The gesture's end is itself a change: idle-gated observers (the
|
|
4441
|
+
// minimap) skip every pan notify while `panGesture` is set and rely on
|
|
4442
|
+
// this trailing one to repaint.
|
|
4443
|
+
this.notify();
|
|
3427
4444
|
}
|
|
3428
4445
|
isDrawingPhase(ctx) {
|
|
3429
|
-
return ctx.mode === "draw-rect" ||
|
|
4446
|
+
return (ctx.mode === "draw-rect" ||
|
|
4447
|
+
ctx.mode === "draw-ellipse" ||
|
|
4448
|
+
ctx.mode === "draw-frame" ||
|
|
4449
|
+
ctx.mode === "draw-edge");
|
|
4450
|
+
}
|
|
4451
|
+
/**
|
|
4452
|
+
* Context press (right-click / touch long-press): first make the selection
|
|
4453
|
+
* match what was pressed, then notify the `onLongPress` listeners so the
|
|
4454
|
+
* menu they open describes that point.
|
|
4455
|
+
* - On an unselected element / link → it becomes the selection.
|
|
4456
|
+
* - On a selected element (or its chrome: handles, rotate grip) → keep.
|
|
4457
|
+
* - On empty canvas → clear elements + links, so the menu is the canvas
|
|
4458
|
+
* menu rather than the previous selection's.
|
|
4459
|
+
*/
|
|
4460
|
+
fireContextPress(payload) {
|
|
4461
|
+
this.routeContextPress(payload.worldPoint);
|
|
4462
|
+
for (const fn of this.longPressListeners)
|
|
4463
|
+
fn(payload);
|
|
4464
|
+
}
|
|
4465
|
+
routeContextPress(worldPoint) {
|
|
4466
|
+
const target = this.hitTest(worldPoint);
|
|
4467
|
+
if (target.kind === "element") {
|
|
4468
|
+
if (!this._selection.has(target.id)) {
|
|
4469
|
+
this.applyEmit({ type: "SELECT_REPLACE", id: target.id });
|
|
4470
|
+
}
|
|
4471
|
+
}
|
|
4472
|
+
else if (target.kind === "link") {
|
|
4473
|
+
if (!this._selectedLinks.has(target.id)) {
|
|
4474
|
+
this.applyEmit({ type: "SELECT_EDGE_REPLACE", id: target.id });
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
4477
|
+
else if (target.kind === "empty") {
|
|
4478
|
+
if (this._selection.size > 0 || this._selectedLinks.size > 0) {
|
|
4479
|
+
this.applyEmit({ type: "SELECT_CLEAR" });
|
|
4480
|
+
}
|
|
4481
|
+
}
|
|
3430
4482
|
}
|
|
3431
4483
|
// --- Long-press ---
|
|
3432
4484
|
startLongPress(screenPoint) {
|
|
@@ -3462,8 +4514,8 @@ export class Editor {
|
|
|
3462
4514
|
* `undefined` → dropped on empty space (the end stays a free point).
|
|
3463
4515
|
*/
|
|
3464
4516
|
linkAttachTargetAt(worldPoint) {
|
|
3465
|
-
const shape = this.acceleratedElementAt(worldPoint);
|
|
3466
|
-
if (shape
|
|
4517
|
+
const shape = this.acceleratedElementAt(worldPoint, (s) => this.isElementInteractable(s));
|
|
4518
|
+
if (shape) {
|
|
3467
4519
|
const target = this.promoteToGroupRoot(shape);
|
|
3468
4520
|
return { kind: "element", id: target.id, bounds: getElementWorldBounds(target) };
|
|
3469
4521
|
}
|
|
@@ -3486,8 +4538,9 @@ export class Editor {
|
|
|
3486
4538
|
hitAnnotation: (p) => this.hitAnnotation(p),
|
|
3487
4539
|
selectionIsAspectLocked: () => this.selectionIsAspectLocked(),
|
|
3488
4540
|
combinedSelectionBounds: () => this.combinedSelectionBounds(),
|
|
3489
|
-
acceleratedElementAt: (p) => this.acceleratedElementAt(p),
|
|
4541
|
+
acceleratedElementAt: (p, accept) => this.acceleratedElementAt(p, accept),
|
|
3490
4542
|
isElementInteractable: (s) => this.isElementInteractable(s),
|
|
4543
|
+
isElementManipulable: (s) => this.isElementManipulable(s),
|
|
3491
4544
|
isLayerLocked: (id) => this.isLayerLocked(id),
|
|
3492
4545
|
promoteToGroupRoot: (s) => this.promoteToGroupRoot(s),
|
|
3493
4546
|
});
|
|
@@ -3542,10 +4595,12 @@ export class Editor {
|
|
|
3542
4595
|
return false;
|
|
3543
4596
|
if (isElementHidden(this._scene, shape))
|
|
3544
4597
|
return false;
|
|
3545
|
-
//
|
|
3546
|
-
//
|
|
3547
|
-
//
|
|
3548
|
-
//
|
|
4598
|
+
// A locked element is click-through: pointer hits pass to the shapes
|
|
4599
|
+
// beneath it (the hit-test scans past rejected candidates). Unlocking
|
|
4600
|
+
// goes through the context menu (`lockedElementAt` + `unlockElement`),
|
|
4601
|
+
// not through selection.
|
|
4602
|
+
if (isElementLocked(this._scene, shape))
|
|
4603
|
+
return false;
|
|
3549
4604
|
return true;
|
|
3550
4605
|
}
|
|
3551
4606
|
/**
|
|
@@ -3590,8 +4645,56 @@ export class Editor {
|
|
|
3590
4645
|
tx.add(r.patch);
|
|
3591
4646
|
}
|
|
3592
4647
|
tx.commit();
|
|
4648
|
+
// Locked elements are click-through (not selectable) — keeping them
|
|
4649
|
+
// selected would leave a live toolbar on an element clicks can no
|
|
4650
|
+
// longer reach. Locking therefore drops the selection.
|
|
4651
|
+
if (anyUnlocked)
|
|
4652
|
+
this._selection = Selection.EMPTY;
|
|
3593
4653
|
this.notify();
|
|
3594
4654
|
}
|
|
4655
|
+
/**
|
|
4656
|
+
* Topmost locked (own or inherited `locked`) shape at `worldPoint`, on a
|
|
4657
|
+
* visible, unlocked layer. Locked shapes are click-through for normal
|
|
4658
|
+
* interaction, so this is the dedicated lookup for affordances that must
|
|
4659
|
+
* still find them — the context menu's Unlock entry.
|
|
4660
|
+
*/
|
|
4661
|
+
lockedElementAt(worldPoint) {
|
|
4662
|
+
const shape = this.acceleratedElementAt(worldPoint, (s) => !this.isLayerLocked(s.layerId) &&
|
|
4663
|
+
!isElementHidden(this._scene, s) &&
|
|
4664
|
+
isElementLocked(this._scene, s));
|
|
4665
|
+
return shape ?? null;
|
|
4666
|
+
}
|
|
4667
|
+
/**
|
|
4668
|
+
* Clear the `locked` flag on the shape that carries it. When the flag
|
|
4669
|
+
* lives on an ancestor (group lock propagation), the closest locked
|
|
4670
|
+
* ancestor is unlocked instead, so the whole locked unit is released.
|
|
4671
|
+
* Selects the unlocked shape afterwards — the natural next step after
|
|
4672
|
+
* an unlock is editing it. One undo step.
|
|
4673
|
+
*/
|
|
4674
|
+
unlockElement(id) {
|
|
4675
|
+
if (this.readOnly)
|
|
4676
|
+
return;
|
|
4677
|
+
let target = getElement(this._scene, id);
|
|
4678
|
+
if (!target || !isElementLocked(this._scene, target))
|
|
4679
|
+
return;
|
|
4680
|
+
// Walk up to the shape that actually carries the flag.
|
|
4681
|
+
while (target.locked !== true && target.parentId !== undefined) {
|
|
4682
|
+
const parent = getElement(this._scene, target.parentId);
|
|
4683
|
+
if (!parent)
|
|
4684
|
+
break;
|
|
4685
|
+
target = parent;
|
|
4686
|
+
}
|
|
4687
|
+
if (target.locked !== true)
|
|
4688
|
+
return;
|
|
4689
|
+
const r = updateElement(this._scene, target.id, (s) => {
|
|
4690
|
+
const copy = { ...s };
|
|
4691
|
+
delete copy.locked;
|
|
4692
|
+
return copy;
|
|
4693
|
+
});
|
|
4694
|
+
this._scene = r.scene;
|
|
4695
|
+
this._history.push(r.patch);
|
|
4696
|
+
this.setSelection([target.id]);
|
|
4697
|
+
}
|
|
3595
4698
|
/**
|
|
3596
4699
|
* Promote a hit shape to the topmost ancestor whose group we have NOT
|
|
3597
4700
|
* "entered" yet. Only **`group`**-typed parents promote — containers
|
|
@@ -3754,11 +4857,11 @@ export class Editor {
|
|
|
3754
4857
|
* scene-identity. Scene operations replace `_scene` (immutable patches),
|
|
3755
4858
|
* so reference-equality is a sufficient invalidation signal.
|
|
3756
4859
|
*/
|
|
3757
|
-
acceleratedElementAt(worldPoint) {
|
|
4860
|
+
acceleratedElementAt(worldPoint, accept) {
|
|
3758
4861
|
if (this._scene.elements.size < LARGE_SCENE_HIT_THRESHOLD) {
|
|
3759
|
-
return getElementAt(this._scene, worldPoint);
|
|
4862
|
+
return getElementAt(this._scene, worldPoint, accept);
|
|
3760
4863
|
}
|
|
3761
|
-
return getElementAtIndexed(this._scene, this.ensureSpatialIndex(), worldPoint);
|
|
4864
|
+
return getElementAtIndexed(this._scene, this.ensureSpatialIndex(), worldPoint, accept);
|
|
3762
4865
|
}
|
|
3763
4866
|
/**
|
|
3764
4867
|
* Build (or return the cached) `SpatialGrid` for the current scene.
|
|
@@ -3801,9 +4904,7 @@ export class Editor {
|
|
|
3801
4904
|
*/
|
|
3802
4905
|
routeIsolationClick(clickEffect, worldPoint) {
|
|
3803
4906
|
const now = performance.now();
|
|
3804
|
-
const isDouble =
|
|
3805
|
-
this.interaction.lastClickWorldPoint !== null &&
|
|
3806
|
-
vec2.distance(this.interaction.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
|
|
4907
|
+
const isDouble = this.interaction.isDoubleClickAt(worldPoint, now);
|
|
3807
4908
|
this.interaction.lastClickAt = now;
|
|
3808
4909
|
this.interaction.lastClickWorldPoint = worldPoint;
|
|
3809
4910
|
// Double-click the frame HEADER (label strip above the body) → rename.
|
|
@@ -3852,6 +4953,12 @@ export class Editor {
|
|
|
3852
4953
|
const raw = this.acceleratedElementAt(worldPoint);
|
|
3853
4954
|
if (raw !== undefined && isText(raw)) {
|
|
3854
4955
|
this.beginTextEdit(raw.id);
|
|
4956
|
+
// Caret lands where the user double-clicked, not at the text end.
|
|
4957
|
+
// Collapsed selection WITHOUT a drag anchor — the double-click's
|
|
4958
|
+
// own pointer movement must not start a drag-select.
|
|
4959
|
+
const caretIdx = this.caretIndexAtWorldPoint(worldPoint);
|
|
4960
|
+
if (caretIdx !== null)
|
|
4961
|
+
this.setEditingSelection(caretIdx, caretIdx);
|
|
3855
4962
|
return true;
|
|
3856
4963
|
}
|
|
3857
4964
|
if (raw !== undefined && isFrame(raw)) {
|
|
@@ -3871,6 +4978,18 @@ export class Editor {
|
|
|
3871
4978
|
this.notify();
|
|
3872
4979
|
return true;
|
|
3873
4980
|
}
|
|
4981
|
+
// No group to drill into — double-click on a labelable shape
|
|
4982
|
+
// opens its embedded label editor (creating an empty label on
|
|
4983
|
+
// the first edit).
|
|
4984
|
+
if (canCarryLabel(raw)) {
|
|
4985
|
+
this.beginTextEdit(raw.id);
|
|
4986
|
+
// Same as text elements: the caret starts at the click point —
|
|
4987
|
+
// important for long labels whose tail is clipped by the shape.
|
|
4988
|
+
const labelCaretIdx = this.caretIndexAtWorldPoint(worldPoint);
|
|
4989
|
+
if (labelCaretIdx !== null)
|
|
4990
|
+
this.setEditingSelection(labelCaretIdx, labelCaretIdx);
|
|
4991
|
+
return true;
|
|
4992
|
+
}
|
|
3874
4993
|
}
|
|
3875
4994
|
}
|
|
3876
4995
|
return false;
|
|
@@ -4066,7 +5185,9 @@ export class Editor {
|
|
|
4066
5185
|
return;
|
|
4067
5186
|
// Shift constrains the drag to one axis before snapping.
|
|
4068
5187
|
const moved = this.interaction.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
|
|
4069
|
-
|
|
5188
|
+
// Rotated shapes keep their AABB-based snapping (edges still align in
|
|
5189
|
+
// world space); the object pass is skipped only when nothing is nearby.
|
|
5190
|
+
const d = this.snapMoveDeltaFor(new Set([id]), originalBounds, moved);
|
|
4070
5191
|
const patch = computeElementMovePatch(this._scene, id, d, originalBounds);
|
|
4071
5192
|
if (!patch)
|
|
4072
5193
|
return;
|
|
@@ -4079,9 +5200,7 @@ export class Editor {
|
|
|
4079
5200
|
return;
|
|
4080
5201
|
// Shift constrains the drag to one axis before snapping.
|
|
4081
5202
|
const moved = this.interaction.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
|
|
4082
|
-
const d = this.
|
|
4083
|
-
? snapGroupDelta(this.groupMoveOrigin, moved, this.snapSpacing())
|
|
4084
|
-
: moved;
|
|
5203
|
+
const d = this.snapGroupMoveDelta(this.groupMoveOrigin, moved);
|
|
4085
5204
|
const patches = computeGroupMovePatches(this._scene, this.groupMoveOrigin, d);
|
|
4086
5205
|
for (const patch of patches) {
|
|
4087
5206
|
this._scene = apply(this._scene, patch);
|
|
@@ -4174,6 +5293,10 @@ export class Editor {
|
|
|
4174
5293
|
// repaint when the entered-group identity changes between frames.
|
|
4175
5294
|
if (this.lastRenderedEnteredGroup !== this._enteredGroup)
|
|
4176
5295
|
return null;
|
|
5296
|
+
// Hover transition on a sticky toggles the "+" chrome without a scene
|
|
5297
|
+
// change — force a repaint frame for it.
|
|
5298
|
+
if (this.lastRenderedHoveredSticky !== this._hoveredStickyId)
|
|
5299
|
+
return null;
|
|
4177
5300
|
// Eraser just STOPPED (the active-stroke guard above already returned, so
|
|
4178
5301
|
// `eraseStroke` is null here): on an Esc-cancel the marked shapes un-dim
|
|
4179
5302
|
// without a scene change — commit changes the scene and is caught by the
|
|
@@ -4274,9 +5397,7 @@ export class Editor {
|
|
|
4274
5397
|
applyGroupResize(handle, delta, originalBounds) {
|
|
4275
5398
|
if (!this.groupResizeOrigin)
|
|
4276
5399
|
return;
|
|
4277
|
-
const d = this.
|
|
4278
|
-
? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
|
|
4279
|
-
: delta;
|
|
5400
|
+
const d = this.snapResizeDeltaFor(new Set(this.groupResizeOrigin.elements.keys()), originalBounds, handle, delta);
|
|
4280
5401
|
const result = computeGroupResizePatches(this._scene, this.groupResizeOrigin, handle, d, originalBounds,
|
|
4281
5402
|
// Aspect-locked when the selection type demands it (images / groups) or
|
|
4282
5403
|
// the user holds Shift for this gesture.
|
|
@@ -4284,6 +5405,7 @@ export class Editor {
|
|
|
4284
5405
|
this._scene = result.scene;
|
|
4285
5406
|
for (const patch of result.patches)
|
|
4286
5407
|
this.recordGesturePatch(patch);
|
|
5408
|
+
this.setSizeReadout(this.combinedSelectionBounds());
|
|
4287
5409
|
this.notify();
|
|
4288
5410
|
}
|
|
4289
5411
|
applyResize(id, handle, delta, originalBounds) {
|
|
@@ -4301,12 +5423,11 @@ export class Editor {
|
|
|
4301
5423
|
return;
|
|
4302
5424
|
this._scene = result.scene;
|
|
4303
5425
|
this.recordGesturePatch(result.patch);
|
|
5426
|
+
this.setSizeReadout(this.resizedBounds(id));
|
|
4304
5427
|
this.notify();
|
|
4305
5428
|
return;
|
|
4306
5429
|
}
|
|
4307
|
-
const d = this.
|
|
4308
|
-
? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
|
|
4309
|
-
: delta;
|
|
5430
|
+
const d = this.snapResizeDeltaFor(new Set([id]), originalBounds, handle, delta);
|
|
4310
5431
|
// Text: aspect-locked font scaling. Snapshot the pristine shape on
|
|
4311
5432
|
// the gesture's first tick so the scale base never compounds.
|
|
4312
5433
|
if (shape !== undefined && isText(shape)) {
|
|
@@ -4322,6 +5443,7 @@ export class Editor {
|
|
|
4322
5443
|
return;
|
|
4323
5444
|
this._scene = result.scene;
|
|
4324
5445
|
this.recordGesturePatch(result.patch);
|
|
5446
|
+
this.setSizeReadout(this.resizedBounds(id));
|
|
4325
5447
|
this.notify();
|
|
4326
5448
|
return;
|
|
4327
5449
|
}
|
|
@@ -4330,12 +5452,32 @@ export class Editor {
|
|
|
4330
5452
|
return;
|
|
4331
5453
|
this._scene = result.scene;
|
|
4332
5454
|
this.recordGesturePatch(result.patch);
|
|
5455
|
+
this.setSizeReadout(this.resizedBounds(id));
|
|
4333
5456
|
this.notify();
|
|
4334
5457
|
}
|
|
5458
|
+
/** World AABB of `id` after a resize tick (null when the shape is gone). */
|
|
5459
|
+
resizedBounds(id) {
|
|
5460
|
+
const shape = getElement(this._scene, id);
|
|
5461
|
+
return shape ? getElementWorldBounds(shape) : null;
|
|
5462
|
+
}
|
|
5463
|
+
/**
|
|
5464
|
+
* Snap freshly-drawn bounds onto the grid when snapping is active.
|
|
5465
|
+
* Shared by the live rubber-band preview and the final CREATE so the
|
|
5466
|
+
* preview shows exactly where the shape will land (parity with how
|
|
5467
|
+
* move / resize snap live during the gesture).
|
|
5468
|
+
*/
|
|
5469
|
+
snapCreateBoundsIfActive(bounds) {
|
|
5470
|
+
return this.snapActive() ? snapCreateBounds(bounds, this.snapSpacing()) : bounds;
|
|
5471
|
+
}
|
|
4335
5472
|
applyCreate(kind, bounds) {
|
|
4336
5473
|
const id = newElementId(++this.nextId);
|
|
4337
|
-
const b = this.
|
|
4338
|
-
|
|
5474
|
+
const b = this.snapCreateBoundsIfActive(bounds);
|
|
5475
|
+
// The machine only knows the MODE ("rect" for draw-rect); the flyout
|
|
5476
|
+
// may have armed a diamond / triangle variant on top of it.
|
|
5477
|
+
const effectiveKind = kind === "rect" && this._drawShapeKind !== "rect" && this._drawShapeKind !== "ellipse"
|
|
5478
|
+
? this._drawShapeKind
|
|
5479
|
+
: kind;
|
|
5480
|
+
const result = computeCreateElement(this._scene, effectiveKind, b, id, this._activeLayerId, () => this.nextFrameName());
|
|
4339
5481
|
this._scene = result.scene;
|
|
4340
5482
|
this._selection = Selection.single(id);
|
|
4341
5483
|
// CREATE is a single-shot operation, not part of a multi-tick gesture.
|
|
@@ -4376,7 +5518,7 @@ export class Editor {
|
|
|
4376
5518
|
const from = this.snapLinkEndpoint(emit.fromElement, emit.fromPoint);
|
|
4377
5519
|
const to = this.snapLinkEndpoint(emit.toElement, emit.toPoint);
|
|
4378
5520
|
const id = newLinkId(++this.nextId);
|
|
4379
|
-
const result = computeCreateLink(this._scene, from, to, id, this._activeLayerId);
|
|
5521
|
+
const result = computeCreateLink(this._scene, from, to, id, this._activeLayerId, this._linkDrawPreset !== null ? LINK_DRAW_PRESETS[this._linkDrawPreset] : undefined);
|
|
4380
5522
|
this._scene = result.scene;
|
|
4381
5523
|
this._history.push(result.patch);
|
|
4382
5524
|
this.edgePreview = null;
|
|
@@ -4477,7 +5619,7 @@ export class Editor {
|
|
|
4477
5619
|
// The wrappers here own the side effects (`_selectedLink`
|
|
4478
5620
|
// clearing, notify).
|
|
4479
5621
|
applySelectByBounds(bounds, mode) {
|
|
4480
|
-
const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode);
|
|
5622
|
+
const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode, (shape) => this.promoteToGroupRoot(shape));
|
|
4481
5623
|
const nextLinks = selectLinksByBoundsLivePure(this._scene, mode === "add" ? this._selectedLinks : LinkSelection.EMPTY, (id) => this.isLayerLocked(id), bounds, mode);
|
|
4482
5624
|
const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
|
|
4483
5625
|
this._selectedLinks = nextLinks;
|
|
@@ -4490,7 +5632,7 @@ export class Editor {
|
|
|
4490
5632
|
}
|
|
4491
5633
|
applyLassoLiveSelection(bounds, mode) {
|
|
4492
5634
|
const base = this.interaction.lassoBaseSelection ?? Selection.EMPTY;
|
|
4493
|
-
const next = selectByBoundsLivePure(this._scene, base, (id) => this.isLayerLocked(id), bounds, mode);
|
|
5635
|
+
const next = selectByBoundsLivePure(this._scene, base, (id) => this.isLayerLocked(id), bounds, mode, (shape) => this.promoteToGroupRoot(shape));
|
|
4494
5636
|
const linkBase = this.interaction.lassoBaseLinks ?? LinkSelection.EMPTY;
|
|
4495
5637
|
const nextLinks = selectLinksByBoundsLivePure(this._scene, linkBase, (id) => this.isLayerLocked(id), bounds, mode);
|
|
4496
5638
|
const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
|
|
@@ -4687,9 +5829,12 @@ export class Editor {
|
|
|
4687
5829
|
}
|
|
4688
5830
|
applyLinkPreview(fromElement, fromPoint, toPoint) {
|
|
4689
5831
|
const ep = computeLinkPreviewEndpoints(this._scene, fromElement, fromPoint, toPoint);
|
|
4690
|
-
// Match the preview to the connector that will be committed:
|
|
4691
|
-
//
|
|
4692
|
-
|
|
5832
|
+
// Match the preview to the connector that will be committed: elbow
|
|
5833
|
+
// presets/defaults draw the orthogonal route, straight presets a line.
|
|
5834
|
+
const previewRouting = this._linkDrawPreset !== null
|
|
5835
|
+
? LINK_DRAW_PRESETS[this._linkDrawPreset].routing
|
|
5836
|
+
: DEFAULT_LINK_ROUTING;
|
|
5837
|
+
if (previewRouting === "orthogonal") {
|
|
4693
5838
|
const hit = this.hitTest(ep.to);
|
|
4694
5839
|
const toElement = hit.kind === "element" ? hit.id : null;
|
|
4695
5840
|
const points = routeElbowPreview(this._scene, fromElement, ep.from, toElement, ep.to);
|
|
@@ -4713,6 +5858,7 @@ export class Editor {
|
|
|
4713
5858
|
}
|
|
4714
5859
|
commitGesture() {
|
|
4715
5860
|
this.interaction.resizeOriginElement = null;
|
|
5861
|
+
this.interaction.clearSnapAssists();
|
|
4716
5862
|
this.rotateGestureOrigin = null;
|
|
4717
5863
|
this.gestures.commit();
|
|
4718
5864
|
this.gestureStartScene = null;
|
|
@@ -4758,6 +5904,7 @@ export class Editor {
|
|
|
4758
5904
|
}
|
|
4759
5905
|
cancelGesture() {
|
|
4760
5906
|
this.interaction.resizeOriginElement = null;
|
|
5907
|
+
this.interaction.clearSnapAssists();
|
|
4761
5908
|
this.interaction.rotateGestureOrigin = null;
|
|
4762
5909
|
this.gestures.cancel();
|
|
4763
5910
|
// Roll the scene back to the pre-gesture snapshot — cancelling the history
|
|
@@ -4801,6 +5948,13 @@ export class Editor {
|
|
|
4801
5948
|
* into a single render per frame.
|
|
4802
5949
|
*/
|
|
4803
5950
|
renderRafId = null;
|
|
5951
|
+
/**
|
|
5952
|
+
* Set by {@link dispose}. Render scheduling bails out when set:
|
|
5953
|
+
* async completions (image decode, font load, wasm init) can resolve
|
|
5954
|
+
* after the host tears the editor down (e.g. a runtime backend
|
|
5955
|
+
* switch) and must not paint onto disposed render targets.
|
|
5956
|
+
*/
|
|
5957
|
+
disposed = false;
|
|
4804
5958
|
/** Unsubscribe for the animation-content-ready listener (decode → re-render). */
|
|
4805
5959
|
animationContentOff = null;
|
|
4806
5960
|
/**
|
|
@@ -4816,6 +5970,8 @@ export class Editor {
|
|
|
4816
5970
|
* compare bitmap output after a mutation).
|
|
4817
5971
|
*/
|
|
4818
5972
|
scheduleRender() {
|
|
5973
|
+
if (this.disposed)
|
|
5974
|
+
return; // async completions (image decode, font load) may outlive the editor
|
|
4819
5975
|
if (this.renderRafId !== null)
|
|
4820
5976
|
return;
|
|
4821
5977
|
if (typeof requestAnimationFrame === "undefined") {
|
|
@@ -4841,6 +5997,8 @@ export class Editor {
|
|
|
4841
5997
|
* Normal interactive flows should let `scheduleRender` do its job.
|
|
4842
5998
|
*/
|
|
4843
5999
|
forceRender() {
|
|
6000
|
+
if (this.disposed)
|
|
6001
|
+
return;
|
|
4844
6002
|
if (this.renderRafId !== null && typeof cancelAnimationFrame !== "undefined") {
|
|
4845
6003
|
cancelAnimationFrame(this.renderRafId);
|
|
4846
6004
|
this.renderRafId = null;
|
|
@@ -5014,10 +6172,17 @@ export class Editor {
|
|
|
5014
6172
|
peerSelections: this._peerSelections,
|
|
5015
6173
|
debugHitZones: this.debugHitZones,
|
|
5016
6174
|
readOnly: this._readOnly,
|
|
6175
|
+
hoveredStickyId: this._hoveredStickyId,
|
|
6176
|
+
linkDrawOverrides: this._linkDrawPreset !== null ? LINK_DRAW_PRESETS[this._linkDrawPreset] : undefined,
|
|
5017
6177
|
groupMoveOrigin: this.groupMoveOrigin,
|
|
5018
6178
|
aspectLocked: this.selectionIsAspectLocked(),
|
|
5019
6179
|
combinedSelectionBounds: this.combinedSelectionBounds(),
|
|
5020
6180
|
editingText: this.editingTextOverlay(),
|
|
6181
|
+
snapGuides: this.interaction.snapGuides,
|
|
6182
|
+
sizeReadout: this.interaction.sizeReadout,
|
|
6183
|
+
sizeMatch: this.interaction.sizeMatch,
|
|
6184
|
+
showDistances: this._preferences.showObjectSize,
|
|
6185
|
+
isElementManipulable: (s) => this.isElementManipulable(s),
|
|
5021
6186
|
previewClickCreate: (fromElement, anchorName) => this.previewClickCreate(fromElement, anchorName),
|
|
5022
6187
|
isPlaybackPaused: (id) => this.isPlaybackPaused(id),
|
|
5023
6188
|
};
|
|
@@ -5038,6 +6203,7 @@ export class Editor {
|
|
|
5038
6203
|
// and, on the tile-cache path, clear the consumed dirty set.
|
|
5039
6204
|
this.lastRenderedScene = this._scene;
|
|
5040
6205
|
this.lastRenderedEnteredGroup = this._enteredGroup;
|
|
6206
|
+
this.lastRenderedHoveredSticky = this._hoveredStickyId;
|
|
5041
6207
|
this.lastRenderedEraseActive = snapshot.eraseActive;
|
|
5042
6208
|
// The forced erase repaint (if any) has now happened — later idle frames
|
|
5043
6209
|
// (cursor moving / trail fading) skip the full main pass until the next cut.
|