@oh-just-another/state 0.62.0 → 0.64.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 +83 -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 +320 -20
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +1324 -73
- 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,654 @@ 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
|
+
* Text-carrier routing for mixed selections: merge a partial text style
|
|
2722
|
+
* into the element style of text elements and into the label style of
|
|
2723
|
+
* labelable shapes (seeding the label when missing); other elements are
|
|
2724
|
+
* skipped. One undo step. The selection toolbar writes through this so a
|
|
2725
|
+
* shape + text selection shares one text-colour / decoration control
|
|
2726
|
+
* instead of choosing between `updateStyle` and `updateLabelStyle`.
|
|
2727
|
+
*/
|
|
2728
|
+
updateTextStyle(ids, partial) {
|
|
2729
|
+
if (this.readOnly)
|
|
2730
|
+
return;
|
|
2731
|
+
const tx = this._history.transaction();
|
|
2732
|
+
let changed = false;
|
|
2733
|
+
for (const id of ids) {
|
|
2734
|
+
const shape = getElement(this._scene, id);
|
|
2735
|
+
if (shape === undefined)
|
|
2736
|
+
continue;
|
|
2737
|
+
let r;
|
|
2738
|
+
if (isText(shape)) {
|
|
2739
|
+
r = updateElement(this._scene, id, (raw) => ({
|
|
2740
|
+
...raw,
|
|
2741
|
+
style: { ...raw.style, ...partial },
|
|
2742
|
+
}));
|
|
2743
|
+
}
|
|
2744
|
+
else if (withLabel(shape).label !== undefined) {
|
|
2745
|
+
r = updateElement(this._scene, id, (raw) => {
|
|
2746
|
+
const l = withLabel(raw);
|
|
2747
|
+
return l.label === undefined
|
|
2748
|
+
? l
|
|
2749
|
+
: { ...l, label: { ...l.label, style: { ...l.label.style, ...partial } } };
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
else {
|
|
2753
|
+
continue;
|
|
2754
|
+
}
|
|
2755
|
+
this._scene = r.scene;
|
|
2756
|
+
tx.add(r.patch);
|
|
2757
|
+
changed = true;
|
|
2758
|
+
}
|
|
2759
|
+
if (!changed)
|
|
2760
|
+
return;
|
|
2761
|
+
tx.commit();
|
|
2762
|
+
this.notify();
|
|
2763
|
+
}
|
|
2764
|
+
/**
|
|
2765
|
+
* Merge font family / size into the embedded label. Picking an
|
|
2766
|
+
* explicit `fontSize` also leaves auto-fit mode (reference behaviour —
|
|
2767
|
+
* a concrete size wins over the automatic one). One undo step.
|
|
2768
|
+
*/
|
|
2769
|
+
updateLabelProps(ids, partial) {
|
|
2770
|
+
if (this.readOnly)
|
|
2771
|
+
return;
|
|
2772
|
+
const tx = this._history.transaction();
|
|
2773
|
+
let changed = false;
|
|
2774
|
+
for (const id of ids) {
|
|
2775
|
+
const shape = getElement(this._scene, id);
|
|
2776
|
+
if (shape === undefined || withLabel(shape).label === undefined)
|
|
2777
|
+
continue;
|
|
2778
|
+
const r = updateElement(this._scene, id, (raw) => {
|
|
2779
|
+
const s = withLabel(raw);
|
|
2780
|
+
if (s.label === undefined)
|
|
2781
|
+
return s;
|
|
2782
|
+
const label = { ...s.label, ...partial };
|
|
2783
|
+
if (partial.fontSize !== undefined)
|
|
2784
|
+
delete label.autoFit;
|
|
2785
|
+
return { ...s, label };
|
|
2786
|
+
});
|
|
2787
|
+
this._scene = r.scene;
|
|
2788
|
+
tx.add(r.patch);
|
|
2789
|
+
changed = true;
|
|
2790
|
+
}
|
|
2791
|
+
if (!changed)
|
|
2792
|
+
return;
|
|
2793
|
+
tx.commit();
|
|
2794
|
+
this.notify();
|
|
2795
|
+
}
|
|
2796
|
+
/** Toggle auto-fit label sizing (sticky notes). One undo step. */
|
|
2797
|
+
setLabelAutoFit(ids, on) {
|
|
2798
|
+
if (this.readOnly)
|
|
2799
|
+
return;
|
|
2800
|
+
const tx = this._history.transaction();
|
|
2801
|
+
let changed = false;
|
|
2802
|
+
for (const id of ids) {
|
|
2803
|
+
const shape = getElement(this._scene, id);
|
|
2804
|
+
if (shape === undefined || withLabel(shape).label === undefined)
|
|
2805
|
+
continue;
|
|
2806
|
+
const r = updateElement(this._scene, id, (raw) => {
|
|
2807
|
+
const s = withLabel(raw);
|
|
2808
|
+
if (s.label === undefined)
|
|
2809
|
+
return s;
|
|
2810
|
+
const label = { ...s.label };
|
|
2811
|
+
if (on)
|
|
2812
|
+
label.autoFit = true;
|
|
2813
|
+
else
|
|
2814
|
+
delete label.autoFit;
|
|
2815
|
+
return { ...s, label };
|
|
2816
|
+
});
|
|
2817
|
+
this._scene = r.scene;
|
|
2818
|
+
tx.add(r.patch);
|
|
2819
|
+
changed = true;
|
|
2820
|
+
}
|
|
2821
|
+
if (!changed)
|
|
2822
|
+
return;
|
|
2823
|
+
tx.commit();
|
|
2824
|
+
this.notify();
|
|
2825
|
+
}
|
|
2826
|
+
/**
|
|
2827
|
+
* Resize sticky notes to a square preset side (`STICKY_SIZE_PRESETS`).
|
|
2828
|
+
* One undo step; non-sticky ids are skipped.
|
|
2829
|
+
*/
|
|
2830
|
+
setStickySize(ids, side) {
|
|
2831
|
+
if (this.readOnly)
|
|
2832
|
+
return;
|
|
2833
|
+
const tx = this._history.transaction();
|
|
2834
|
+
let changed = false;
|
|
2835
|
+
for (const id of ids) {
|
|
2836
|
+
const shape = getElement(this._scene, id);
|
|
2837
|
+
if (!shape || !isSticky(shape))
|
|
2838
|
+
continue;
|
|
2839
|
+
const r = updateElement(this._scene, id, (s) => ({ ...s, width: side, height: side }));
|
|
2840
|
+
this._scene = r.scene;
|
|
2841
|
+
tx.add(r.patch);
|
|
2842
|
+
changed = true;
|
|
2843
|
+
}
|
|
2844
|
+
if (!changed)
|
|
2845
|
+
return;
|
|
2846
|
+
tx.commit();
|
|
2847
|
+
this.notify();
|
|
2848
|
+
}
|
|
2849
|
+
/**
|
|
2850
|
+
* Toggle the author-name strip on sticky notes. Turning it on fills a
|
|
2851
|
+
* missing `authorName` from the editor's comment author (the local
|
|
2852
|
+
* user). One undo step.
|
|
2853
|
+
*/
|
|
2854
|
+
toggleStickyAuthor(ids) {
|
|
2855
|
+
if (this.readOnly)
|
|
2856
|
+
return;
|
|
2857
|
+
const tx = this._history.transaction();
|
|
2858
|
+
let changed = false;
|
|
2859
|
+
for (const id of ids) {
|
|
2860
|
+
const shape = getElement(this._scene, id);
|
|
2861
|
+
if (!shape || !isSticky(shape))
|
|
2862
|
+
continue;
|
|
2863
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
2864
|
+
const sticky = s;
|
|
2865
|
+
const showAuthor = sticky.showAuthor !== true;
|
|
2866
|
+
return {
|
|
2867
|
+
...s,
|
|
2868
|
+
showAuthor,
|
|
2869
|
+
authorName: sticky.authorName ?? this.commentAuthor.name,
|
|
2870
|
+
};
|
|
2871
|
+
});
|
|
2872
|
+
this._scene = r.scene;
|
|
2873
|
+
tx.add(r.patch);
|
|
2874
|
+
changed = true;
|
|
2875
|
+
}
|
|
2876
|
+
if (!changed)
|
|
2877
|
+
return;
|
|
2878
|
+
tx.commit();
|
|
2879
|
+
this.notify();
|
|
2880
|
+
}
|
|
2881
|
+
/** Replace the tag list on sticky notes. One undo step. */
|
|
2882
|
+
setStickyTags(ids, tags) {
|
|
2883
|
+
if (this.readOnly)
|
|
2884
|
+
return;
|
|
2885
|
+
const tx = this._history.transaction();
|
|
2886
|
+
let changed = false;
|
|
2887
|
+
for (const id of ids) {
|
|
2888
|
+
const shape = getElement(this._scene, id);
|
|
2889
|
+
if (!shape || !isSticky(shape))
|
|
2890
|
+
continue;
|
|
2891
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
2892
|
+
const copy = { ...s };
|
|
2893
|
+
if (tags.length === 0)
|
|
2894
|
+
delete copy.tags;
|
|
2895
|
+
else
|
|
2896
|
+
copy.tags = tags;
|
|
2897
|
+
return copy;
|
|
2898
|
+
});
|
|
2899
|
+
this._scene = r.scene;
|
|
2900
|
+
tx.add(r.patch);
|
|
2901
|
+
changed = true;
|
|
2902
|
+
}
|
|
2903
|
+
if (!changed)
|
|
2904
|
+
return;
|
|
2905
|
+
tx.commit();
|
|
2906
|
+
this.notify();
|
|
2907
|
+
}
|
|
2908
|
+
/**
|
|
2909
|
+
* Toggle the local user's emoji reaction on a sticky note. A glyph the
|
|
2910
|
+
* user has NOT reacted with gains their reaction (+1); one they
|
|
2911
|
+
* already reacted with loses it (-1, removing the glyph at zero).
|
|
2912
|
+
* Other users' reactions are never touched — so counters only grow
|
|
2913
|
+
* through OTHER collaborators. Plain element data → syncs through the
|
|
2914
|
+
* normal scene channel. One undo step.
|
|
2915
|
+
*/
|
|
2916
|
+
toggleStickyReaction(id, glyph) {
|
|
2917
|
+
if (this.readOnly)
|
|
2918
|
+
return;
|
|
2919
|
+
const shape = getElement(this._scene, id);
|
|
2920
|
+
if (!shape || !isSticky(shape))
|
|
2921
|
+
return;
|
|
2922
|
+
const user = this.commentAuthor.id;
|
|
2923
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
2924
|
+
const sticky = s;
|
|
2925
|
+
const reactions = [...(sticky.reactions ?? [])];
|
|
2926
|
+
const i = reactions.findIndex((x) => x.glyph === glyph);
|
|
2927
|
+
const existing = i >= 0 ? reactions[i] : undefined;
|
|
2928
|
+
// Pre-per-user scenes may carry `{glyph, count}` without `users` —
|
|
2929
|
+
// treat those as nobody-having-reacted and rewrite on first toggle.
|
|
2930
|
+
const existingUsers = existing?.users ?? [];
|
|
2931
|
+
if (existing === undefined) {
|
|
2932
|
+
reactions.push({ glyph, users: [user] });
|
|
2933
|
+
}
|
|
2934
|
+
else if (existingUsers.includes(user)) {
|
|
2935
|
+
const users = existingUsers.filter((u) => u !== user);
|
|
2936
|
+
if (users.length === 0)
|
|
2937
|
+
reactions.splice(i, 1);
|
|
2938
|
+
else
|
|
2939
|
+
reactions[i] = { glyph, users };
|
|
2940
|
+
}
|
|
2941
|
+
else {
|
|
2942
|
+
reactions[i] = { glyph, users: [...existingUsers, user] };
|
|
2943
|
+
}
|
|
2944
|
+
const copy = { ...s };
|
|
2945
|
+
if (reactions.length === 0)
|
|
2946
|
+
delete copy.reactions;
|
|
2947
|
+
else
|
|
2948
|
+
copy.reactions = reactions;
|
|
2949
|
+
return copy;
|
|
2950
|
+
});
|
|
2951
|
+
this._scene = r.scene;
|
|
2952
|
+
this._history.push(r.patch);
|
|
2953
|
+
this.notify();
|
|
2954
|
+
}
|
|
2955
|
+
/** Replace an emoji element's glyph (the toolbar picker). One undo step. */
|
|
2956
|
+
setEmojiGlyph(ids, glyph) {
|
|
2957
|
+
if (this.readOnly)
|
|
2958
|
+
return;
|
|
2959
|
+
const tx = this._history.transaction();
|
|
2960
|
+
let changed = false;
|
|
2961
|
+
for (const id of ids) {
|
|
2962
|
+
const shape = getElement(this._scene, id);
|
|
2963
|
+
if (!shape || !isEmoji(shape))
|
|
2964
|
+
continue;
|
|
2965
|
+
const r = updateElement(this._scene, id, (s) => ({ ...s, glyph }));
|
|
2966
|
+
this._scene = r.scene;
|
|
2967
|
+
tx.add(r.patch);
|
|
2968
|
+
changed = true;
|
|
2969
|
+
}
|
|
2970
|
+
if (!changed)
|
|
2971
|
+
return;
|
|
2972
|
+
tx.commit();
|
|
2973
|
+
this.notify();
|
|
2974
|
+
}
|
|
2975
|
+
/**
|
|
2976
|
+
* Resize a frame to one of the `FRAME_SIZE_PRESETS` (top-left corner
|
|
2977
|
+
* stays put; members keep their absolute positions). One undo step.
|
|
2978
|
+
*/
|
|
2979
|
+
applyFramePreset(id, preset) {
|
|
2980
|
+
if (this.readOnly)
|
|
2981
|
+
return;
|
|
2982
|
+
const shape = getElement(this._scene, id);
|
|
2983
|
+
if (!shape || !isFrame(shape))
|
|
2984
|
+
return;
|
|
2985
|
+
const r = updateElement(this._scene, id, (s) => ({
|
|
2986
|
+
...s,
|
|
2987
|
+
width: preset.width,
|
|
2988
|
+
height: preset.height,
|
|
2989
|
+
}));
|
|
2990
|
+
this._scene = r.scene;
|
|
2991
|
+
this._history.push(r.patch);
|
|
2992
|
+
this.notify();
|
|
2993
|
+
}
|
|
2994
|
+
/**
|
|
2995
|
+
* Rename the binary file behind an image (the toolbar's file-name
|
|
2996
|
+
* input). One undo step; no-op when the id is unknown or the name is
|
|
2997
|
+
* unchanged.
|
|
2998
|
+
*/
|
|
2999
|
+
renameBinaryFile(id, name) {
|
|
3000
|
+
if (this.readOnly)
|
|
3001
|
+
return;
|
|
3002
|
+
const file = getBinaryFile(this._scene, id);
|
|
3003
|
+
if (!file || file.name === name)
|
|
3004
|
+
return;
|
|
3005
|
+
const patch = { kind: "file", id, before: file, after: { ...file, name } };
|
|
3006
|
+
this._scene = apply(this._scene, patch);
|
|
3007
|
+
this._history.push(patch);
|
|
3008
|
+
this.notify();
|
|
3009
|
+
}
|
|
3010
|
+
/** Set (or clear, with `null`) the accessible description on image shapes. */
|
|
3011
|
+
setImageAlt(ids, alt) {
|
|
3012
|
+
if (this.readOnly)
|
|
3013
|
+
return;
|
|
3014
|
+
const tx = this._history.transaction();
|
|
3015
|
+
let changed = false;
|
|
3016
|
+
for (const id of ids) {
|
|
3017
|
+
const shape = getElement(this._scene, id);
|
|
3018
|
+
if (!shape || !isImage(shape))
|
|
3019
|
+
continue;
|
|
3020
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
3021
|
+
const copy = { ...s };
|
|
3022
|
+
if (alt === null || alt === "")
|
|
3023
|
+
delete copy.alt;
|
|
3024
|
+
else
|
|
3025
|
+
copy.alt = alt;
|
|
3026
|
+
return copy;
|
|
3027
|
+
});
|
|
3028
|
+
this._scene = r.scene;
|
|
3029
|
+
tx.add(r.patch);
|
|
3030
|
+
changed = true;
|
|
3031
|
+
}
|
|
3032
|
+
if (!changed)
|
|
3033
|
+
return;
|
|
3034
|
+
tx.commit();
|
|
3035
|
+
this.notify();
|
|
3036
|
+
}
|
|
3037
|
+
/**
|
|
3038
|
+
* Set (or clear, with `null`) the shape mask on image elements. The
|
|
3039
|
+
* mask clips the drawn box (renderer-side `RenderTarget.clip`);
|
|
3040
|
+
* normalised coordinates make it survive resizes. One undo step.
|
|
3041
|
+
*/
|
|
3042
|
+
setImageMask(ids, mask) {
|
|
3043
|
+
if (this.readOnly)
|
|
3044
|
+
return;
|
|
3045
|
+
const tx = this._history.transaction();
|
|
3046
|
+
let changed = false;
|
|
3047
|
+
for (const id of ids) {
|
|
3048
|
+
const shape = getElement(this._scene, id);
|
|
3049
|
+
if (!shape || !isImage(shape))
|
|
3050
|
+
continue;
|
|
3051
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
3052
|
+
const copy = { ...s };
|
|
3053
|
+
if (mask === null)
|
|
3054
|
+
delete copy.mask;
|
|
3055
|
+
else
|
|
3056
|
+
copy.mask = mask;
|
|
3057
|
+
return copy;
|
|
3058
|
+
});
|
|
3059
|
+
this._scene = r.scene;
|
|
3060
|
+
tx.add(r.patch);
|
|
3061
|
+
changed = true;
|
|
3062
|
+
}
|
|
3063
|
+
if (!changed)
|
|
3064
|
+
return;
|
|
3065
|
+
tx.commit();
|
|
3066
|
+
this.notify();
|
|
3067
|
+
}
|
|
3068
|
+
/**
|
|
3069
|
+
* Apply an aspect preset from the mask picker to image elements.
|
|
3070
|
+
* `"original"` clears crop AND mask and refits the box to the source's
|
|
3071
|
+
* natural aspect; the ratio presets centre-crop the source to the
|
|
3072
|
+
* target aspect and refit the box height (width kept), with `"circle"`
|
|
3073
|
+
* additionally installing an ellipse mask on the square box. The
|
|
3074
|
+
* source's natural aspect derives from the current box + crop (the box
|
|
3075
|
+
* always displays the crop region undistorted), so no live image
|
|
3076
|
+
* handle is needed. One undo step.
|
|
3077
|
+
*/
|
|
3078
|
+
setImageAspectPreset(ids, preset) {
|
|
3079
|
+
if (this.readOnly)
|
|
3080
|
+
return;
|
|
3081
|
+
const tx = this._history.transaction();
|
|
3082
|
+
let changed = false;
|
|
3083
|
+
for (const id of ids) {
|
|
3084
|
+
const shape = getElement(this._scene, id);
|
|
3085
|
+
if (!shape || !isImage(shape))
|
|
3086
|
+
continue;
|
|
3087
|
+
const crop = shape.crop ?? { x: 0, y: 0, width: 1, height: 1 };
|
|
3088
|
+
const naturalAspect = shape.height > 0 && crop.width > 0
|
|
3089
|
+
? (shape.width / shape.height) * (crop.height / crop.width)
|
|
3090
|
+
: 1;
|
|
3091
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
3092
|
+
const copy = { ...s };
|
|
3093
|
+
if (preset === "original") {
|
|
3094
|
+
delete copy.crop;
|
|
3095
|
+
delete copy.mask;
|
|
3096
|
+
if (naturalAspect > 0)
|
|
3097
|
+
copy.height = copy.width / naturalAspect;
|
|
3098
|
+
return copy;
|
|
3099
|
+
}
|
|
3100
|
+
const aspect = IMAGE_ASPECT_PRESETS[preset];
|
|
3101
|
+
// Centred source crop with the target aspect: trim the longer axis.
|
|
3102
|
+
if (naturalAspect > aspect) {
|
|
3103
|
+
const w = aspect / naturalAspect;
|
|
3104
|
+
copy.crop = { x: (1 - w) / 2, y: 0, width: w, height: 1 };
|
|
3105
|
+
}
|
|
3106
|
+
else {
|
|
3107
|
+
const h = naturalAspect / aspect;
|
|
3108
|
+
copy.crop = { x: 0, y: (1 - h) / 2, width: 1, height: h };
|
|
3109
|
+
}
|
|
3110
|
+
copy.height = copy.width / aspect;
|
|
3111
|
+
if (preset === "circle")
|
|
3112
|
+
copy.mask = { kind: "ellipse" };
|
|
3113
|
+
else
|
|
3114
|
+
delete copy.mask;
|
|
3115
|
+
return copy;
|
|
3116
|
+
});
|
|
3117
|
+
this._scene = r.scene;
|
|
3118
|
+
tx.add(r.patch);
|
|
3119
|
+
changed = true;
|
|
3120
|
+
}
|
|
3121
|
+
if (!changed)
|
|
3122
|
+
return;
|
|
3123
|
+
tx.commit();
|
|
3124
|
+
this.notify();
|
|
3125
|
+
}
|
|
3126
|
+
/**
|
|
3127
|
+
* Swap a media shape's backing file for `blob` while keeping its
|
|
3128
|
+
* position and width (height refits to the new aspect). Accepts static
|
|
3129
|
+
* images, GIFs and videos — the shape's animation fields are rewritten
|
|
3130
|
+
* to match the NEW media kind, and `crop` resets when the media kind
|
|
3131
|
+
* changes (its pixel rect targeted the old content). Registers the new
|
|
3132
|
+
* `BinaryFile`, points the shape at it, drops the stale live handle
|
|
3133
|
+
* (`metadata.image`) and rebuilds handles for the new bytes. One undo
|
|
3134
|
+
* step.
|
|
3135
|
+
*/
|
|
3136
|
+
async replaceImageFile(id, blob, name) {
|
|
3137
|
+
if (this.readOnly)
|
|
3138
|
+
return;
|
|
3139
|
+
const shape = getElement(this._scene, id);
|
|
3140
|
+
if (!shape || !isImage(shape))
|
|
3141
|
+
return;
|
|
3142
|
+
const added = await computeAddBinaryFile(this._scene, blob, name, () => ++this.nextId);
|
|
3143
|
+
const mime = blob.type;
|
|
3144
|
+
const isVideo = mime.startsWith("video/") || /\.(mp4|webm|ogv|mov)$/i.test(name ?? "");
|
|
3145
|
+
const isGif = !isVideo && (mime === "image/gif" || /\.gif$/i.test(name ?? ""));
|
|
3146
|
+
const url = typeof URL !== "undefined" && typeof URL.createObjectURL === "function"
|
|
3147
|
+
? URL.createObjectURL(blob)
|
|
3148
|
+
: null;
|
|
3149
|
+
// The new media's aspect ratio replaces the old one: keep the shape's
|
|
3150
|
+
// width, refit the height — otherwise a differently-proportioned file
|
|
3151
|
+
// renders stretched into the old box. Videos measure through a hidden
|
|
3152
|
+
// looping <video> (which then stays on as the live handle, same as the
|
|
3153
|
+
// video drop handler); images/GIFs through createImageBitmap.
|
|
3154
|
+
let refitHeight = null;
|
|
3155
|
+
let videoHandle = null;
|
|
3156
|
+
if (isVideo) {
|
|
3157
|
+
if (typeof document !== "undefined" && url !== null) {
|
|
3158
|
+
const video = createHiddenLoopingVideo(url);
|
|
3159
|
+
await new Promise((resolve) => {
|
|
3160
|
+
video.onloadedmetadata = () => {
|
|
3161
|
+
resolve();
|
|
3162
|
+
};
|
|
3163
|
+
video.onerror = () => {
|
|
3164
|
+
resolve();
|
|
3165
|
+
};
|
|
3166
|
+
});
|
|
3167
|
+
if (video.videoWidth > 0) {
|
|
3168
|
+
refitHeight = (shape.width * video.videoHeight) / video.videoWidth;
|
|
3169
|
+
}
|
|
3170
|
+
// Best-effort play (muted autoplay can still need a prior gesture).
|
|
3171
|
+
void video.play().catch(() => {
|
|
3172
|
+
/* intentional no-op */
|
|
3173
|
+
});
|
|
3174
|
+
videoHandle = video;
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
else if (typeof createImageBitmap === "function") {
|
|
3178
|
+
try {
|
|
3179
|
+
const bitmap = await createImageBitmap(blob);
|
|
3180
|
+
if (bitmap.width > 0) {
|
|
3181
|
+
refitHeight = (shape.width * bitmap.height) / bitmap.width;
|
|
3182
|
+
}
|
|
3183
|
+
bitmap.close();
|
|
3184
|
+
}
|
|
3185
|
+
catch {
|
|
3186
|
+
/* undecodable here — keep the old box; rehydration reports errors */
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
// GIF frames decode from the raw bytes via the "gif" adapter.
|
|
3190
|
+
const animationBytes = isGif ? await blob.arrayBuffer() : undefined;
|
|
3191
|
+
const oldKind = shape.animationKind
|
|
3192
|
+
? "gif"
|
|
3193
|
+
: shape.metadata?.animated === true
|
|
3194
|
+
? "video"
|
|
3195
|
+
: "static";
|
|
3196
|
+
const newKind = isGif ? "gif" : isVideo ? "video" : "static";
|
|
3197
|
+
const tx = this._history.transaction();
|
|
3198
|
+
tx.add(added.patch);
|
|
3199
|
+
const r = updateElement(added.scene, id, (s) => {
|
|
3200
|
+
const copy = { ...s };
|
|
3201
|
+
copy.fileId = added.id;
|
|
3202
|
+
if (url !== null)
|
|
3203
|
+
copy.src = url;
|
|
3204
|
+
if (refitHeight !== null)
|
|
3205
|
+
copy.height = refitHeight;
|
|
3206
|
+
const meta = { ...copy.metadata };
|
|
3207
|
+
delete meta.image;
|
|
3208
|
+
delete meta.animated;
|
|
3209
|
+
if (newKind !== "static")
|
|
3210
|
+
meta.animated = true;
|
|
3211
|
+
if (videoHandle)
|
|
3212
|
+
meta.image = videoHandle;
|
|
3213
|
+
if (Object.keys(meta).length > 0)
|
|
3214
|
+
copy.metadata = meta;
|
|
3215
|
+
else
|
|
3216
|
+
delete copy.metadata;
|
|
3217
|
+
delete copy.animationKind;
|
|
3218
|
+
delete copy.animationData;
|
|
3219
|
+
if (isGif) {
|
|
3220
|
+
copy.animationKind = "gif";
|
|
3221
|
+
copy.animationData = animationBytes;
|
|
3222
|
+
}
|
|
3223
|
+
// The crop rect addressed the OLD media's pixels — meaningless
|
|
3224
|
+
// across a media-kind switch.
|
|
3225
|
+
if (oldKind !== newKind)
|
|
3226
|
+
delete copy.crop;
|
|
3227
|
+
return copy;
|
|
3228
|
+
});
|
|
3229
|
+
this._scene = r.scene;
|
|
3230
|
+
tx.add(r.patch);
|
|
3231
|
+
tx.commit();
|
|
3232
|
+
if (newKind !== "static") {
|
|
3233
|
+
this.gifPlayback.ensure(id);
|
|
3234
|
+
this.maybeAnimate();
|
|
3235
|
+
}
|
|
3236
|
+
this.notify();
|
|
3237
|
+
if (newKind === "static")
|
|
3238
|
+
await animScene.rehydrateStaticImages(this);
|
|
3239
|
+
}
|
|
3240
|
+
/**
|
|
3241
|
+
* Paragraph targets for the list operations: while inline-editing one
|
|
3242
|
+
* of `ids`, only the paragraphs covered by the caret / selection;
|
|
3243
|
+
* otherwise every paragraph of each text element. Returns `null` for
|
|
3244
|
+
* non-text ids.
|
|
3245
|
+
*/
|
|
3246
|
+
paragraphTargets(id) {
|
|
3247
|
+
const shape = getElement(this._scene, id);
|
|
3248
|
+
if (!shape || !isText(shape))
|
|
3249
|
+
return null;
|
|
3250
|
+
const sel = this.editingTextElement === id ? this.editingTextSelection : null;
|
|
3251
|
+
if (sel)
|
|
3252
|
+
return paragraphRangeForOffsets(shape.text, sel.start, sel.end);
|
|
3253
|
+
return { first: 0, last: paragraphCount(shape.text) - 1 };
|
|
3254
|
+
}
|
|
3255
|
+
/**
|
|
3256
|
+
* Set (or clear, with `null`) the list kind on the targeted paragraphs
|
|
3257
|
+
* of every text element in `ids` — the inline-edit selection's
|
|
3258
|
+
* paragraphs while editing, the whole element otherwise. One undo step.
|
|
3259
|
+
*/
|
|
3260
|
+
setParagraphList(ids, kind) {
|
|
3261
|
+
if (this.readOnly)
|
|
3262
|
+
return;
|
|
3263
|
+
const tx = this._history.transaction();
|
|
3264
|
+
let changed = false;
|
|
3265
|
+
for (const id of ids) {
|
|
3266
|
+
const range = this.paragraphTargets(id);
|
|
3267
|
+
if (!range)
|
|
3268
|
+
continue;
|
|
3269
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
3270
|
+
const shape = s;
|
|
3271
|
+
const count = paragraphCount(shape.text);
|
|
3272
|
+
const next = Array.from({ length: count }, (_, i) => paragraphAt(shape.paragraphs, i)).map((p, i) => {
|
|
3273
|
+
if (i < range.first || i > range.last)
|
|
3274
|
+
return p;
|
|
3275
|
+
const { list: _list, ...rest } = p;
|
|
3276
|
+
return kind === null ? rest : { ...rest, list: kind };
|
|
3277
|
+
});
|
|
3278
|
+
const normalized = normalizeParagraphs(next);
|
|
3279
|
+
const copy = { ...s };
|
|
3280
|
+
if (normalized === undefined)
|
|
3281
|
+
delete copy.paragraphs;
|
|
3282
|
+
else
|
|
3283
|
+
copy.paragraphs = normalized;
|
|
3284
|
+
return copy;
|
|
3285
|
+
});
|
|
3286
|
+
this._scene = r.scene;
|
|
3287
|
+
tx.add(r.patch);
|
|
3288
|
+
changed = true;
|
|
3289
|
+
}
|
|
3290
|
+
if (!changed)
|
|
3291
|
+
return;
|
|
3292
|
+
tx.commit();
|
|
3293
|
+
this.notify();
|
|
3294
|
+
}
|
|
3295
|
+
/**
|
|
3296
|
+
* Shift the targeted paragraphs' list nesting by `delta` (±1), clamped
|
|
3297
|
+
* to `[0, MAX_LIST_INDENT]`. Same targeting rules as
|
|
3298
|
+
* {@link setParagraphList}. One undo step.
|
|
3299
|
+
*/
|
|
3300
|
+
indentParagraphs(ids, delta) {
|
|
3301
|
+
if (this.readOnly)
|
|
3302
|
+
return;
|
|
3303
|
+
const tx = this._history.transaction();
|
|
3304
|
+
let changed = false;
|
|
3305
|
+
for (const id of ids) {
|
|
3306
|
+
const range = this.paragraphTargets(id);
|
|
3307
|
+
if (!range)
|
|
3308
|
+
continue;
|
|
3309
|
+
const r = updateElement(this._scene, id, (s) => {
|
|
3310
|
+
const shape = s;
|
|
3311
|
+
const count = paragraphCount(shape.text);
|
|
3312
|
+
const next = Array.from({ length: count }, (_, i) => paragraphAt(shape.paragraphs, i)).map((p, i) => {
|
|
3313
|
+
if (i < range.first || i > range.last)
|
|
3314
|
+
return p;
|
|
3315
|
+
const level = Math.max(0, Math.min(MAX_LIST_INDENT, (p.indent ?? 0) + delta));
|
|
3316
|
+
const { indent: _indent, ...rest } = p;
|
|
3317
|
+
return level === 0 ? rest : { ...rest, indent: level };
|
|
3318
|
+
});
|
|
3319
|
+
const normalized = normalizeParagraphs(next);
|
|
3320
|
+
const copy = { ...s };
|
|
3321
|
+
if (normalized === undefined)
|
|
3322
|
+
delete copy.paragraphs;
|
|
3323
|
+
else
|
|
3324
|
+
copy.paragraphs = normalized;
|
|
3325
|
+
return copy;
|
|
3326
|
+
});
|
|
3327
|
+
this._scene = r.scene;
|
|
3328
|
+
tx.add(r.patch);
|
|
3329
|
+
changed = true;
|
|
3330
|
+
}
|
|
3331
|
+
if (!changed)
|
|
3332
|
+
return;
|
|
3333
|
+
tx.commit();
|
|
3334
|
+
this.notify();
|
|
3335
|
+
}
|
|
2456
3336
|
// --- F8: Eyedropper ---------------------------------------------------
|
|
2457
3337
|
/**
|
|
2458
3338
|
* The fill (or stroke, per `role`) colour of the top-most shape under the
|
|
@@ -2878,17 +3758,58 @@ export class Editor {
|
|
|
2878
3758
|
}
|
|
2879
3759
|
/**
|
|
2880
3760
|
* Update non-style text properties (`fontSize`, `fontFamily`,
|
|
2881
|
-
* `maxWidth`) on every
|
|
2882
|
-
*
|
|
3761
|
+
* `maxWidth`) on every text shape in `ids`; labelable shapes route
|
|
3762
|
+
* `fontSize` / `fontFamily` to their embedded label instead (as
|
|
3763
|
+
* `updateLabelProps` — an explicit size leaves auto-fit). Anything else is
|
|
3764
|
+
* skipped. Single undo step. Used by the selection toolbar, so a mixed
|
|
3765
|
+
* text + shape selection shares one font control.
|
|
2883
3766
|
*/
|
|
2884
3767
|
updateTextProps(ids, partial) {
|
|
2885
3768
|
if (this.readOnly)
|
|
2886
3769
|
return;
|
|
2887
|
-
const
|
|
2888
|
-
|
|
3770
|
+
const textIds = [];
|
|
3771
|
+
const labelIds = [];
|
|
3772
|
+
for (const id of ids) {
|
|
3773
|
+
const el = getElement(this._scene, id);
|
|
3774
|
+
if (el === undefined)
|
|
3775
|
+
continue;
|
|
3776
|
+
if (isText(el))
|
|
3777
|
+
textIds.push(id);
|
|
3778
|
+
else if (withLabel(el).label !== undefined)
|
|
3779
|
+
labelIds.push(id);
|
|
3780
|
+
}
|
|
3781
|
+
const tx = this._history.transaction();
|
|
3782
|
+
let changed = false;
|
|
3783
|
+
const text = computeUpdateTextProps(this._scene, textIds, partial);
|
|
3784
|
+
if (text) {
|
|
3785
|
+
this._scene = text.scene;
|
|
3786
|
+
tx.add(text.patch);
|
|
3787
|
+
changed = true;
|
|
3788
|
+
}
|
|
3789
|
+
const { fontFamily, fontSize } = partial;
|
|
3790
|
+
if (fontFamily !== undefined || fontSize !== undefined) {
|
|
3791
|
+
const labelPartial = {
|
|
3792
|
+
...(fontFamily !== undefined ? { fontFamily } : {}),
|
|
3793
|
+
...(fontSize !== undefined ? { fontSize } : {}),
|
|
3794
|
+
};
|
|
3795
|
+
for (const id of labelIds) {
|
|
3796
|
+
const r = updateElement(this._scene, id, (raw) => {
|
|
3797
|
+
const l = withLabel(raw);
|
|
3798
|
+
if (l.label === undefined)
|
|
3799
|
+
return l;
|
|
3800
|
+
const label = { ...l.label, ...labelPartial };
|
|
3801
|
+
if (fontSize !== undefined)
|
|
3802
|
+
delete label.autoFit;
|
|
3803
|
+
return { ...l, label };
|
|
3804
|
+
});
|
|
3805
|
+
this._scene = r.scene;
|
|
3806
|
+
tx.add(r.patch);
|
|
3807
|
+
changed = true;
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
if (!changed)
|
|
2889
3811
|
return;
|
|
2890
|
-
|
|
2891
|
-
this._history.push(result.patch);
|
|
3812
|
+
tx.commit();
|
|
2892
3813
|
this.notify();
|
|
2893
3814
|
}
|
|
2894
3815
|
/**
|
|
@@ -2946,8 +3867,8 @@ export class Editor {
|
|
|
2946
3867
|
* with its world bounds — for the hover link-popup. `null` when none.
|
|
2947
3868
|
*/
|
|
2948
3869
|
linkAt(worldPoint) {
|
|
2949
|
-
const shape = this.acceleratedElementAt(worldPoint);
|
|
2950
|
-
if (!shape
|
|
3870
|
+
const shape = this.acceleratedElementAt(worldPoint, (s) => this.isElementInteractable(s));
|
|
3871
|
+
if (!shape)
|
|
2951
3872
|
return null;
|
|
2952
3873
|
const href = safeHref(shape.href);
|
|
2953
3874
|
if (!href)
|
|
@@ -3026,20 +3947,37 @@ export class Editor {
|
|
|
3026
3947
|
* rotate gesture drives the same maths from a press-time snapshot.
|
|
3027
3948
|
*/
|
|
3028
3949
|
rotateSelection(delta) {
|
|
3029
|
-
|
|
3950
|
+
// Groups carry their subtree: every descendant orbits the shared pivot.
|
|
3030
3951
|
const origin = new Map();
|
|
3031
|
-
for (const id of this.
|
|
3952
|
+
for (const id of this.expandSelectionWithDescendants()) {
|
|
3032
3953
|
const el = getElement(this._scene, id);
|
|
3033
|
-
if (el === undefined)
|
|
3954
|
+
if (el === undefined || isGroup(el))
|
|
3034
3955
|
continue;
|
|
3035
|
-
elements.push(el);
|
|
3036
3956
|
origin.set(id, { position: el.position, rotation: el.rotation });
|
|
3037
3957
|
}
|
|
3038
|
-
|
|
3958
|
+
const box = this.combinedSelectionBounds();
|
|
3959
|
+
if (origin.size === 0 || !box)
|
|
3039
3960
|
return;
|
|
3040
|
-
const pivot =
|
|
3961
|
+
const pivot = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
3041
3962
|
this.commitArrange(computeRotatePatches(this._scene, origin, pivot, delta));
|
|
3042
3963
|
}
|
|
3964
|
+
/**
|
|
3965
|
+
* Straighten the selection: every selected element (and descendants of
|
|
3966
|
+
* selected groups) goes back to `rotation: 0`, each turning about its own
|
|
3967
|
+
* centre so it stays where it is. One undoable step; no-op when nothing
|
|
3968
|
+
* is rotated. Triggered by a double-click on the rotate grip.
|
|
3969
|
+
*/
|
|
3970
|
+
resetSelectionRotation() {
|
|
3971
|
+
const patches = [];
|
|
3972
|
+
for (const id of this.expandSelectionWithDescendants()) {
|
|
3973
|
+
const el = getElement(this._scene, id);
|
|
3974
|
+
if (el === undefined || el.rotation === 0)
|
|
3975
|
+
continue;
|
|
3976
|
+
const origin = new Map([[id, { position: el.position, rotation: el.rotation }]]);
|
|
3977
|
+
patches.push(...computeRotatePatches(this._scene, origin, selectionCenter([el]), -el.rotation));
|
|
3978
|
+
}
|
|
3979
|
+
this.commitArrange(patches);
|
|
3980
|
+
}
|
|
3043
3981
|
/** Apply a batch of arrange patches as a single undoable step. */
|
|
3044
3982
|
commitArrange(patches) {
|
|
3045
3983
|
if (this.readOnly)
|
|
@@ -3191,6 +4129,18 @@ export class Editor {
|
|
|
3191
4129
|
const center = this.screenToWorld({ x: vp.size.width / 2, y: vp.size.height / 2 });
|
|
3192
4130
|
this.zoomAt(factor, center);
|
|
3193
4131
|
}
|
|
4132
|
+
/**
|
|
4133
|
+
* Set an absolute zoom level (1 = 100%), scaling about the viewport
|
|
4134
|
+
* centre and clamped to `MIN_ZOOM..MAX_ZOOM`. The zoom menu's presets.
|
|
4135
|
+
*/
|
|
4136
|
+
setZoom(level) {
|
|
4137
|
+
const vp = this._scene.viewport;
|
|
4138
|
+
const current = vp.zoom || 1;
|
|
4139
|
+
const target = clampZoom(level);
|
|
4140
|
+
if (target === current || vp.size.width <= 0 || vp.size.height <= 0)
|
|
4141
|
+
return;
|
|
4142
|
+
this.zoomStep(target / current);
|
|
4143
|
+
}
|
|
3194
4144
|
resetZoom() {
|
|
3195
4145
|
const next = computeResetZoom(this._scene);
|
|
3196
4146
|
if (!next)
|
|
@@ -3232,6 +4182,20 @@ export class Editor {
|
|
|
3232
4182
|
this._scene = next;
|
|
3233
4183
|
this.notify();
|
|
3234
4184
|
}
|
|
4185
|
+
/**
|
|
4186
|
+
* "Back to content": jump to the element nearest the camera centre, keeping
|
|
4187
|
+
* the current zoom (it only drops when that element doesn't fit). Unlike
|
|
4188
|
+
* {@link zoomToFit} this neither shrinks a large board to a speck nor blows
|
|
4189
|
+
* a single small shape up to full screen. Groups are skipped (their own
|
|
4190
|
+
* bounds are empty — their children count). No-op on an empty scene.
|
|
4191
|
+
*/
|
|
4192
|
+
revealNearestContent(padding = 80) {
|
|
4193
|
+
const next = computeRevealNearest(this._scene, padding, (s) => s.type !== "group");
|
|
4194
|
+
if (!next)
|
|
4195
|
+
return;
|
|
4196
|
+
this._scene = next;
|
|
4197
|
+
this.notify();
|
|
4198
|
+
}
|
|
3235
4199
|
/**
|
|
3236
4200
|
* Select the nearest interactable top-level element in `direction` from the
|
|
3237
4201
|
* current selection's centre (or the viewport centre when nothing is
|
|
@@ -3314,6 +4278,137 @@ export class Editor {
|
|
|
3314
4278
|
snapSpacing() {
|
|
3315
4279
|
return resolveSnapSpacing();
|
|
3316
4280
|
}
|
|
4281
|
+
/**
|
|
4282
|
+
* Object snapping is a per-user preference; the suppress modifier
|
|
4283
|
+
* (Cmd/Ctrl) bypasses it for the current gesture like grid snapping.
|
|
4284
|
+
*/
|
|
4285
|
+
objectSnapActive() {
|
|
4286
|
+
return !this.interaction.snapSuppressed && this._preferences.snapObjects;
|
|
4287
|
+
}
|
|
4288
|
+
sizeSuggestActive() {
|
|
4289
|
+
return !this.interaction.snapSuppressed && this._preferences.suggestObjectSize;
|
|
4290
|
+
}
|
|
4291
|
+
/**
|
|
4292
|
+
* World AABBs of the shapes a gesture may snap to (reference rules): every
|
|
4293
|
+
* visible element on screen that is not being moved / resized, except
|
|
4294
|
+
* groups (no geometry of their own), brush strokes (freehand ink is never
|
|
4295
|
+
* a snap target), shapes rotated off the 90° grid, and shapes too small on
|
|
4296
|
+
* screen (`OBJECT_SNAP_MIN_SIZE_PX`). Returns nothing on a crowded view
|
|
4297
|
+
* (`OBJECT_SNAP_MAX_CANDIDATES`) so a huge board never stalls a drag.
|
|
4298
|
+
*/
|
|
4299
|
+
snapCandidates(moving) {
|
|
4300
|
+
const view = this.computeViewportWorld();
|
|
4301
|
+
const zoom = this._scene.viewport.zoom || 1;
|
|
4302
|
+
const minSize = OBJECT_SNAP_MIN_SIZE_PX / zoom;
|
|
4303
|
+
const out = [];
|
|
4304
|
+
for (const shape of this._scene.elements.values()) {
|
|
4305
|
+
if (moving.has(shape.id) || isGroup(shape) || isBrush(shape))
|
|
4306
|
+
continue;
|
|
4307
|
+
if (!isRightAngle(shape.rotation))
|
|
4308
|
+
continue;
|
|
4309
|
+
if (isElementHidden(this._scene, shape))
|
|
4310
|
+
continue;
|
|
4311
|
+
const b = getElementWorldBounds(shape);
|
|
4312
|
+
if (view && !B.intersects(view, b))
|
|
4313
|
+
continue;
|
|
4314
|
+
if (b.width < minSize && b.height < minSize)
|
|
4315
|
+
continue;
|
|
4316
|
+
out.push(b);
|
|
4317
|
+
if (out.length > OBJECT_SNAP_MAX_CANDIDATES)
|
|
4318
|
+
return [];
|
|
4319
|
+
}
|
|
4320
|
+
return out;
|
|
4321
|
+
}
|
|
4322
|
+
/** Brush strokes never snap (as movers or targets) — freehand ink has no meaningful edges. */
|
|
4323
|
+
snapsAsMover(id) {
|
|
4324
|
+
const shape = getElement(this._scene, id);
|
|
4325
|
+
return shape !== undefined && !isBrush(shape);
|
|
4326
|
+
}
|
|
4327
|
+
/**
|
|
4328
|
+
* Move-delta snapping: object snapping (edges / centres of nearby shapes)
|
|
4329
|
+
* wins when it lands; otherwise grid snapping. Records the guides for the
|
|
4330
|
+
* overlay either way.
|
|
4331
|
+
*/
|
|
4332
|
+
snapMoveDeltaFor(moving, bounds, delta) {
|
|
4333
|
+
this.interaction.snapGuides = [];
|
|
4334
|
+
if (this.objectSnapActive() && [...moving].some((id) => this.snapsAsMover(id))) {
|
|
4335
|
+
const threshold = OBJECT_SNAP_THRESHOLD_PX / (this._scene.viewport.zoom || 1);
|
|
4336
|
+
const r = snapMoveDeltaToObjects(bounds, delta, this.snapCandidates(moving), threshold);
|
|
4337
|
+
if (r.guides.length > 0) {
|
|
4338
|
+
this.interaction.snapGuides = r.guides;
|
|
4339
|
+
return r.delta;
|
|
4340
|
+
}
|
|
4341
|
+
}
|
|
4342
|
+
return this.snapActive() ? snapMoveDelta(bounds, delta, this.snapSpacing()) : delta;
|
|
4343
|
+
}
|
|
4344
|
+
/**
|
|
4345
|
+
* Group move: the press-time union AABB of the members (each member's
|
|
4346
|
+
* current geometry at its press-time position; brush strokes excluded)
|
|
4347
|
+
* snaps to nearby shapes by its frame EDGES only — a multi-selection has
|
|
4348
|
+
* no centre line, like the reference. Falls back to the grid
|
|
4349
|
+
* reference-point snap.
|
|
4350
|
+
*/
|
|
4351
|
+
snapGroupMoveDelta(origins, delta) {
|
|
4352
|
+
this.interaction.snapGuides = [];
|
|
4353
|
+
if (this.objectSnapActive()) {
|
|
4354
|
+
let union = null;
|
|
4355
|
+
let members = 0;
|
|
4356
|
+
for (const [id, position] of origins) {
|
|
4357
|
+
const shape = getElement(this._scene, id);
|
|
4358
|
+
if (!shape || isGroup(shape) || isBrush(shape))
|
|
4359
|
+
continue;
|
|
4360
|
+
members += 1;
|
|
4361
|
+
const b = getElementWorldBounds({ ...shape, position });
|
|
4362
|
+
union = union ? B.union(union, b) : b;
|
|
4363
|
+
}
|
|
4364
|
+
if (union) {
|
|
4365
|
+
const threshold = OBJECT_SNAP_THRESHOLD_PX / (this._scene.viewport.zoom || 1);
|
|
4366
|
+
const r = snapMoveDeltaToObjects(union, delta, this.snapCandidates(new Set(origins.keys())), threshold, { centerLines: members === 1 });
|
|
4367
|
+
if (r.guides.length > 0) {
|
|
4368
|
+
this.interaction.snapGuides = r.guides;
|
|
4369
|
+
return r.delta;
|
|
4370
|
+
}
|
|
4371
|
+
}
|
|
4372
|
+
}
|
|
4373
|
+
return this.snapActive() ? snapGroupDelta(origins, delta, this.snapSpacing()) : delta;
|
|
4374
|
+
}
|
|
4375
|
+
/** Resize-delta counterpart of {@link snapMoveDeltaFor}; also sets `sizeMatch`. */
|
|
4376
|
+
snapResizeDeltaFor(moving, original, handle, delta) {
|
|
4377
|
+
this.interaction.snapGuides = [];
|
|
4378
|
+
this.interaction.sizeMatch = null;
|
|
4379
|
+
const alignEdges = this.objectSnapActive();
|
|
4380
|
+
const matchSizes = this.sizeSuggestActive();
|
|
4381
|
+
if ((alignEdges || matchSizes) && [...moving].some((id) => this.snapsAsMover(id))) {
|
|
4382
|
+
const zoom = this._scene.viewport.zoom || 1;
|
|
4383
|
+
const threshold = Math.max(OBJECT_SNAP_THRESHOLD_PX, SIZE_SUGGEST_THRESHOLD_PX) / zoom;
|
|
4384
|
+
const r = snapResizeDeltaToObjects(original, handle, delta, this.snapCandidates(moving), threshold, { alignEdges, matchSizes });
|
|
4385
|
+
if (r.guides.length > 0 || r.sizeMatch !== null) {
|
|
4386
|
+
this.interaction.snapGuides = r.guides;
|
|
4387
|
+
this.interaction.sizeMatch = r.sizeMatch;
|
|
4388
|
+
return r.delta;
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
4391
|
+
return this.snapActive() ? snapResizeDelta(original, handle, delta, this.snapSpacing()) : delta;
|
|
4392
|
+
}
|
|
4393
|
+
/** Publish the `W × H` readout for `bounds` when the preference is on. */
|
|
4394
|
+
setSizeReadout(bounds) {
|
|
4395
|
+
this.interaction.sizeReadout =
|
|
4396
|
+
bounds && this._preferences.showObjectSize
|
|
4397
|
+
? { bounds, width: bounds.width, height: bounds.height }
|
|
4398
|
+
: null;
|
|
4399
|
+
}
|
|
4400
|
+
/** Alignment guides of the current gesture tick (object snapping). */
|
|
4401
|
+
get snapGuides() {
|
|
4402
|
+
return this.interaction.snapGuides;
|
|
4403
|
+
}
|
|
4404
|
+
/** `W × H` readout of the shape being resized, when `showObjectSize` is on. */
|
|
4405
|
+
get sizeReadout() {
|
|
4406
|
+
return this.interaction.sizeReadout;
|
|
4407
|
+
}
|
|
4408
|
+
/** Bounds of the shape whose size the current resize matched, if any. */
|
|
4409
|
+
get sizeMatch() {
|
|
4410
|
+
return this.interaction.sizeMatch;
|
|
4411
|
+
}
|
|
3317
4412
|
/**
|
|
3318
4413
|
* Replace the entire scene (e.g. after `parseScene`). Clears history,
|
|
3319
4414
|
* selection and any open gesture. Use to load a saved document.
|
|
@@ -3323,7 +4418,9 @@ export class Editor {
|
|
|
3323
4418
|
this.gestureTx.cancel();
|
|
3324
4419
|
this.gestureTx = null;
|
|
3325
4420
|
}
|
|
3326
|
-
|
|
4421
|
+
// A saved start view is where a freshly opened document lands; a
|
|
4422
|
+
// peer update (`preserveHistory`) must not yank the local camera.
|
|
4423
|
+
this._scene = options.preserveHistory ? scene : withStartViewApplied(scene);
|
|
3327
4424
|
// Snap active layer back into the loaded scene's layer set.
|
|
3328
4425
|
if (!scene.layers.has(this._activeLayerId)) {
|
|
3329
4426
|
const first = scene.layers.keys().next().value;
|
|
@@ -3358,6 +4455,7 @@ export class Editor {
|
|
|
3358
4455
|
}
|
|
3359
4456
|
/** Detach all DOM listeners and stop the actor. */
|
|
3360
4457
|
dispose() {
|
|
4458
|
+
this.disposed = true;
|
|
3361
4459
|
this.cancelLongPress();
|
|
3362
4460
|
this.unbind();
|
|
3363
4461
|
this.actor.stop();
|
|
@@ -3410,10 +4508,10 @@ export class Editor {
|
|
|
3410
4508
|
// Right-click without a drag → trigger the context-menu listeners.
|
|
3411
4509
|
// Same payload as touch long-press so existing UI (e.g.
|
|
3412
4510
|
// `@react-ui/ContextMenu`) works without changes.
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
}
|
|
4511
|
+
this.fireContextPress({
|
|
4512
|
+
screenPoint: gesture.startPoint,
|
|
4513
|
+
worldPoint: this.screenToWorld(gesture.startPoint),
|
|
4514
|
+
});
|
|
3417
4515
|
}
|
|
3418
4516
|
else {
|
|
3419
4517
|
// Either it was a real drag, or Space + left drag. In both cases the
|
|
@@ -3424,9 +4522,48 @@ export class Editor {
|
|
|
3424
4522
|
// Pan over — recompute (→ "grab" if Space/hand still armed, else the
|
|
3425
4523
|
// idle hover cursor).
|
|
3426
4524
|
this.refreshCursor();
|
|
4525
|
+
// The gesture's end is itself a change: idle-gated observers (the
|
|
4526
|
+
// minimap) skip every pan notify while `panGesture` is set and rely on
|
|
4527
|
+
// this trailing one to repaint.
|
|
4528
|
+
this.notify();
|
|
3427
4529
|
}
|
|
3428
4530
|
isDrawingPhase(ctx) {
|
|
3429
|
-
return ctx.mode === "draw-rect" ||
|
|
4531
|
+
return (ctx.mode === "draw-rect" ||
|
|
4532
|
+
ctx.mode === "draw-ellipse" ||
|
|
4533
|
+
ctx.mode === "draw-frame" ||
|
|
4534
|
+
ctx.mode === "draw-edge");
|
|
4535
|
+
}
|
|
4536
|
+
/**
|
|
4537
|
+
* Context press (right-click / touch long-press): first make the selection
|
|
4538
|
+
* match what was pressed, then notify the `onLongPress` listeners so the
|
|
4539
|
+
* menu they open describes that point.
|
|
4540
|
+
* - On an unselected element / link → it becomes the selection.
|
|
4541
|
+
* - On a selected element (or its chrome: handles, rotate grip) → keep.
|
|
4542
|
+
* - On empty canvas → clear elements + links, so the menu is the canvas
|
|
4543
|
+
* menu rather than the previous selection's.
|
|
4544
|
+
*/
|
|
4545
|
+
fireContextPress(payload) {
|
|
4546
|
+
this.routeContextPress(payload.worldPoint);
|
|
4547
|
+
for (const fn of this.longPressListeners)
|
|
4548
|
+
fn(payload);
|
|
4549
|
+
}
|
|
4550
|
+
routeContextPress(worldPoint) {
|
|
4551
|
+
const target = this.hitTest(worldPoint);
|
|
4552
|
+
if (target.kind === "element") {
|
|
4553
|
+
if (!this._selection.has(target.id)) {
|
|
4554
|
+
this.applyEmit({ type: "SELECT_REPLACE", id: target.id });
|
|
4555
|
+
}
|
|
4556
|
+
}
|
|
4557
|
+
else if (target.kind === "link") {
|
|
4558
|
+
if (!this._selectedLinks.has(target.id)) {
|
|
4559
|
+
this.applyEmit({ type: "SELECT_EDGE_REPLACE", id: target.id });
|
|
4560
|
+
}
|
|
4561
|
+
}
|
|
4562
|
+
else if (target.kind === "empty") {
|
|
4563
|
+
if (this._selection.size > 0 || this._selectedLinks.size > 0) {
|
|
4564
|
+
this.applyEmit({ type: "SELECT_CLEAR" });
|
|
4565
|
+
}
|
|
4566
|
+
}
|
|
3430
4567
|
}
|
|
3431
4568
|
// --- Long-press ---
|
|
3432
4569
|
startLongPress(screenPoint) {
|
|
@@ -3462,8 +4599,8 @@ export class Editor {
|
|
|
3462
4599
|
* `undefined` → dropped on empty space (the end stays a free point).
|
|
3463
4600
|
*/
|
|
3464
4601
|
linkAttachTargetAt(worldPoint) {
|
|
3465
|
-
const shape = this.acceleratedElementAt(worldPoint);
|
|
3466
|
-
if (shape
|
|
4602
|
+
const shape = this.acceleratedElementAt(worldPoint, (s) => this.isElementInteractable(s));
|
|
4603
|
+
if (shape) {
|
|
3467
4604
|
const target = this.promoteToGroupRoot(shape);
|
|
3468
4605
|
return { kind: "element", id: target.id, bounds: getElementWorldBounds(target) };
|
|
3469
4606
|
}
|
|
@@ -3486,8 +4623,9 @@ export class Editor {
|
|
|
3486
4623
|
hitAnnotation: (p) => this.hitAnnotation(p),
|
|
3487
4624
|
selectionIsAspectLocked: () => this.selectionIsAspectLocked(),
|
|
3488
4625
|
combinedSelectionBounds: () => this.combinedSelectionBounds(),
|
|
3489
|
-
acceleratedElementAt: (p) => this.acceleratedElementAt(p),
|
|
4626
|
+
acceleratedElementAt: (p, accept) => this.acceleratedElementAt(p, accept),
|
|
3490
4627
|
isElementInteractable: (s) => this.isElementInteractable(s),
|
|
4628
|
+
isElementManipulable: (s) => this.isElementManipulable(s),
|
|
3491
4629
|
isLayerLocked: (id) => this.isLayerLocked(id),
|
|
3492
4630
|
promoteToGroupRoot: (s) => this.promoteToGroupRoot(s),
|
|
3493
4631
|
});
|
|
@@ -3542,10 +4680,12 @@ export class Editor {
|
|
|
3542
4680
|
return false;
|
|
3543
4681
|
if (isElementHidden(this._scene, shape))
|
|
3544
4682
|
return false;
|
|
3545
|
-
//
|
|
3546
|
-
//
|
|
3547
|
-
//
|
|
3548
|
-
//
|
|
4683
|
+
// A locked element is click-through: pointer hits pass to the shapes
|
|
4684
|
+
// beneath it (the hit-test scans past rejected candidates). Unlocking
|
|
4685
|
+
// goes through the context menu (`lockedElementAt` + `unlockElement`),
|
|
4686
|
+
// not through selection.
|
|
4687
|
+
if (isElementLocked(this._scene, shape))
|
|
4688
|
+
return false;
|
|
3549
4689
|
return true;
|
|
3550
4690
|
}
|
|
3551
4691
|
/**
|
|
@@ -3590,8 +4730,56 @@ export class Editor {
|
|
|
3590
4730
|
tx.add(r.patch);
|
|
3591
4731
|
}
|
|
3592
4732
|
tx.commit();
|
|
4733
|
+
// Locked elements are click-through (not selectable) — keeping them
|
|
4734
|
+
// selected would leave a live toolbar on an element clicks can no
|
|
4735
|
+
// longer reach. Locking therefore drops the selection.
|
|
4736
|
+
if (anyUnlocked)
|
|
4737
|
+
this._selection = Selection.EMPTY;
|
|
3593
4738
|
this.notify();
|
|
3594
4739
|
}
|
|
4740
|
+
/**
|
|
4741
|
+
* Topmost locked (own or inherited `locked`) shape at `worldPoint`, on a
|
|
4742
|
+
* visible, unlocked layer. Locked shapes are click-through for normal
|
|
4743
|
+
* interaction, so this is the dedicated lookup for affordances that must
|
|
4744
|
+
* still find them — the context menu's Unlock entry.
|
|
4745
|
+
*/
|
|
4746
|
+
lockedElementAt(worldPoint) {
|
|
4747
|
+
const shape = this.acceleratedElementAt(worldPoint, (s) => !this.isLayerLocked(s.layerId) &&
|
|
4748
|
+
!isElementHidden(this._scene, s) &&
|
|
4749
|
+
isElementLocked(this._scene, s));
|
|
4750
|
+
return shape ?? null;
|
|
4751
|
+
}
|
|
4752
|
+
/**
|
|
4753
|
+
* Clear the `locked` flag on the shape that carries it. When the flag
|
|
4754
|
+
* lives on an ancestor (group lock propagation), the closest locked
|
|
4755
|
+
* ancestor is unlocked instead, so the whole locked unit is released.
|
|
4756
|
+
* Selects the unlocked shape afterwards — the natural next step after
|
|
4757
|
+
* an unlock is editing it. One undo step.
|
|
4758
|
+
*/
|
|
4759
|
+
unlockElement(id) {
|
|
4760
|
+
if (this.readOnly)
|
|
4761
|
+
return;
|
|
4762
|
+
let target = getElement(this._scene, id);
|
|
4763
|
+
if (!target || !isElementLocked(this._scene, target))
|
|
4764
|
+
return;
|
|
4765
|
+
// Walk up to the shape that actually carries the flag.
|
|
4766
|
+
while (target.locked !== true && target.parentId !== undefined) {
|
|
4767
|
+
const parent = getElement(this._scene, target.parentId);
|
|
4768
|
+
if (!parent)
|
|
4769
|
+
break;
|
|
4770
|
+
target = parent;
|
|
4771
|
+
}
|
|
4772
|
+
if (target.locked !== true)
|
|
4773
|
+
return;
|
|
4774
|
+
const r = updateElement(this._scene, target.id, (s) => {
|
|
4775
|
+
const copy = { ...s };
|
|
4776
|
+
delete copy.locked;
|
|
4777
|
+
return copy;
|
|
4778
|
+
});
|
|
4779
|
+
this._scene = r.scene;
|
|
4780
|
+
this._history.push(r.patch);
|
|
4781
|
+
this.setSelection([target.id]);
|
|
4782
|
+
}
|
|
3595
4783
|
/**
|
|
3596
4784
|
* Promote a hit shape to the topmost ancestor whose group we have NOT
|
|
3597
4785
|
* "entered" yet. Only **`group`**-typed parents promote — containers
|
|
@@ -3754,11 +4942,11 @@ export class Editor {
|
|
|
3754
4942
|
* scene-identity. Scene operations replace `_scene` (immutable patches),
|
|
3755
4943
|
* so reference-equality is a sufficient invalidation signal.
|
|
3756
4944
|
*/
|
|
3757
|
-
acceleratedElementAt(worldPoint) {
|
|
4945
|
+
acceleratedElementAt(worldPoint, accept) {
|
|
3758
4946
|
if (this._scene.elements.size < LARGE_SCENE_HIT_THRESHOLD) {
|
|
3759
|
-
return getElementAt(this._scene, worldPoint);
|
|
4947
|
+
return getElementAt(this._scene, worldPoint, accept);
|
|
3760
4948
|
}
|
|
3761
|
-
return getElementAtIndexed(this._scene, this.ensureSpatialIndex(), worldPoint);
|
|
4949
|
+
return getElementAtIndexed(this._scene, this.ensureSpatialIndex(), worldPoint, accept);
|
|
3762
4950
|
}
|
|
3763
4951
|
/**
|
|
3764
4952
|
* Build (or return the cached) `SpatialGrid` for the current scene.
|
|
@@ -3801,9 +4989,7 @@ export class Editor {
|
|
|
3801
4989
|
*/
|
|
3802
4990
|
routeIsolationClick(clickEffect, worldPoint) {
|
|
3803
4991
|
const now = performance.now();
|
|
3804
|
-
const isDouble =
|
|
3805
|
-
this.interaction.lastClickWorldPoint !== null &&
|
|
3806
|
-
vec2.distance(this.interaction.lastClickWorldPoint, worldPoint) <= DOUBLE_CLICK_TOLERANCE_PX;
|
|
4992
|
+
const isDouble = this.interaction.isDoubleClickAt(worldPoint, now);
|
|
3807
4993
|
this.interaction.lastClickAt = now;
|
|
3808
4994
|
this.interaction.lastClickWorldPoint = worldPoint;
|
|
3809
4995
|
// Double-click the frame HEADER (label strip above the body) → rename.
|
|
@@ -3852,6 +5038,12 @@ export class Editor {
|
|
|
3852
5038
|
const raw = this.acceleratedElementAt(worldPoint);
|
|
3853
5039
|
if (raw !== undefined && isText(raw)) {
|
|
3854
5040
|
this.beginTextEdit(raw.id);
|
|
5041
|
+
// Caret lands where the user double-clicked, not at the text end.
|
|
5042
|
+
// Collapsed selection WITHOUT a drag anchor — the double-click's
|
|
5043
|
+
// own pointer movement must not start a drag-select.
|
|
5044
|
+
const caretIdx = this.caretIndexAtWorldPoint(worldPoint);
|
|
5045
|
+
if (caretIdx !== null)
|
|
5046
|
+
this.setEditingSelection(caretIdx, caretIdx);
|
|
3855
5047
|
return true;
|
|
3856
5048
|
}
|
|
3857
5049
|
if (raw !== undefined && isFrame(raw)) {
|
|
@@ -3871,6 +5063,18 @@ export class Editor {
|
|
|
3871
5063
|
this.notify();
|
|
3872
5064
|
return true;
|
|
3873
5065
|
}
|
|
5066
|
+
// No group to drill into — double-click on a labelable shape
|
|
5067
|
+
// opens its embedded label editor (creating an empty label on
|
|
5068
|
+
// the first edit).
|
|
5069
|
+
if (canCarryLabel(raw)) {
|
|
5070
|
+
this.beginTextEdit(raw.id);
|
|
5071
|
+
// Same as text elements: the caret starts at the click point —
|
|
5072
|
+
// important for long labels whose tail is clipped by the shape.
|
|
5073
|
+
const labelCaretIdx = this.caretIndexAtWorldPoint(worldPoint);
|
|
5074
|
+
if (labelCaretIdx !== null)
|
|
5075
|
+
this.setEditingSelection(labelCaretIdx, labelCaretIdx);
|
|
5076
|
+
return true;
|
|
5077
|
+
}
|
|
3874
5078
|
}
|
|
3875
5079
|
}
|
|
3876
5080
|
return false;
|
|
@@ -4066,7 +5270,9 @@ export class Editor {
|
|
|
4066
5270
|
return;
|
|
4067
5271
|
// Shift constrains the drag to one axis before snapping.
|
|
4068
5272
|
const moved = this.interaction.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
|
|
4069
|
-
|
|
5273
|
+
// Rotated shapes keep their AABB-based snapping (edges still align in
|
|
5274
|
+
// world space); the object pass is skipped only when nothing is nearby.
|
|
5275
|
+
const d = this.snapMoveDeltaFor(new Set([id]), originalBounds, moved);
|
|
4070
5276
|
const patch = computeElementMovePatch(this._scene, id, d, originalBounds);
|
|
4071
5277
|
if (!patch)
|
|
4072
5278
|
return;
|
|
@@ -4079,9 +5285,7 @@ export class Editor {
|
|
|
4079
5285
|
return;
|
|
4080
5286
|
// Shift constrains the drag to one axis before snapping.
|
|
4081
5287
|
const moved = this.interaction.transformShiftKey ? constrainDeltaToAxis(delta) : delta;
|
|
4082
|
-
const d = this.
|
|
4083
|
-
? snapGroupDelta(this.groupMoveOrigin, moved, this.snapSpacing())
|
|
4084
|
-
: moved;
|
|
5288
|
+
const d = this.snapGroupMoveDelta(this.groupMoveOrigin, moved);
|
|
4085
5289
|
const patches = computeGroupMovePatches(this._scene, this.groupMoveOrigin, d);
|
|
4086
5290
|
for (const patch of patches) {
|
|
4087
5291
|
this._scene = apply(this._scene, patch);
|
|
@@ -4174,6 +5378,10 @@ export class Editor {
|
|
|
4174
5378
|
// repaint when the entered-group identity changes between frames.
|
|
4175
5379
|
if (this.lastRenderedEnteredGroup !== this._enteredGroup)
|
|
4176
5380
|
return null;
|
|
5381
|
+
// Hover transition on a sticky toggles the "+" chrome without a scene
|
|
5382
|
+
// change — force a repaint frame for it.
|
|
5383
|
+
if (this.lastRenderedHoveredSticky !== this._hoveredStickyId)
|
|
5384
|
+
return null;
|
|
4177
5385
|
// Eraser just STOPPED (the active-stroke guard above already returned, so
|
|
4178
5386
|
// `eraseStroke` is null here): on an Esc-cancel the marked shapes un-dim
|
|
4179
5387
|
// without a scene change — commit changes the scene and is caught by the
|
|
@@ -4274,9 +5482,7 @@ export class Editor {
|
|
|
4274
5482
|
applyGroupResize(handle, delta, originalBounds) {
|
|
4275
5483
|
if (!this.groupResizeOrigin)
|
|
4276
5484
|
return;
|
|
4277
|
-
const d = this.
|
|
4278
|
-
? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
|
|
4279
|
-
: delta;
|
|
5485
|
+
const d = this.snapResizeDeltaFor(new Set(this.groupResizeOrigin.elements.keys()), originalBounds, handle, delta);
|
|
4280
5486
|
const result = computeGroupResizePatches(this._scene, this.groupResizeOrigin, handle, d, originalBounds,
|
|
4281
5487
|
// Aspect-locked when the selection type demands it (images / groups) or
|
|
4282
5488
|
// the user holds Shift for this gesture.
|
|
@@ -4284,6 +5490,7 @@ export class Editor {
|
|
|
4284
5490
|
this._scene = result.scene;
|
|
4285
5491
|
for (const patch of result.patches)
|
|
4286
5492
|
this.recordGesturePatch(patch);
|
|
5493
|
+
this.setSizeReadout(this.combinedSelectionBounds());
|
|
4287
5494
|
this.notify();
|
|
4288
5495
|
}
|
|
4289
5496
|
applyResize(id, handle, delta, originalBounds) {
|
|
@@ -4301,12 +5508,11 @@ export class Editor {
|
|
|
4301
5508
|
return;
|
|
4302
5509
|
this._scene = result.scene;
|
|
4303
5510
|
this.recordGesturePatch(result.patch);
|
|
5511
|
+
this.setSizeReadout(this.resizedBounds(id));
|
|
4304
5512
|
this.notify();
|
|
4305
5513
|
return;
|
|
4306
5514
|
}
|
|
4307
|
-
const d = this.
|
|
4308
|
-
? snapResizeDelta(originalBounds, handle, delta, this.snapSpacing())
|
|
4309
|
-
: delta;
|
|
5515
|
+
const d = this.snapResizeDeltaFor(new Set([id]), originalBounds, handle, delta);
|
|
4310
5516
|
// Text: aspect-locked font scaling. Snapshot the pristine shape on
|
|
4311
5517
|
// the gesture's first tick so the scale base never compounds.
|
|
4312
5518
|
if (shape !== undefined && isText(shape)) {
|
|
@@ -4322,6 +5528,7 @@ export class Editor {
|
|
|
4322
5528
|
return;
|
|
4323
5529
|
this._scene = result.scene;
|
|
4324
5530
|
this.recordGesturePatch(result.patch);
|
|
5531
|
+
this.setSizeReadout(this.resizedBounds(id));
|
|
4325
5532
|
this.notify();
|
|
4326
5533
|
return;
|
|
4327
5534
|
}
|
|
@@ -4330,12 +5537,32 @@ export class Editor {
|
|
|
4330
5537
|
return;
|
|
4331
5538
|
this._scene = result.scene;
|
|
4332
5539
|
this.recordGesturePatch(result.patch);
|
|
5540
|
+
this.setSizeReadout(this.resizedBounds(id));
|
|
4333
5541
|
this.notify();
|
|
4334
5542
|
}
|
|
5543
|
+
/** World AABB of `id` after a resize tick (null when the shape is gone). */
|
|
5544
|
+
resizedBounds(id) {
|
|
5545
|
+
const shape = getElement(this._scene, id);
|
|
5546
|
+
return shape ? getElementWorldBounds(shape) : null;
|
|
5547
|
+
}
|
|
5548
|
+
/**
|
|
5549
|
+
* Snap freshly-drawn bounds onto the grid when snapping is active.
|
|
5550
|
+
* Shared by the live rubber-band preview and the final CREATE so the
|
|
5551
|
+
* preview shows exactly where the shape will land (parity with how
|
|
5552
|
+
* move / resize snap live during the gesture).
|
|
5553
|
+
*/
|
|
5554
|
+
snapCreateBoundsIfActive(bounds) {
|
|
5555
|
+
return this.snapActive() ? snapCreateBounds(bounds, this.snapSpacing()) : bounds;
|
|
5556
|
+
}
|
|
4335
5557
|
applyCreate(kind, bounds) {
|
|
4336
5558
|
const id = newElementId(++this.nextId);
|
|
4337
|
-
const b = this.
|
|
4338
|
-
|
|
5559
|
+
const b = this.snapCreateBoundsIfActive(bounds);
|
|
5560
|
+
// The machine only knows the MODE ("rect" for draw-rect); the flyout
|
|
5561
|
+
// may have armed a diamond / triangle variant on top of it.
|
|
5562
|
+
const effectiveKind = kind === "rect" && this._drawShapeKind !== "rect" && this._drawShapeKind !== "ellipse"
|
|
5563
|
+
? this._drawShapeKind
|
|
5564
|
+
: kind;
|
|
5565
|
+
const result = computeCreateElement(this._scene, effectiveKind, b, id, this._activeLayerId, () => this.nextFrameName());
|
|
4339
5566
|
this._scene = result.scene;
|
|
4340
5567
|
this._selection = Selection.single(id);
|
|
4341
5568
|
// CREATE is a single-shot operation, not part of a multi-tick gesture.
|
|
@@ -4376,7 +5603,7 @@ export class Editor {
|
|
|
4376
5603
|
const from = this.snapLinkEndpoint(emit.fromElement, emit.fromPoint);
|
|
4377
5604
|
const to = this.snapLinkEndpoint(emit.toElement, emit.toPoint);
|
|
4378
5605
|
const id = newLinkId(++this.nextId);
|
|
4379
|
-
const result = computeCreateLink(this._scene, from, to, id, this._activeLayerId);
|
|
5606
|
+
const result = computeCreateLink(this._scene, from, to, id, this._activeLayerId, this._linkDrawPreset !== null ? LINK_DRAW_PRESETS[this._linkDrawPreset] : undefined);
|
|
4380
5607
|
this._scene = result.scene;
|
|
4381
5608
|
this._history.push(result.patch);
|
|
4382
5609
|
this.edgePreview = null;
|
|
@@ -4477,7 +5704,7 @@ export class Editor {
|
|
|
4477
5704
|
// The wrappers here own the side effects (`_selectedLink`
|
|
4478
5705
|
// clearing, notify).
|
|
4479
5706
|
applySelectByBounds(bounds, mode) {
|
|
4480
|
-
const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode);
|
|
5707
|
+
const next = selectByBoundsPure(this._scene, this._selection, (id) => this.isLayerLocked(id), bounds, mode, (shape) => this.promoteToGroupRoot(shape));
|
|
4481
5708
|
const nextLinks = selectLinksByBoundsLivePure(this._scene, mode === "add" ? this._selectedLinks : LinkSelection.EMPTY, (id) => this.isLayerLocked(id), bounds, mode);
|
|
4482
5709
|
const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
|
|
4483
5710
|
this._selectedLinks = nextLinks;
|
|
@@ -4490,7 +5717,7 @@ export class Editor {
|
|
|
4490
5717
|
}
|
|
4491
5718
|
applyLassoLiveSelection(bounds, mode) {
|
|
4492
5719
|
const base = this.interaction.lassoBaseSelection ?? Selection.EMPTY;
|
|
4493
|
-
const next = selectByBoundsLivePure(this._scene, base, (id) => this.isLayerLocked(id), bounds, mode);
|
|
5720
|
+
const next = selectByBoundsLivePure(this._scene, base, (id) => this.isLayerLocked(id), bounds, mode, (shape) => this.promoteToGroupRoot(shape));
|
|
4494
5721
|
const linkBase = this.interaction.lassoBaseLinks ?? LinkSelection.EMPTY;
|
|
4495
5722
|
const nextLinks = selectLinksByBoundsLivePure(this._scene, linkBase, (id) => this.isLayerLocked(id), bounds, mode);
|
|
4496
5723
|
const linksChanged = !LinkSelection.equals(nextLinks, this._selectedLinks);
|
|
@@ -4687,9 +5914,12 @@ export class Editor {
|
|
|
4687
5914
|
}
|
|
4688
5915
|
applyLinkPreview(fromElement, fromPoint, toPoint) {
|
|
4689
5916
|
const ep = computeLinkPreviewEndpoints(this._scene, fromElement, fromPoint, toPoint);
|
|
4690
|
-
// Match the preview to the connector that will be committed:
|
|
4691
|
-
//
|
|
4692
|
-
|
|
5917
|
+
// Match the preview to the connector that will be committed: elbow
|
|
5918
|
+
// presets/defaults draw the orthogonal route, straight presets a line.
|
|
5919
|
+
const previewRouting = this._linkDrawPreset !== null
|
|
5920
|
+
? LINK_DRAW_PRESETS[this._linkDrawPreset].routing
|
|
5921
|
+
: DEFAULT_LINK_ROUTING;
|
|
5922
|
+
if (previewRouting === "orthogonal") {
|
|
4693
5923
|
const hit = this.hitTest(ep.to);
|
|
4694
5924
|
const toElement = hit.kind === "element" ? hit.id : null;
|
|
4695
5925
|
const points = routeElbowPreview(this._scene, fromElement, ep.from, toElement, ep.to);
|
|
@@ -4713,6 +5943,7 @@ export class Editor {
|
|
|
4713
5943
|
}
|
|
4714
5944
|
commitGesture() {
|
|
4715
5945
|
this.interaction.resizeOriginElement = null;
|
|
5946
|
+
this.interaction.clearSnapAssists();
|
|
4716
5947
|
this.rotateGestureOrigin = null;
|
|
4717
5948
|
this.gestures.commit();
|
|
4718
5949
|
this.gestureStartScene = null;
|
|
@@ -4758,6 +5989,7 @@ export class Editor {
|
|
|
4758
5989
|
}
|
|
4759
5990
|
cancelGesture() {
|
|
4760
5991
|
this.interaction.resizeOriginElement = null;
|
|
5992
|
+
this.interaction.clearSnapAssists();
|
|
4761
5993
|
this.interaction.rotateGestureOrigin = null;
|
|
4762
5994
|
this.gestures.cancel();
|
|
4763
5995
|
// Roll the scene back to the pre-gesture snapshot — cancelling the history
|
|
@@ -4801,6 +6033,13 @@ export class Editor {
|
|
|
4801
6033
|
* into a single render per frame.
|
|
4802
6034
|
*/
|
|
4803
6035
|
renderRafId = null;
|
|
6036
|
+
/**
|
|
6037
|
+
* Set by {@link dispose}. Render scheduling bails out when set:
|
|
6038
|
+
* async completions (image decode, font load, wasm init) can resolve
|
|
6039
|
+
* after the host tears the editor down (e.g. a runtime backend
|
|
6040
|
+
* switch) and must not paint onto disposed render targets.
|
|
6041
|
+
*/
|
|
6042
|
+
disposed = false;
|
|
4804
6043
|
/** Unsubscribe for the animation-content-ready listener (decode → re-render). */
|
|
4805
6044
|
animationContentOff = null;
|
|
4806
6045
|
/**
|
|
@@ -4816,6 +6055,8 @@ export class Editor {
|
|
|
4816
6055
|
* compare bitmap output after a mutation).
|
|
4817
6056
|
*/
|
|
4818
6057
|
scheduleRender() {
|
|
6058
|
+
if (this.disposed)
|
|
6059
|
+
return; // async completions (image decode, font load) may outlive the editor
|
|
4819
6060
|
if (this.renderRafId !== null)
|
|
4820
6061
|
return;
|
|
4821
6062
|
if (typeof requestAnimationFrame === "undefined") {
|
|
@@ -4841,6 +6082,8 @@ export class Editor {
|
|
|
4841
6082
|
* Normal interactive flows should let `scheduleRender` do its job.
|
|
4842
6083
|
*/
|
|
4843
6084
|
forceRender() {
|
|
6085
|
+
if (this.disposed)
|
|
6086
|
+
return;
|
|
4844
6087
|
if (this.renderRafId !== null && typeof cancelAnimationFrame !== "undefined") {
|
|
4845
6088
|
cancelAnimationFrame(this.renderRafId);
|
|
4846
6089
|
this.renderRafId = null;
|
|
@@ -5014,10 +6257,17 @@ export class Editor {
|
|
|
5014
6257
|
peerSelections: this._peerSelections,
|
|
5015
6258
|
debugHitZones: this.debugHitZones,
|
|
5016
6259
|
readOnly: this._readOnly,
|
|
6260
|
+
hoveredStickyId: this._hoveredStickyId,
|
|
6261
|
+
linkDrawOverrides: this._linkDrawPreset !== null ? LINK_DRAW_PRESETS[this._linkDrawPreset] : undefined,
|
|
5017
6262
|
groupMoveOrigin: this.groupMoveOrigin,
|
|
5018
6263
|
aspectLocked: this.selectionIsAspectLocked(),
|
|
5019
6264
|
combinedSelectionBounds: this.combinedSelectionBounds(),
|
|
5020
6265
|
editingText: this.editingTextOverlay(),
|
|
6266
|
+
snapGuides: this.interaction.snapGuides,
|
|
6267
|
+
sizeReadout: this.interaction.sizeReadout,
|
|
6268
|
+
sizeMatch: this.interaction.sizeMatch,
|
|
6269
|
+
showDistances: this._preferences.showObjectSize,
|
|
6270
|
+
isElementManipulable: (s) => this.isElementManipulable(s),
|
|
5021
6271
|
previewClickCreate: (fromElement, anchorName) => this.previewClickCreate(fromElement, anchorName),
|
|
5022
6272
|
isPlaybackPaused: (id) => this.isPlaybackPaused(id),
|
|
5023
6273
|
};
|
|
@@ -5038,6 +6288,7 @@ export class Editor {
|
|
|
5038
6288
|
// and, on the tile-cache path, clear the consumed dirty set.
|
|
5039
6289
|
this.lastRenderedScene = this._scene;
|
|
5040
6290
|
this.lastRenderedEnteredGroup = this._enteredGroup;
|
|
6291
|
+
this.lastRenderedHoveredSticky = this._hoveredStickyId;
|
|
5041
6292
|
this.lastRenderedEraseActive = snapshot.eraseActive;
|
|
5042
6293
|
// The forced erase repaint (if any) has now happened — later idle frames
|
|
5043
6294
|
// (cursor moving / trail fading) skip the full main pass until the next cut.
|