@hyperframes/studio 0.7.55 → 0.7.57

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.
Files changed (274) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/index-DfmYkU44.js +423 -0
  4. package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  7. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  8. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  9. package/dist/index.d.ts +172 -144
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +43613 -40346
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +106 -96
  15. package/src/components/EditorShell.tsx +253 -0
  16. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  17. package/src/components/StudioLeftSidebar.tsx +13 -3
  18. package/src/components/StudioRightPanel.tsx +8 -3
  19. package/src/components/TimelineToolbar.test.tsx +2 -2
  20. package/src/components/TimelineToolbar.tsx +179 -121
  21. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  22. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  23. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  24. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  25. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  26. package/src/components/editor/DomEditOverlay.tsx +126 -125
  27. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  28. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  29. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  30. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  31. package/src/components/editor/LayersPanel.test.ts +66 -2
  32. package/src/components/editor/LayersPanel.tsx +53 -2
  33. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  34. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  35. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  36. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  37. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  38. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  39. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  40. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  41. package/src/components/editor/domEditNudge.test.ts +80 -0
  42. package/src/components/editor/domEditNudge.ts +44 -0
  43. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  44. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  45. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  46. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  47. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  48. package/src/components/editor/domEditResizeLocal.ts +125 -0
  49. package/src/components/editor/domEditingDom.ts +1 -1
  50. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  51. package/src/components/editor/manualOffsetDrag.ts +55 -12
  52. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  53. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  54. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  55. package/src/components/editor/resizeDraft.ts +108 -0
  56. package/src/components/editor/snapEngine.test.ts +12 -78
  57. package/src/components/editor/snapEngine.ts +13 -53
  58. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  60. package/src/components/editor/useDomEditNudge.ts +254 -0
  61. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  62. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  63. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  64. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  65. package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
  66. package/src/components/nle/NLEContext.test.ts +144 -0
  67. package/src/components/nle/NLEContext.tsx +328 -0
  68. package/src/components/nle/NLEPreview.tsx +1 -1
  69. package/src/components/nle/PreviewOverlays.tsx +260 -0
  70. package/src/components/nle/PreviewPane.tsx +163 -0
  71. package/src/components/nle/TimelinePane.test.ts +60 -0
  72. package/src/components/nle/TimelinePane.tsx +293 -0
  73. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  74. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  75. package/src/components/nle/useCompositionStack.ts +17 -9
  76. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  77. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  78. package/src/components/sidebar/AssetCard.tsx +343 -0
  79. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  80. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  81. package/src/components/sidebar/AssetsTab.tsx +71 -206
  82. package/src/components/sidebar/AudioRow.tsx +56 -2
  83. package/src/components/sidebar/BlocksTab.tsx +8 -1
  84. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  85. package/src/components/sidebar/assetHelpers.ts +29 -0
  86. package/src/contexts/DomEditContext.tsx +9 -0
  87. package/src/contexts/StudioContext.tsx +9 -8
  88. package/src/contexts/TimelineEditContext.tsx +3 -7
  89. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  90. package/src/hooks/domEditCommitTypes.ts +23 -1
  91. package/src/hooks/domSelectionTestHarness.ts +14 -0
  92. package/src/hooks/gestureTransaction.test.ts +311 -0
  93. package/src/hooks/gestureTransaction.ts +199 -0
  94. package/src/hooks/gsapDragCommit.test.ts +141 -41
  95. package/src/hooks/gsapDragCommit.ts +64 -74
  96. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  97. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  98. package/src/hooks/gsapResizeIntercept.ts +38 -10
  99. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  100. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  101. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  102. package/src/hooks/gsapShared.test.ts +19 -1
  103. package/src/hooks/gsapShared.ts +14 -0
  104. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  105. package/src/hooks/timelineEditingGsap.ts +14 -0
  106. package/src/hooks/timelineEditingHelpers.test.ts +125 -1
  107. package/src/hooks/timelineEditingHelpers.ts +53 -292
  108. package/src/hooks/timelineMoveAdapter.test.ts +81 -0
  109. package/src/hooks/timelineMoveAdapter.ts +39 -0
  110. package/src/hooks/timelineTimingSync.test.ts +150 -0
  111. package/src/hooks/timelineTimingSync.ts +412 -0
  112. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  113. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  114. package/src/hooks/useAppHotkeys.ts +16 -25
  115. package/src/hooks/useBlockHandlers.ts +55 -27
  116. package/src/hooks/useCaptionDetection.ts +2 -0
  117. package/src/hooks/useCompositionDimensions.ts +32 -5
  118. package/src/hooks/useContextMenuDismiss.ts +31 -7
  119. package/src/hooks/useDomEditCommits.test.tsx +245 -1
  120. package/src/hooks/useDomEditCommits.ts +162 -3
  121. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  122. package/src/hooks/useDomEditSession.ts +2 -2
  123. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  124. package/src/hooks/useDomEditTextCommits.ts +8 -3
  125. package/src/hooks/useDomEditWiring.ts +7 -9
  126. package/src/hooks/useDomGeometryCommits.ts +17 -2
  127. package/src/hooks/useDomSelection.test.ts +2 -36
  128. package/src/hooks/useDomSelection.ts +81 -51
  129. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  130. package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
  131. package/src/hooks/useElementLifecycleOps.ts +53 -71
  132. package/src/hooks/useElementPicker.ts +7 -8
  133. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  134. package/src/hooks/useEnableKeyframes.ts +73 -29
  135. package/src/hooks/useGestureCommit.test.tsx +130 -0
  136. package/src/hooks/useGestureCommit.ts +32 -8
  137. package/src/hooks/useGroupCommits.ts +7 -28
  138. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  139. package/src/hooks/useGsapAwareEditing.ts +115 -20
  140. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  141. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  142. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  143. package/src/hooks/useGsapScriptCommits.ts +134 -50
  144. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  145. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  146. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  147. package/src/hooks/usePersistentEditHistory.ts +92 -58
  148. package/src/hooks/usePreviewPersistence.ts +25 -7
  149. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  150. package/src/hooks/useRazorSplit.test.ts +269 -0
  151. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  152. package/src/hooks/useRazorSplit.ts +107 -36
  153. package/src/hooks/useRenderClipContent.ts +24 -6
  154. package/src/hooks/useStudioContextValue.ts +15 -5
  155. package/src/hooks/useStudioUrlState.ts +2 -4
  156. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  157. package/src/hooks/useTimelineEditing.test.tsx +398 -241
  158. package/src/hooks/useTimelineEditing.ts +181 -265
  159. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  160. package/src/hooks/useTimelineGroupEditing.ts +167 -132
  161. package/src/index.ts +1 -1
  162. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  163. package/src/player/components/ImageThumbnail.tsx +160 -0
  164. package/src/player/components/PlayerControls.tsx +5 -5
  165. package/src/player/components/PlayheadIndicator.tsx +55 -5
  166. package/src/player/components/Timeline.test.ts +183 -23
  167. package/src/player/components/Timeline.tsx +194 -194
  168. package/src/player/components/TimelineCanvas.tsx +218 -523
  169. package/src/player/components/TimelineClip.tsx +4 -1
  170. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  171. package/src/player/components/TimelineEmptyState.tsx +3 -1
  172. package/src/player/components/TimelineLanes.tsx +487 -0
  173. package/src/player/components/TimelineOverlays.tsx +122 -0
  174. package/src/player/components/TimelineRuler.tsx +58 -48
  175. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  176. package/src/player/components/VideoThumbnail.tsx +28 -7
  177. package/src/player/components/thumbnailUtils.test.ts +127 -0
  178. package/src/player/components/thumbnailUtils.ts +54 -0
  179. package/src/player/components/timelineCallbacks.ts +18 -18
  180. package/src/player/components/timelineClipChildren.tsx +39 -0
  181. package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
  182. package/src/player/components/timelineClipDragCommit.ts +498 -0
  183. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  184. package/src/player/components/timelineClipDragPreview.ts +323 -38
  185. package/src/player/components/timelineClipDragTypes.ts +65 -0
  186. package/src/player/components/timelineCollision.test.ts +42 -2
  187. package/src/player/components/timelineDragDrop.ts +69 -60
  188. package/src/player/components/timelineEditCapabilities.ts +52 -0
  189. package/src/player/components/timelineEditing.test.ts +1 -1
  190. package/src/player/components/timelineEditing.ts +76 -59
  191. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  192. package/src/player/components/timelineGroupEditing.ts +133 -0
  193. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  194. package/src/player/components/timelineLayout.test.ts +95 -23
  195. package/src/player/components/timelineLayout.ts +207 -75
  196. package/src/player/components/timelineMarquee.test.ts +197 -0
  197. package/src/player/components/timelineMarquee.ts +112 -0
  198. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  199. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  200. package/src/player/components/timelineRevealScroll.ts +91 -0
  201. package/src/player/components/timelineStackingSync.test.ts +60 -0
  202. package/src/player/components/timelineStackingSync.ts +27 -2
  203. package/src/player/components/timelineTheme.ts +6 -1
  204. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  205. package/src/player/components/timelineZones.test.ts +122 -310
  206. package/src/player/components/timelineZones.ts +82 -135
  207. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  208. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  209. package/src/player/components/useTimelineClipDrag.ts +380 -386
  210. package/src/player/components/useTimelineEditPinning.ts +121 -0
  211. package/src/player/components/useTimelineGeometry.ts +129 -0
  212. package/src/player/components/useTimelinePlayhead.ts +4 -16
  213. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  214. package/src/player/components/useTimelineRevealClip.ts +56 -0
  215. package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
  216. package/src/player/components/useTimelineStackingSync.ts +94 -0
  217. package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
  218. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  219. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  220. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  221. package/src/player/hooks/useTimelinePlayer.ts +52 -53
  222. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  224. package/src/player/index.ts +5 -2
  225. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  226. package/src/player/lib/runtimeProtocol.ts +65 -0
  227. package/src/player/lib/time.test.ts +19 -19
  228. package/src/player/lib/time.ts +6 -2
  229. package/src/player/lib/timelineDOM.test.ts +25 -98
  230. package/src/player/lib/timelineDOM.ts +21 -81
  231. package/src/player/lib/timelineElementHelpers.ts +73 -5
  232. package/src/player/lib/timelineIframeHelpers.ts +4 -4
  233. package/src/player/store/playerStore.test.ts +38 -13
  234. package/src/player/store/playerStore.ts +99 -3
  235. package/src/styles/studio.css +27 -0
  236. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  237. package/src/utils/assetPreviewDismiss.ts +29 -0
  238. package/src/utils/assetPreviewStore.ts +33 -0
  239. package/src/utils/blockInstaller.ts +10 -2
  240. package/src/utils/editHistory.test.ts +35 -0
  241. package/src/utils/gsapSoftReload.test.ts +119 -1
  242. package/src/utils/gsapSoftReload.ts +153 -0
  243. package/src/utils/mediaTypes.ts +3 -2
  244. package/src/utils/resizeDebug.ts +55 -0
  245. package/src/utils/rootDuration.test.ts +90 -1
  246. package/src/utils/rootDuration.ts +76 -13
  247. package/src/utils/sdkCutover.ts +4 -0
  248. package/src/utils/studioFileHistory.ts +37 -7
  249. package/src/utils/studioHelpers.ts +62 -0
  250. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  251. package/src/utils/studioPreviewHelpers.ts +10 -0
  252. package/src/utils/studioTelemetry.ts +4 -1
  253. package/src/utils/studioUrlState.test.ts +0 -1
  254. package/src/utils/timelineAssetDrop.test.ts +144 -52
  255. package/src/utils/timelineAssetDrop.ts +67 -29
  256. package/src/utils/timelineDiscovery.ts +0 -17
  257. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  258. package/dist/assets/index-BRwkMj0w.js +0 -423
  259. package/dist/assets/index-Dq7FEg0K.css +0 -1
  260. package/dist/chunk-SOTCF4DF.js.map +0 -1
  261. package/src/components/StudioPreviewArea.tsx +0 -500
  262. package/src/components/nle/NLELayout.test.ts +0 -12
  263. package/src/components/nle/NLELayout.tsx +0 -591
  264. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  265. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  266. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  267. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  268. package/src/player/components/timelineSnapTargets.ts +0 -164
  269. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  270. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  271. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  272. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  273. package/src/utils/timelineDiscovery.test.ts +0 -90
  274. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -7,8 +7,23 @@
7
7
  * the computed value. Treat missing / "auto" as 0 for comparison purposes.
8
8
  *
9
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.
10
+ * target's bounding rect AND are actually visible at the current frame.
11
+ * Forward/backward operate within that set; front/back operate across all
12
+ * siblings (full painting family, visible or not — unchanged semantics).
13
+ *
14
+ * ── Visibility ───────────────────────────────────────────────────────────────
15
+ * In HyperFrames compositions the nearest z-neighbor is often INVISIBLE at the
16
+ * paused frame: the runtime hides time-inactive clips with inline
17
+ * `visibility:hidden` / `display:none` (see core runtime
18
+ * syncTimedElementVisibility), and GSAP timelines park elements at `opacity:0`.
19
+ * Stepping "forward" over such a sibling looks like a silent no-op. The
20
+ * forward/backward comparison set therefore keeps only siblings whose
21
+ * element-level computed style is visible (display ≠ none, visibility ≠
22
+ * hidden, opacity > 0.01) — all runtime hiding signals are inline styles, so
23
+ * computed style covers them. Ancestor-chain checks are unnecessary here:
24
+ * siblings share the target's ancestors. The probe is injectable
25
+ * (ZOrderResolveOptions.isVisible) so the pure-module tests stay meaningful
26
+ * without a real style engine, mirroring how tests stub rect reading.
12
27
  *
13
28
  * ── Tie-awareness ────────────────────────────────────────────────────────────
14
29
  * CSS paint order for elements that share a z-index is DOM document order:
@@ -26,6 +41,8 @@
26
41
  * (project convention clamps z ≥ 0).
27
42
  */
28
43
 
44
+ import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
45
+
29
46
  export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
30
47
 
31
48
  /** A resolved change: set `element`'s z-index to `zIndex`. */
@@ -34,6 +51,48 @@ export interface ZOrderPatch {
34
51
  zIndex: number;
35
52
  }
36
53
 
54
+ /** Injectable knobs for the pure resolver (kept mockable like rect reading). */
55
+ export interface ZOrderResolveOptions {
56
+ /**
57
+ * Element-level visibility probe used to scope the forward/backward
58
+ * comparison set. Defaults to `isElementVisibleForZOrder` (computed-style
59
+ * display/visibility/opacity). Injectable so tests can run without a real
60
+ * style engine.
61
+ */
62
+ isVisible?: (el: HTMLElement) => boolean;
63
+ }
64
+
65
+ /**
66
+ * Default visibility probe: is this element itself visible at the current
67
+ * frame? Element-level only (siblings share the target's ancestor chain).
68
+ * Covers the runtime's inactive-clip hiding (inline `visibility:hidden` /
69
+ * `display:none`) and animation-parked `opacity:0`, all of which surface
70
+ * through computed style. A color-grading source (hidden at opacity:0 while
71
+ * its canvas paints in its place) still counts as visible, matching
72
+ * isElementVisibleThroughAncestors in domEditingDom.
73
+ */
74
+ export function isElementVisibleForZOrder(el: HTMLElement): boolean {
75
+ try {
76
+ const win = el.ownerDocument?.defaultView;
77
+ if (!win) return true;
78
+ const computed = win.getComputedStyle(el);
79
+ if (computed.display === "none") return false;
80
+ if (computed.visibility === "hidden" || computed.visibility === "collapse") return false;
81
+ const opacity = Number.parseFloat(computed.opacity);
82
+ if (
83
+ Number.isFinite(opacity) &&
84
+ opacity <= 0.01 &&
85
+ !el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR)
86
+ ) {
87
+ return false;
88
+ }
89
+ return true;
90
+ } catch {
91
+ /* cross-origin / detached — assume visible (fail open, matches rect fallback) */
92
+ return true;
93
+ }
94
+ }
95
+
37
96
  interface RenderEntry {
38
97
  element: HTMLElement;
39
98
  zIndex: number;
@@ -81,8 +140,18 @@ function isElementNode(node: Node): node is HTMLElement {
81
140
  * z-index onto the qa-clean audio element, and counting it as a sibling skews the
82
141
  * renumber for the visible elements. `<script>/<style>/<link>/<meta>` are also
83
142
  * non-painting and could otherwise pad the family / eat a z slot.
143
+ * `<template>/<noscript>` never paint either — letting them in meant renumber
144
+ * fallbacks wrote z-index/position into template source markup.
84
145
  */
85
- const NON_PAINTING_TAGS = new Set(["AUDIO", "SCRIPT", "STYLE", "LINK", "META"]);
146
+ const NON_PAINTING_TAGS = new Set([
147
+ "AUDIO",
148
+ "SCRIPT",
149
+ "STYLE",
150
+ "LINK",
151
+ "META",
152
+ "TEMPLATE",
153
+ "NOSCRIPT",
154
+ ]);
86
155
 
87
156
  /** A painting element: an element node whose tag actually renders pixels. */
88
157
  function isPaintingElement(node: Node): node is HTMLElement {
@@ -112,7 +181,7 @@ function getFamily(target: HTMLElement): { entries: RenderEntry[]; targetIndex:
112
181
  return { entries, targetIndex };
113
182
  }
114
183
 
115
- /** True if two DOM bounding rects intersect (even if touching). */
184
+ /** True if two DOM bounding rects strictly overlap (rects that merely touch do NOT intersect). */
116
185
  function rectsIntersect(
117
186
  a: { left: number; top: number; right: number; bottom: number },
118
187
  b: { left: number; top: number; right: number; bottom: number },
@@ -121,26 +190,35 @@ function rectsIntersect(
121
190
  }
122
191
 
123
192
  /**
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.
193
+ * Restrict a family to the target plus siblings that are VISIBLE and whose
194
+ * bounding rect overlaps the target's rect. The target is always retained
195
+ * (even when itself hidden at the current frame it is the user's explicit
196
+ * selection). If the target's rect is unavailable or empty (headless /
197
+ * happy-dom returns 0×0), the overlap filter is skipped and all VISIBLE
198
+ * entries are kept — matching the prior rect-fallback behavior.
128
199
  */
129
- function getOverlappingFamily(target: HTMLElement, entries: RenderEntry[]): RenderEntry[] {
200
+ function getOverlappingFamily(
201
+ target: HTMLElement,
202
+ entries: RenderEntry[],
203
+ isVisible: (el: HTMLElement) => boolean,
204
+ ): RenderEntry[] {
205
+ const visibleEntries = entries.filter(
206
+ (entry) => entry.element === target || isVisible(entry.element),
207
+ );
130
208
  let targetRect: DOMRect;
131
209
  try {
132
210
  targetRect = target.getBoundingClientRect();
133
211
  } catch {
134
- return entries;
212
+ return visibleEntries;
135
213
  }
136
- if (targetRect.width === 0 && targetRect.height === 0) return entries;
214
+ if (targetRect.width === 0 && targetRect.height === 0) return visibleEntries;
137
215
  const tr = {
138
216
  left: targetRect.left,
139
217
  top: targetRect.top,
140
218
  right: targetRect.right,
141
219
  bottom: targetRect.bottom,
142
220
  };
143
- return entries.filter((entry) => {
221
+ return visibleEntries.filter((entry) => {
144
222
  if (entry.element === target) return true;
145
223
  try {
146
224
  const r = entry.element.getBoundingClientRect();
@@ -296,28 +374,46 @@ function buildGlobalOrder(
296
374
  }
297
375
 
298
376
  /**
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.
377
+ * The shared scoping pipeline: full painting family for front/back, visible
378
+ * overlapping siblings for forward/backward, sorted into render order with the
379
+ * target's position. Null when the family/scope is too small to act on.
303
380
  */
304
- export function resolveZOrderChange(
381
+ function resolveScopedRenderOrder(
305
382
  target: HTMLElement,
306
383
  action: ZOrderAction,
307
- ): ZOrderPatch[] | null {
384
+ options?: ZOrderResolveOptions,
385
+ ): { entries: RenderEntry[]; order: RenderEntry[]; pos: number } | null {
308
386
  const { entries } = getFamily(target);
309
387
  // Family always includes the target; fewer than 2 means no siblings at all.
310
388
  if (entries.length < 2) return null;
311
389
 
390
+ const isVisible = options?.isVisible ?? isElementVisibleForZOrder;
312
391
  const scoped =
313
392
  action === "bring-to-front" || action === "send-to-back"
314
393
  ? entries
315
- : getOverlappingFamily(target, entries);
394
+ : getOverlappingFamily(target, entries, isVisible);
316
395
  if (scoped.length < 2) return null;
317
396
 
318
397
  const order = toRenderOrder(scoped);
319
398
  const pos = order.findIndex((e) => e.element === target);
320
399
  if (pos === -1) return null;
400
+ return { entries, order, pos };
401
+ }
402
+
403
+ /**
404
+ * Resolve the z-order patches for an action.
405
+ *
406
+ * Returns null when the action is a no-op (target already at the relevant
407
+ * end of its set), otherwise the minimal list of {element, zIndex} changes.
408
+ */
409
+ export function resolveZOrderChange(
410
+ target: HTMLElement,
411
+ action: ZOrderAction,
412
+ options?: ZOrderResolveOptions,
413
+ ): ZOrderPatch[] | null {
414
+ const resolved = resolveScopedRenderOrder(target, action, options);
415
+ if (!resolved) return null;
416
+ const { entries, order, pos } = resolved;
321
417
 
322
418
  const desired = [...order];
323
419
  const [moved] = desired.splice(pos, 1);
@@ -343,10 +439,36 @@ export function resolveZOrderChange(
343
439
  return realizeOrder(order, desired, target, entries);
344
440
  }
345
441
 
442
+ /**
443
+ * The sibling a forward/backward step crosses: the visible overlapping
444
+ * neighbor directly above (bring-forward) or below (send-backward) the target
445
+ * in render order. Null for front/back, for a no-op step, or when the scope is
446
+ * too small. Uses the SAME scoping as resolveZOrderChange, so call it with the
447
+ * same options BEFORE any live styles are applied.
448
+ */
449
+ export function resolveCrossedNeighbor(
450
+ target: HTMLElement,
451
+ action: ZOrderAction,
452
+ options?: ZOrderResolveOptions,
453
+ ): HTMLElement | null {
454
+ if (action !== "bring-forward" && action !== "send-backward") return null;
455
+ const resolved = resolveScopedRenderOrder(target, action, options);
456
+ if (!resolved) return null;
457
+ const { order, pos } = resolved;
458
+ const neighbor = action === "bring-forward" ? order[pos + 1] : order[pos - 1];
459
+ return neighbor?.element ?? null;
460
+ }
461
+
346
462
  /**
347
463
  * Whether a z-order action is available for the target.
348
- * "disabled" = the element is already at that limit.
464
+ * "disabled" = the element is already at that limit. Shares the resolver (and
465
+ * its visibility scoping), so enable/disable always matches what the action
466
+ * would actually do.
349
467
  */
350
- export function isZOrderActionEnabled(target: HTMLElement, action: ZOrderAction): boolean {
351
- return resolveZOrderChange(target, action) !== null;
468
+ export function isZOrderActionEnabled(
469
+ target: HTMLElement,
470
+ action: ZOrderAction,
471
+ options?: ZOrderResolveOptions,
472
+ ): boolean {
473
+ return resolveZOrderChange(target, action, options) !== null;
352
474
  }
@@ -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 { selectionCacheKey } from "./domEditOverlayGeometry";
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
+ });