@hyperframes/studio 0.7.54 → 0.7.56
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/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
- package/dist/assets/index-CmVCjZjp.js +423 -0
- package/dist/assets/index-D-GyYi2d.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +146 -144
- package/dist/index.html +2 -2
- package/dist/index.js +40582 -37803
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +158 -126
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +43 -1
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +240 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.tsx +326 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +39 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +34 -1
- package/src/hooks/timelineEditingHelpers.ts +22 -48
- package/src/hooks/timelineMoveAdapter.test.ts +77 -0
- package/src/hooks/timelineMoveAdapter.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +154 -1
- package/src/hooks/useDomEditCommits.ts +100 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
- package/src/hooks/useElementLifecycleOps.ts +40 -71
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +303 -0
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineEditing.test.tsx +20 -29
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +18 -4
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayheadIndicator.tsx +50 -5
- package/src/player/components/Timeline.test.ts +102 -19
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +207 -521
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +943 -0
- package/src/player/components/timelineClipDragCommit.ts +401 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +477 -0
- package/src/player/components/timelineCollision.ts +263 -0
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +120 -61
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
- package/src/player/components/useTimelineStackingSync.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.ts +48 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/player/index.ts +3 -2
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +12 -81
- package/src/player/lib/timelineElementHelpers.ts +54 -5
- package/src/player/lib/timelineIframeHelpers.ts +2 -0
- package/src/player/store/playerStore.ts +62 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/assets/index-pRhCpGPz.js +0 -423
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure z-order helpers for the canvas right-click context menu.
|
|
3
|
+
*
|
|
4
|
+
* Layering strategy: z-index + CSS stacking context (position ≠ static).
|
|
5
|
+
* All sibling z-index values are read from the live iframe DOM via
|
|
6
|
+
* element.style.zIndex (inline style, set by the editor) falling back to
|
|
7
|
+
* the computed value. Treat missing / "auto" as 0 for comparison purposes.
|
|
8
|
+
*
|
|
9
|
+
* "Overlapping siblings" = siblings whose bounding rects intersect the
|
|
10
|
+
* target's bounding rect. Forward/backward operate within that set;
|
|
11
|
+
* front/back operate across all siblings.
|
|
12
|
+
*
|
|
13
|
+
* ── Tie-awareness ────────────────────────────────────────────────────────────
|
|
14
|
+
* CSS paint order for elements that share a z-index is DOM document order:
|
|
15
|
+
* the element that comes LATER in the DOM paints ON TOP. The old resolver
|
|
16
|
+
* compared z-index alone, so a target tied with the element visually below it
|
|
17
|
+
* (equal z, target later in DOM) had an empty "below" set and silently
|
|
18
|
+
* no-op'd. This module computes true render order — sort by
|
|
19
|
+
* (zIndex asc, DOM position asc), bottom→top — moves the target one step (or
|
|
20
|
+
* to an end) in that order, then realizes the new order back into z values.
|
|
21
|
+
*
|
|
22
|
+
* The result is a MULTI-element patch: a single-element patch when a
|
|
23
|
+
* strictly-between z value can express the new order given DOM-order
|
|
24
|
+
* tie-breaking, otherwise a minimal renumber of the affected set (emitting
|
|
25
|
+
* patches only for elements whose z actually changes). z is never negative
|
|
26
|
+
* (project convention clamps z ≥ 0).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
|
|
30
|
+
|
|
31
|
+
/** A resolved change: set `element`'s z-index to `zIndex`. */
|
|
32
|
+
export interface ZOrderPatch {
|
|
33
|
+
element: HTMLElement;
|
|
34
|
+
zIndex: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface RenderEntry {
|
|
38
|
+
element: HTMLElement;
|
|
39
|
+
zIndex: number;
|
|
40
|
+
/** Position within the shared parent's children (DOM document order). */
|
|
41
|
+
domIndex: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Parse a z-index string to a number; treats "auto" / empty as 0. */
|
|
45
|
+
export function parseZIndex(value: string | null | undefined): number {
|
|
46
|
+
if (!value || value === "auto") return 0;
|
|
47
|
+
const n = parseInt(value, 10);
|
|
48
|
+
return Number.isFinite(n) ? n : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read the effective z-index for an element (inline style preferred). */
|
|
52
|
+
export function readEffectiveZIndex(el: HTMLElement): number {
|
|
53
|
+
const inline = el.style.zIndex;
|
|
54
|
+
if (inline && inline !== "auto") return parseZIndex(inline);
|
|
55
|
+
try {
|
|
56
|
+
const win = el.ownerDocument?.defaultView;
|
|
57
|
+
if (win) return parseZIndex(win.getComputedStyle(el).zIndex);
|
|
58
|
+
} catch {
|
|
59
|
+
/* cross-origin / detached */
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Realm-safe HTMLElement check. The target lives in the preview IFRAME's
|
|
66
|
+
* document, but this module runs in the top window, so `child instanceof
|
|
67
|
+
* HTMLElement` (top-window constructor) is ALWAYS false for iframe elements —
|
|
68
|
+
* which silently emptied the sibling list and left every z-order action
|
|
69
|
+
* permanently disabled. Compare against the element's own realm instead, with
|
|
70
|
+
* a nodeType fallback for detached / cross-realm edge cases.
|
|
71
|
+
*/
|
|
72
|
+
function isElementNode(node: Node): node is HTMLElement {
|
|
73
|
+
const view = node.ownerDocument?.defaultView;
|
|
74
|
+
if (view && node instanceof view.HTMLElement) return true;
|
|
75
|
+
return node.nodeType === 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Tags that never paint pixels and so must be excluded from z-order siblings.
|
|
80
|
+
* `<audio>` is the real offender here: a prior renumber wrote a meaningless
|
|
81
|
+
* z-index onto the qa-clean audio element, and counting it as a sibling skews the
|
|
82
|
+
* renumber for the visible elements. `<script>/<style>/<link>/<meta>` are also
|
|
83
|
+
* non-painting and could otherwise pad the family / eat a z slot.
|
|
84
|
+
*/
|
|
85
|
+
const NON_PAINTING_TAGS = new Set(["AUDIO", "SCRIPT", "STYLE", "LINK", "META"]);
|
|
86
|
+
|
|
87
|
+
/** A painting element: an element node whose tag actually renders pixels. */
|
|
88
|
+
function isPaintingElement(node: Node): node is HTMLElement {
|
|
89
|
+
return isElementNode(node) && !NON_PAINTING_TAGS.has(node.tagName);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Collect the target plus every PAINTING HTMLElement sibling (same parent),
|
|
94
|
+
* tagged with DOM document position. Non-painting siblings (audio/script/style/
|
|
95
|
+
* link/meta) are skipped so they neither pad the family nor consume a z slot in
|
|
96
|
+
* the renumber path. Returns the target's own index within the result.
|
|
97
|
+
*/
|
|
98
|
+
function getFamily(target: HTMLElement): { entries: RenderEntry[]; targetIndex: number } {
|
|
99
|
+
const parent = target.parentElement;
|
|
100
|
+
if (!parent) return { entries: [], targetIndex: -1 };
|
|
101
|
+
const entries: RenderEntry[] = [];
|
|
102
|
+
let targetIndex = -1;
|
|
103
|
+
let domIndex = 0;
|
|
104
|
+
for (const child of Array.from(parent.children)) {
|
|
105
|
+
// The target is always retained even if its own tag is non-painting.
|
|
106
|
+
if (child !== target && !isPaintingElement(child)) continue;
|
|
107
|
+
if (!isElementNode(child)) continue;
|
|
108
|
+
if (child === target) targetIndex = entries.length;
|
|
109
|
+
entries.push({ element: child, zIndex: readEffectiveZIndex(child), domIndex });
|
|
110
|
+
domIndex += 1;
|
|
111
|
+
}
|
|
112
|
+
return { entries, targetIndex };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** True if two DOM bounding rects intersect (even if touching). */
|
|
116
|
+
function rectsIntersect(
|
|
117
|
+
a: { left: number; top: number; right: number; bottom: number },
|
|
118
|
+
b: { left: number; top: number; right: number; bottom: number },
|
|
119
|
+
): boolean {
|
|
120
|
+
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Restrict a family to the target plus siblings whose bounding rect overlaps
|
|
125
|
+
* the target's rect. The target is always retained. If the target's rect is
|
|
126
|
+
* unavailable or empty (headless / happy-dom returns 0×0), all entries are
|
|
127
|
+
* kept — matching the prior behavior.
|
|
128
|
+
*/
|
|
129
|
+
function getOverlappingFamily(target: HTMLElement, entries: RenderEntry[]): RenderEntry[] {
|
|
130
|
+
let targetRect: DOMRect;
|
|
131
|
+
try {
|
|
132
|
+
targetRect = target.getBoundingClientRect();
|
|
133
|
+
} catch {
|
|
134
|
+
return entries;
|
|
135
|
+
}
|
|
136
|
+
if (targetRect.width === 0 && targetRect.height === 0) return entries;
|
|
137
|
+
const tr = {
|
|
138
|
+
left: targetRect.left,
|
|
139
|
+
top: targetRect.top,
|
|
140
|
+
right: targetRect.right,
|
|
141
|
+
bottom: targetRect.bottom,
|
|
142
|
+
};
|
|
143
|
+
return entries.filter((entry) => {
|
|
144
|
+
if (entry.element === target) return true;
|
|
145
|
+
try {
|
|
146
|
+
const r = entry.element.getBoundingClientRect();
|
|
147
|
+
return rectsIntersect(tr, { left: r.left, top: r.top, right: r.right, bottom: r.bottom });
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Sort a family into render order (bottom→top): z asc, then DOM position asc. */
|
|
155
|
+
function toRenderOrder(entries: RenderEntry[]): RenderEntry[] {
|
|
156
|
+
return [...entries].sort((a, b) => a.zIndex - b.zIndex || a.domIndex - b.domIndex);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A z that lands the target strictly between `below` and `above` in render order,
|
|
161
|
+
* or null when no such value exists (a tie-prone gap, or no room below the floor)
|
|
162
|
+
* and the caller must renumber. Equal-z ties break by DOM order, so a plain
|
|
163
|
+
* equality can flip order unpredictably; require a strict gap and clamp at 0.
|
|
164
|
+
*/
|
|
165
|
+
function computeBetweenZ(
|
|
166
|
+
below: RenderEntry | undefined,
|
|
167
|
+
above: RenderEntry | undefined,
|
|
168
|
+
): number | null {
|
|
169
|
+
if (below && above) {
|
|
170
|
+
return above.zIndex - below.zIndex >= 2 ? below.zIndex + 1 : null;
|
|
171
|
+
}
|
|
172
|
+
if (below) return below.zIndex + 1; // move to top
|
|
173
|
+
if (above) {
|
|
174
|
+
const candidate = Math.max(0, above.zIndex - 1); // move to bottom
|
|
175
|
+
return candidate >= above.zIndex ? null : candidate; // no room below → renumber
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Realize a desired render order (bottom→top) into z-index patches for the
|
|
182
|
+
* given family, emitting patches ONLY for elements whose z actually changes.
|
|
183
|
+
*
|
|
184
|
+
* Fast path: if the SCOPED z values are all distinct, the render order is fully
|
|
185
|
+
* determined by z alone — a single-element move can be expressed by placing the
|
|
186
|
+
* target's z strictly between its new neighbours (or at an end), so at most one
|
|
187
|
+
* element changes (and it never disturbs an untouched pair, since only the target
|
|
188
|
+
* moves). When ties exist a between value can be impossible, so renumber — but the
|
|
189
|
+
* scoped set is only a SUBSET of the family (the target's overlapping siblings),
|
|
190
|
+
* so a naive 0..n-1 renumber can drop a scoped sibling below an untouched
|
|
191
|
+
* non-scoped one, reordering an untouched pair (#2202). `renumberScoped` keeps the
|
|
192
|
+
* scoped block inside its original z-band, bounded by the non-scoped siblings.
|
|
193
|
+
*/
|
|
194
|
+
function realizeOrder(
|
|
195
|
+
currentOrder: RenderEntry[],
|
|
196
|
+
desiredOrder: RenderEntry[],
|
|
197
|
+
target: HTMLElement,
|
|
198
|
+
family: RenderEntry[],
|
|
199
|
+
): ZOrderPatch[] | null {
|
|
200
|
+
const targetPos = desiredOrder.findIndex((e) => e.element === target);
|
|
201
|
+
if (targetPos === -1) return null;
|
|
202
|
+
|
|
203
|
+
const targetZ = readEffectiveZIndex(target);
|
|
204
|
+
|
|
205
|
+
// ── Fast path: distinct z values → a single between-value move suffices.
|
|
206
|
+
const zValues = currentOrder.map((e) => e.zIndex);
|
|
207
|
+
const hasDupes = zValues.some((v, i) => zValues.indexOf(v) !== i);
|
|
208
|
+
if (!hasDupes) {
|
|
209
|
+
const candidate = computeBetweenZ(desiredOrder[targetPos - 1], desiredOrder[targetPos + 1]);
|
|
210
|
+
if (candidate !== null) {
|
|
211
|
+
if (candidate === targetZ) return null;
|
|
212
|
+
return [{ element: target, zIndex: candidate }];
|
|
213
|
+
}
|
|
214
|
+
// else fall through to renumber
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return renumberScoped(currentOrder, desiredOrder, target, family);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Renumber the SCOPED set (the reordered subset) to distinct z, keeping the whole
|
|
222
|
+
* block within the band its members already occupied so no untouched scoped /
|
|
223
|
+
* non-scoped pair is reordered (#2202). The block is placed near its original base
|
|
224
|
+
* `lo`, but clamped to sit strictly above the highest non-scoped sibling below the
|
|
225
|
+
* band and strictly below the lowest non-scoped sibling above it. Only scoped
|
|
226
|
+
* members are patched; non-scoped siblings keep their authored z.
|
|
227
|
+
*
|
|
228
|
+
* If a non-scoped sibling sits INSIDE or tied to the band (no clean bracket), or
|
|
229
|
+
* the bracket is too narrow to hold `n` distinct integers, fall back to a
|
|
230
|
+
* whole-family renumber — less minimal but still preserves every relative order.
|
|
231
|
+
*/
|
|
232
|
+
function renumberScoped(
|
|
233
|
+
currentOrder: RenderEntry[],
|
|
234
|
+
desiredOrder: RenderEntry[],
|
|
235
|
+
target: HTMLElement,
|
|
236
|
+
family: RenderEntry[],
|
|
237
|
+
): ZOrderPatch[] | null {
|
|
238
|
+
const scoped = new Set(desiredOrder.map((e) => e.element));
|
|
239
|
+
const nonScoped = family.filter((e) => !scoped.has(e.element));
|
|
240
|
+
const n = desiredOrder.length;
|
|
241
|
+
const zs = currentOrder.map((e) => e.zIndex);
|
|
242
|
+
const lo = Math.min(...zs);
|
|
243
|
+
const hi = Math.max(...zs);
|
|
244
|
+
|
|
245
|
+
const bracketed = !nonScoped.some((e) => e.zIndex >= lo && e.zIndex <= hi);
|
|
246
|
+
if (bracketed) {
|
|
247
|
+
const below = nonScoped.filter((e) => e.zIndex < lo).map((e) => e.zIndex);
|
|
248
|
+
const above = nonScoped.filter((e) => e.zIndex > hi).map((e) => e.zIndex);
|
|
249
|
+
const minStart = below.length > 0 ? Math.max(...below) + 1 : 0; // z ≥ 0 convention
|
|
250
|
+
const hasUpper = above.length > 0;
|
|
251
|
+
const maxStart = hasUpper ? Math.min(...above) - n : Number.POSITIVE_INFINITY;
|
|
252
|
+
if (minStart <= maxStart) {
|
|
253
|
+
let start = Math.max(lo, minStart);
|
|
254
|
+
if (hasUpper) start = Math.min(start, maxStart);
|
|
255
|
+
const patches: ZOrderPatch[] = [];
|
|
256
|
+
desiredOrder.forEach((entry, i) => {
|
|
257
|
+
if (entry.zIndex !== start + i) patches.push({ element: entry.element, zIndex: start + i });
|
|
258
|
+
});
|
|
259
|
+
return patches.length === 0 ? null : patches;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Fallback: renumber the whole family so relative order is still preserved.
|
|
264
|
+
const desiredGlobal = buildGlobalOrder(family, desiredOrder, target);
|
|
265
|
+
const patches: ZOrderPatch[] = [];
|
|
266
|
+
desiredGlobal.forEach((entry, i) => {
|
|
267
|
+
if (entry.zIndex !== i) patches.push({ element: entry.element, zIndex: i });
|
|
268
|
+
});
|
|
269
|
+
return patches.length === 0 ? null : patches;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* A whole-family render order (bottom→top) with the non-scoped siblings kept in
|
|
274
|
+
* their current relative order and the target reinserted beside its new SCOPED
|
|
275
|
+
* neighbour (just above the scoped element below it, else just below the scoped
|
|
276
|
+
* element above it). Used only by the renumber fallback.
|
|
277
|
+
*/
|
|
278
|
+
function buildGlobalOrder(
|
|
279
|
+
family: RenderEntry[],
|
|
280
|
+
desiredOrder: RenderEntry[],
|
|
281
|
+
target: HTMLElement,
|
|
282
|
+
): RenderEntry[] {
|
|
283
|
+
const full = toRenderOrder(family);
|
|
284
|
+
const targetEntry = full.find((e) => e.element === target);
|
|
285
|
+
const rest = full.filter((e) => e.element !== target);
|
|
286
|
+
if (!targetEntry) return rest;
|
|
287
|
+
const targetPos = desiredOrder.findIndex((e) => e.element === target);
|
|
288
|
+
const prev = desiredOrder[targetPos - 1];
|
|
289
|
+
const next = desiredOrder[targetPos + 1];
|
|
290
|
+
const prevIdx = prev ? rest.findIndex((e) => e.element === prev.element) : -1;
|
|
291
|
+
const nextIdx = next ? rest.findIndex((e) => e.element === next.element) : -1;
|
|
292
|
+
if (prevIdx >= 0) rest.splice(prevIdx + 1, 0, targetEntry);
|
|
293
|
+
else if (nextIdx >= 0) rest.splice(nextIdx, 0, targetEntry);
|
|
294
|
+
else rest.unshift(targetEntry);
|
|
295
|
+
return rest;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Resolve the z-order patches for an action.
|
|
300
|
+
*
|
|
301
|
+
* Returns null when the action is a no-op (target already at the relevant
|
|
302
|
+
* end of its set), otherwise the minimal list of {element, zIndex} changes.
|
|
303
|
+
*/
|
|
304
|
+
export function resolveZOrderChange(
|
|
305
|
+
target: HTMLElement,
|
|
306
|
+
action: ZOrderAction,
|
|
307
|
+
): ZOrderPatch[] | null {
|
|
308
|
+
const { entries } = getFamily(target);
|
|
309
|
+
// Family always includes the target; fewer than 2 means no siblings at all.
|
|
310
|
+
if (entries.length < 2) return null;
|
|
311
|
+
|
|
312
|
+
const scoped =
|
|
313
|
+
action === "bring-to-front" || action === "send-to-back"
|
|
314
|
+
? entries
|
|
315
|
+
: getOverlappingFamily(target, entries);
|
|
316
|
+
if (scoped.length < 2) return null;
|
|
317
|
+
|
|
318
|
+
const order = toRenderOrder(scoped);
|
|
319
|
+
const pos = order.findIndex((e) => e.element === target);
|
|
320
|
+
if (pos === -1) return null;
|
|
321
|
+
|
|
322
|
+
const desired = [...order];
|
|
323
|
+
const [moved] = desired.splice(pos, 1);
|
|
324
|
+
switch (action) {
|
|
325
|
+
case "bring-forward":
|
|
326
|
+
if (pos >= order.length - 1) return null; // already top of set
|
|
327
|
+
desired.splice(pos + 1, 0, moved);
|
|
328
|
+
break;
|
|
329
|
+
case "send-backward":
|
|
330
|
+
if (pos <= 0) return null; // already bottom of set
|
|
331
|
+
desired.splice(pos - 1, 0, moved);
|
|
332
|
+
break;
|
|
333
|
+
case "bring-to-front":
|
|
334
|
+
if (pos >= order.length - 1) return null;
|
|
335
|
+
desired.push(moved);
|
|
336
|
+
break;
|
|
337
|
+
case "send-to-back":
|
|
338
|
+
if (pos <= 0) return null;
|
|
339
|
+
desired.unshift(moved);
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return realizeOrder(order, desired, target, entries);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Whether a z-order action is available for the target.
|
|
348
|
+
* "disabled" = the element is already at that limit.
|
|
349
|
+
*/
|
|
350
|
+
export function isZOrderActionEnabled(target: HTMLElement, action: ZOrderAction): boolean {
|
|
351
|
+
return resolveZOrderChange(target, action) !== null;
|
|
352
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
CANVAS_NUDGE_SHIFT_STEP_PX,
|
|
4
|
+
CANVAS_NUDGE_STEP_PX,
|
|
5
|
+
canCanvasNudgeTargets,
|
|
6
|
+
resolveCanvasNudgeDelta,
|
|
7
|
+
} from "./domEditNudge";
|
|
8
|
+
|
|
9
|
+
function mockKeyboardEvent(
|
|
10
|
+
key: string,
|
|
11
|
+
overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "shiftKey">> = {},
|
|
12
|
+
): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "shiftKey" | "key"> {
|
|
13
|
+
return {
|
|
14
|
+
altKey: false,
|
|
15
|
+
ctrlKey: false,
|
|
16
|
+
metaKey: false,
|
|
17
|
+
shiftKey: false,
|
|
18
|
+
key,
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("resolveCanvasNudgeDelta", () => {
|
|
24
|
+
it("maps plain arrows to 1px composition deltas", () => {
|
|
25
|
+
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft"))).toEqual({
|
|
26
|
+
dx: -CANVAS_NUDGE_STEP_PX,
|
|
27
|
+
dy: 0,
|
|
28
|
+
});
|
|
29
|
+
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowRight"))).toEqual({
|
|
30
|
+
dx: CANVAS_NUDGE_STEP_PX,
|
|
31
|
+
dy: 0,
|
|
32
|
+
});
|
|
33
|
+
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowUp"))).toEqual({
|
|
34
|
+
dx: 0,
|
|
35
|
+
dy: -CANVAS_NUDGE_STEP_PX,
|
|
36
|
+
});
|
|
37
|
+
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowDown"))).toEqual({
|
|
38
|
+
dx: 0,
|
|
39
|
+
dy: CANVAS_NUDGE_STEP_PX,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("maps Shift+arrow to 10px deltas", () => {
|
|
44
|
+
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowRight", { shiftKey: true }))).toEqual({
|
|
45
|
+
dx: CANVAS_NUDGE_SHIFT_STEP_PX,
|
|
46
|
+
dy: 0,
|
|
47
|
+
});
|
|
48
|
+
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowUp", { shiftKey: true }))).toEqual({
|
|
49
|
+
dx: 0,
|
|
50
|
+
dy: -CANVAS_NUDGE_SHIFT_STEP_PX,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("ignores browser and app shortcut chords", () => {
|
|
55
|
+
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft", { altKey: true }))).toBeNull();
|
|
56
|
+
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft", { ctrlKey: true }))).toBeNull();
|
|
57
|
+
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft", { metaKey: true }))).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("ignores non-arrow keys", () => {
|
|
61
|
+
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("Escape"))).toBeNull();
|
|
62
|
+
expect(resolveCanvasNudgeDelta(mockKeyboardEvent("a"))).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("canCanvasNudgeTargets", () => {
|
|
67
|
+
const movable = { capabilities: { canApplyManualOffset: true } };
|
|
68
|
+
const locked = { capabilities: { canApplyManualOffset: false } };
|
|
69
|
+
|
|
70
|
+
it("requires at least one target", () => {
|
|
71
|
+
expect(canCanvasNudgeTargets([])).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("allows only when every target accepts a manual offset", () => {
|
|
75
|
+
expect(canCanvasNudgeTargets([movable])).toBe(true);
|
|
76
|
+
expect(canCanvasNudgeTargets([movable, movable])).toBe(true);
|
|
77
|
+
expect(canCanvasNudgeTargets([locked])).toBe(false);
|
|
78
|
+
expect(canCanvasNudgeTargets([movable, locked])).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the canvas arrow-key nudge (DomEditOverlay).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors captions/keyboard.ts conventions; the two nudge surfaces can't
|
|
5
|
+
* double-fire because PreviewOverlays mounts CaptionOverlay and DomEditOverlay
|
|
6
|
+
* mutually exclusively (caption edit mode returns before the DOM overlay).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const CANVAS_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
|
|
10
|
+
|
|
11
|
+
export const CANVAS_NUDGE_STEP_PX = 1;
|
|
12
|
+
export const CANVAS_NUDGE_SHIFT_STEP_PX = 10;
|
|
13
|
+
/** One undo entry per key burst: the commit fires after this idle gap. */
|
|
14
|
+
export const CANVAS_NUDGE_COMMIT_DEBOUNCE_MS = 400;
|
|
15
|
+
|
|
16
|
+
type CanvasNudgeKeyEvent = Pick<
|
|
17
|
+
KeyboardEvent,
|
|
18
|
+
"altKey" | "ctrlKey" | "metaKey" | "shiftKey" | "key"
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Arrow key → composition-px delta (Shift = 10). Null when the key is not a
|
|
23
|
+
* plain/Shift arrow, so browser and app shortcut chords pass through.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveCanvasNudgeDelta(
|
|
26
|
+
event: CanvasNudgeKeyEvent,
|
|
27
|
+
): { dx: number; dy: number } | null {
|
|
28
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return null;
|
|
29
|
+
if (!CANVAS_NUDGE_KEYS.has(event.key)) return null;
|
|
30
|
+
const step = event.shiftKey ? CANVAS_NUDGE_SHIFT_STEP_PX : CANVAS_NUDGE_STEP_PX;
|
|
31
|
+
return {
|
|
32
|
+
dx: event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0,
|
|
33
|
+
dy: event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CanvasNudgeTarget {
|
|
38
|
+
capabilities: { canApplyManualOffset: boolean };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A nudge needs at least one target and every target must accept manual offsets. */
|
|
42
|
+
export function canCanvasNudgeTargets(targets: ReadonlyArray<CanvasNudgeTarget>): boolean {
|
|
43
|
+
return targets.length > 0 && targets.every((t) => t.capabilities.canApplyManualOffset);
|
|
44
|
+
}
|
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
orientedOverlayRect,
|
|
5
|
+
orientedGroupAwareOverlayRect,
|
|
6
|
+
overlayCornersCentroid,
|
|
7
|
+
selectionCacheKey,
|
|
8
|
+
} from "./domEditOverlayGeometry";
|
|
9
|
+
|
|
10
|
+
describe("overlayCornersCentroid", () => {
|
|
11
|
+
it("averages the four corners (the rendered rotation center)", () => {
|
|
12
|
+
expect(
|
|
13
|
+
overlayCornersCentroid({
|
|
14
|
+
nw: { x: 10, y: 20 },
|
|
15
|
+
ne: { x: 110, y: 20 },
|
|
16
|
+
se: { x: 110, y: 80 },
|
|
17
|
+
sw: { x: 10, y: 80 },
|
|
18
|
+
}),
|
|
19
|
+
).toEqual({ x: 60, y: 50 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("is unchanged by rotation — a rotated square's corners average to its center", () => {
|
|
23
|
+
// Unit square centered at (5,5), rotated 45deg about its center: corners land
|
|
24
|
+
// on the axis midpoints, whose average is still the center.
|
|
25
|
+
const c = overlayCornersCentroid({
|
|
26
|
+
nw: { x: 5, y: 5 - Math.SQRT2 / 2 },
|
|
27
|
+
ne: { x: 5 + Math.SQRT2 / 2, y: 5 },
|
|
28
|
+
se: { x: 5, y: 5 + Math.SQRT2 / 2 },
|
|
29
|
+
sw: { x: 5 - Math.SQRT2 / 2, y: 5 },
|
|
30
|
+
});
|
|
31
|
+
expect(c.x).toBeCloseTo(5, 9);
|
|
32
|
+
expect(c.y).toBeCloseTo(5, 9);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
4
35
|
|
|
5
36
|
describe("selectionCacheKey — hfId collision (R7)", () => {
|
|
6
37
|
it("produces distinct keys for two elements that differ only by hfId", () => {
|
|
@@ -11,3 +42,138 @@ describe("selectionCacheKey — hfId collision (R7)", () => {
|
|
|
11
42
|
expect(a).not.toBe(b);
|
|
12
43
|
});
|
|
13
44
|
});
|
|
45
|
+
|
|
46
|
+
describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () => {
|
|
47
|
+
// jsdom has no DOMMatrix/DOMPoint; a minimal stand-in is enough to exercise the
|
|
48
|
+
// real (unmocked) orientedOverlayRect, unlike DomEditOverlay.test.ts and
|
|
49
|
+
// anchoredResizeCommitFeedsOffset.test.ts, which mock this module entirely.
|
|
50
|
+
class FakeDOMMatrix {
|
|
51
|
+
a = 1;
|
|
52
|
+
b = 0;
|
|
53
|
+
c = 0;
|
|
54
|
+
d = 1;
|
|
55
|
+
e = 0;
|
|
56
|
+
f = 0;
|
|
57
|
+
constructor(init?: string) {
|
|
58
|
+
const m = init ? /matrix\(([^)]+)\)/.exec(init) : null;
|
|
59
|
+
if (!m) return;
|
|
60
|
+
const parts = m[1]!.split(",").map((s) => Number.parseFloat(s.trim()));
|
|
61
|
+
[this.a, this.b, this.c, this.d, this.e, this.f] = parts as [
|
|
62
|
+
number,
|
|
63
|
+
number,
|
|
64
|
+
number,
|
|
65
|
+
number,
|
|
66
|
+
number,
|
|
67
|
+
number,
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
transformPoint(pt: { x: number; y: number }) {
|
|
71
|
+
return {
|
|
72
|
+
x: this.a * pt.x + this.c * pt.y + this.e,
|
|
73
|
+
y: this.b * pt.x + this.d * pt.y + this.f,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
class FakeDOMPoint {
|
|
78
|
+
constructor(
|
|
79
|
+
public x: number,
|
|
80
|
+
public y: number,
|
|
81
|
+
) {}
|
|
82
|
+
}
|
|
83
|
+
// matrix() form of rotate(30deg), so the module's `new DOMMatrix(cs.transform)`
|
|
84
|
+
// parse (which expects "matrix(...)", not "rotate(...)") resolves correctly.
|
|
85
|
+
const ROTATE_30DEG_MATRIX =
|
|
86
|
+
"matrix(0.8660254037844387, 0.49999999999999994, -0.49999999999999994, 0.8660254037844387, 0, 0)";
|
|
87
|
+
|
|
88
|
+
function stubRect(
|
|
89
|
+
el: Element,
|
|
90
|
+
rect: { left: number; top: number; width: number; height: number },
|
|
91
|
+
) {
|
|
92
|
+
(el as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
|
|
93
|
+
({
|
|
94
|
+
left: rect.left,
|
|
95
|
+
top: rect.top,
|
|
96
|
+
width: rect.width,
|
|
97
|
+
height: rect.height,
|
|
98
|
+
right: rect.left + rect.width,
|
|
99
|
+
bottom: rect.top + rect.height,
|
|
100
|
+
x: rect.left,
|
|
101
|
+
y: rect.top,
|
|
102
|
+
toJSON() {
|
|
103
|
+
return this;
|
|
104
|
+
},
|
|
105
|
+
}) as DOMRect;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildHarness() {
|
|
109
|
+
const overlayEl = document.createElement("div");
|
|
110
|
+
document.body.appendChild(overlayEl);
|
|
111
|
+
stubRect(overlayEl, { left: 0, top: 0, width: 1000, height: 1000 });
|
|
112
|
+
|
|
113
|
+
const iframe = document.createElement("iframe");
|
|
114
|
+
document.body.appendChild(iframe);
|
|
115
|
+
stubRect(iframe, { left: 0, top: 0, width: 1000, height: 1000 });
|
|
116
|
+
|
|
117
|
+
const doc = iframe.contentDocument!;
|
|
118
|
+
const root = doc.createElement("div");
|
|
119
|
+
root.setAttribute("data-composition-id", "root");
|
|
120
|
+
root.setAttribute("data-width", "1000");
|
|
121
|
+
root.setAttribute("data-height", "1000");
|
|
122
|
+
doc.body.appendChild(root);
|
|
123
|
+
|
|
124
|
+
const el = doc.createElement("div");
|
|
125
|
+
root.appendChild(el);
|
|
126
|
+
stubRect(el, { left: 400, top: 450, width: 200, height: 100 });
|
|
127
|
+
Object.defineProperty(el, "offsetWidth", { value: 200, configurable: true });
|
|
128
|
+
Object.defineProperty(el, "offsetHeight", { value: 100, configurable: true });
|
|
129
|
+
|
|
130
|
+
const win = iframe.contentWindow as unknown as Window & {
|
|
131
|
+
DOMMatrix: unknown;
|
|
132
|
+
DOMPoint: unknown;
|
|
133
|
+
};
|
|
134
|
+
win.DOMMatrix = FakeDOMMatrix;
|
|
135
|
+
win.DOMPoint = FakeDOMPoint;
|
|
136
|
+
|
|
137
|
+
return { overlayEl, iframe, el };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
it("unrotated element takes the cheap AABB path — matches the raw bounding rect, angle 0", () => {
|
|
141
|
+
const { overlayEl, iframe, el } = buildHarness();
|
|
142
|
+
const rect = orientedOverlayRect(overlayEl, iframe, el);
|
|
143
|
+
expect(rect).not.toBeNull();
|
|
144
|
+
expect(rect!.left).toBeCloseTo(400, 5);
|
|
145
|
+
expect(rect!.top).toBeCloseTo(450, 5);
|
|
146
|
+
expect(rect!.width).toBeCloseTo(200, 5);
|
|
147
|
+
expect(rect!.height).toBeCloseTo(100, 5);
|
|
148
|
+
expect(rect!.angle ?? 0).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("rotated element takes the corner-geometry path — reports the live angle", () => {
|
|
152
|
+
const { overlayEl, iframe, el } = buildHarness();
|
|
153
|
+
el.style.transform = ROTATE_30DEG_MATRIX;
|
|
154
|
+
const rect = orientedOverlayRect(overlayEl, iframe, el);
|
|
155
|
+
expect(rect).not.toBeNull();
|
|
156
|
+
expect(rect!.angle).toBeCloseTo(30, 3);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("preserves an ordinary element's rotation through the group-aware entry point", () => {
|
|
160
|
+
const { overlayEl, iframe, el } = buildHarness();
|
|
161
|
+
el.style.transform = ROTATE_30DEG_MATRIX;
|
|
162
|
+
const rect = orientedGroupAwareOverlayRect(overlayEl, iframe, el);
|
|
163
|
+
expect(rect!.angle).toBeCloseTo(30, 3);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("gate re-evaluates every call — editing an element to rotated mid-session flips the path immediately", () => {
|
|
167
|
+
const { overlayEl, iframe, el } = buildHarness();
|
|
168
|
+
const before = orientedOverlayRect(overlayEl, iframe, el);
|
|
169
|
+
expect(before?.angle ?? 0).toBe(0);
|
|
170
|
+
|
|
171
|
+
el.style.transform = ROTATE_30DEG_MATRIX;
|
|
172
|
+
const after = orientedOverlayRect(overlayEl, iframe, el);
|
|
173
|
+
expect(after!.angle).toBeCloseTo(30, 3);
|
|
174
|
+
|
|
175
|
+
el.style.transform = "";
|
|
176
|
+
const restored = orientedOverlayRect(overlayEl, iframe, el);
|
|
177
|
+
expect(restored?.angle ?? 0).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
});
|