@hyperframes/studio 0.8.82 → 0.8.83

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 (76) hide show
  1. package/dist/assets/{hyperframes-player-zGIbZhLJ.js → hyperframes-player-BAerMCex.js} +1 -1
  2. package/dist/assets/{index-BiLCTunu.js → index-BUhcVhNy.js} +1 -1
  3. package/dist/assets/{index-DSazIt7R.js → index-BYhK_KMc.js} +193 -193
  4. package/dist/assets/{index-DStmfnpZ.js → index-Iv0HI7eM.js} +1 -1
  5. package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
  6. package/dist/chunk-3IOCVWB6.js.map +1 -0
  7. package/dist/chunk-62DDWOSH.js +189 -0
  8. package/dist/chunk-62DDWOSH.js.map +1 -0
  9. package/dist/chunk-LPZMAGS7.js +1871 -0
  10. package/dist/chunk-LPZMAGS7.js.map +1 -0
  11. package/dist/chunk-QZ6HNNFG.js +934 -0
  12. package/dist/chunk-QZ6HNNFG.js.map +1 -0
  13. package/dist/chunk-S65AQH6M.js +101 -0
  14. package/dist/chunk-S65AQH6M.js.map +1 -0
  15. package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
  16. package/dist/index.d.ts +669 -330
  17. package/dist/index.html +1 -1
  18. package/dist/index.js +20501 -23167
  19. package/dist/index.js.map +1 -1
  20. package/dist/player.d.ts +30 -0
  21. package/dist/player.js +10 -0
  22. package/dist/player.js.map +1 -0
  23. package/dist/ui.d.ts +294 -0
  24. package/dist/ui.js +66 -0
  25. package/dist/ui.js.map +1 -0
  26. package/package.json +16 -8
  27. package/src/components/StudioHeader.tsx +1 -121
  28. package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
  29. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  30. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
  31. package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
  32. package/src/components/editor/DomEditOverlay.tsx +36 -21
  33. package/src/components/editor/LayersPanel.tsx +1 -1
  34. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  35. package/src/components/editor/SnapGuideOverlay.tsx +1 -1
  36. package/src/components/editor/clipAudioClock.test.ts +61 -0
  37. package/src/components/editor/clipAudioClock.ts +78 -0
  38. package/src/components/editor/marqueeCommit.ts +100 -37
  39. package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
  40. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +135 -0
  41. package/src/components/editor/useFxCarve.ts +11 -15
  42. package/src/components/editor/useFxCarveNodes.ts +27 -15
  43. package/src/components/editor/useFxLevelling.ts +7 -43
  44. package/src/components/editor/useInlineTextEditing.tsx +35 -19
  45. package/src/components/feedback/projectProvenance.ts +2 -3
  46. package/src/components/nle/PreviewOverlays.tsx +7 -188
  47. package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
  48. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
  49. package/src/components/ui/HyperframesLogo.tsx +120 -0
  50. package/src/domEditExports.test.tsx +69 -0
  51. package/src/editorGesturesExports.test.tsx +236 -0
  52. package/src/hooks/useElementLifecycleOps.ts +1 -1
  53. package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
  54. package/src/hooks/useKeyframeKeyboard.ts +9 -71
  55. package/src/hooks/usePreviewPersistence.ts +1 -1
  56. package/src/index.ts +45 -32
  57. package/src/lightEntries.test.ts +32 -0
  58. package/src/player/components/TimelineClipFades.test.tsx +31 -10
  59. package/src/player/components/TimelineClipFades.tsx +2 -1
  60. package/src/player/components/TimelineTypes.ts +4 -2
  61. package/src/player/components/studioShortcuts.ts +8 -5
  62. package/src/player/components/timelineClipChildren.test.ts +34 -1
  63. package/src/player/components/timelineClipChildren.tsx +7 -4
  64. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
  65. package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
  66. package/src/player/components/timelinePointerRelease.ts +4 -0
  67. package/src/player/components/useTimelineProviderState.tsx +4 -0
  68. package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
  69. package/src/player/components/useTimelineStackingSync.ts +18 -11
  70. package/src/playerEntry.ts +1 -0
  71. package/src/timelineStackingSyncExport.test.tsx +67 -0
  72. package/src/ui.ts +31 -0
  73. package/src/utils/mediaTypes.test.ts +33 -0
  74. package/src/utils/mediaTypes.ts +12 -5
  75. package/dist/chunk-I7XNAIOF.js.map +0 -1
  76. /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
@@ -23,6 +23,7 @@ import {
23
23
  } from "@hyperframes/core/audio-carve";
24
24
  import { fxAutomationTarget, type HfAutomationLane } from "@hyperframes/core/audio-automation";
25
25
  import { clipStart } from "./propertyPanelAudioFxGroupUtils.js";
26
+ import { clipAudioOnItsClock, type ClipClock } from "./clipAudioClock.js";
26
27
 
27
28
  /** Decode rate every carve measurement shares — see `measureCarve`. */
28
29
  const DECODE_SAMPLE_RATE = 48000;
@@ -60,6 +61,13 @@ export function mintCarveNodes(
60
61
  };
61
62
  }
62
63
 
64
+ /** A clip to decode, where it starts on the timeline, and how it plays its file. */
65
+ export interface CarveClip {
66
+ src: string;
67
+ start: string | null | undefined;
68
+ clock: ClipClock;
69
+ }
70
+
63
71
  /**
64
72
  * Decode every voice, mix them onto the bed's own clock, and measure the
65
73
  * bands (and, if the profile calls for it, the ducking envelope) from that
@@ -68,10 +76,9 @@ export function mintCarveNodes(
68
76
  */
69
77
  export async function measureCarve(
70
78
  doc: Document,
71
- voices: { src: string; start: string | null }[],
79
+ voices: CarveClip[],
72
80
  strength: number,
73
- bedStartAttr: string | null | undefined,
74
- bedSrc: string | null | undefined,
81
+ bed: Omit<CarveClip, "src"> & { src: string | null | undefined },
75
82
  ): Promise<{
76
83
  bands: ReturnType<typeof analyseCarveBands>;
77
84
  carved: HfAudioFxChain;
@@ -86,11 +93,15 @@ export async function measureCarve(
86
93
  (window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
87
94
  .webkitOfflineAudioContext;
88
95
  if (!Ctor) return null;
89
- const decode = async (relative: string): Promise<AudioBuffer> => {
96
+ // Each clip as it plays: trimmed to its in-point and duration, and at its own speed.
97
+ const decode = async (relative: string, clock: ClipClock): Promise<Float32Array> => {
90
98
  const res = await fetch(new URL(relative, doc.baseURI).href);
91
- return new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(await res.arrayBuffer());
99
+ const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
100
+ await res.arrayBuffer(),
101
+ );
102
+ return clipAudioOnItsClock(buffer.getChannelData(0), DECODE_SAMPLE_RATE, clock);
92
103
  };
93
- const bedStart = clipStart(bedStartAttr);
104
+ const bedStart = clipStart(bed.start);
94
105
  // Every voice, summed onto the bed's own clock. One question — where and
95
106
  // when is speech masking this bed — with one answer, even when the answer
96
107
  // comes from three people talking at different times. Doing this before the
@@ -98,23 +109,24 @@ export async function measureCarve(
98
109
  // the chain is fixed, so there is no per-voice filter to switch between.
99
110
  const decoded = await Promise.all(
100
111
  voices.map(async (voice) => ({
101
- samples: (await decode(voice.src)).getChannelData(0),
112
+ samples: await decode(voice.src, voice.clock),
102
113
  offsetSeconds: clipStart(voice.start) - bedStart,
103
114
  })),
104
115
  );
105
- const voiceMix = mixCarveSources(decoded, DECODE_SAMPLE_RATE);
106
- if (voiceMix.length === 0) return null;
116
+ const mixed = mixCarveSources(decoded, DECODE_SAMPLE_RATE);
117
+ if (mixed.length === 0) return null;
107
118
  // Strength is what the author set; these are the numbers it means.
108
119
  const profile = carveProfile(strength);
109
- // The bed as well as the voice, when the carve is asked to match levels:
110
- // "how far over the voice is this bed" cannot be answered by listening to
111
- // one of them.
112
- const bedBuffer = profile.duckDb > 0 && bedSrc ? await decode(bedSrc).catch(() => null) : null;
120
+ // The bed as it plays: its length bounds the speech worth carving, and the duck
121
+ // compares against it ("how far over the voice is this bed" needs both).
122
+ const bedSamples = bed.src ? await decode(bed.src, bed.clock).catch(() => null) : null;
123
+ // Speech after the bed stops has no bed to carve.
124
+ const voiceMix = bedSamples ? mixed.subarray(0, bedSamples.length) : mixed;
113
125
  const bands = analyseCarveBands(voiceMix, DECODE_SAMPLE_RATE, profile);
114
126
  // The level half of the carve, measured against the speech it has to sit
115
127
  // under. No offset to apply: the mix is already on the bed's clock.
116
- const duck = bedBuffer
117
- ? analyseCarveDuck(voiceMix, bedBuffer.getChannelData(0), DECODE_SAMPLE_RATE, profile, 0)
128
+ const duck = bedSamples
129
+ ? analyseCarveDuck(voiceMix, bedSamples, DECODE_SAMPLE_RATE, profile, 0)
118
130
  : [];
119
131
  return { bands, carved: carveBandsToChain(bands), duck, voiceMix };
120
132
  }
@@ -15,13 +15,7 @@ import {
15
15
  } from "@hyperframes/core/audio-fx";
16
16
  import { levellingResult, removeLevelling } from "@hyperframes/core/audio-leveller";
17
17
  import type { HfAutomation, HfAutomationPoint } from "@hyperframes/core/audio-automation";
18
- import {
19
- resolveRateSpec,
20
- sourceTimeAt,
21
- timeAtSourceTime,
22
- type RateSpec,
23
- } from "@hyperframes/core/speed-ramp";
24
- import { readPlaybackRate } from "@hyperframes/parsers/media-duration";
18
+ import { timeAtSourceTime } from "@hyperframes/core/speed-ramp";
25
19
  import {
26
20
  automationAttrValue,
27
21
  HF_AUDIO_AUTOMATION_ATTR,
@@ -30,7 +24,7 @@ import {
30
24
  } from "./propertyPanelAutomation";
31
25
  import { trackLeveller } from "./audioFxTelemetry.js";
32
26
  import type { DomEditSelection } from "./domEditingTypes";
33
- import { readClipInPoint } from "./propertyPanelHelpers";
27
+ import { clipSourceWindow, readClipClock } from "./clipAudioClock.js";
34
28
  import { useAuditionTransport } from "./useAuditionTransport.js";
35
29
 
36
30
  /**
@@ -39,11 +33,6 @@ import { useAuditionTransport } from "./useAuditionTransport.js";
39
33
  */
40
34
  const DECODE_SAMPLE_RATE = 48000;
41
35
 
42
- /** A parsed attribute, kept only when it is a usable positive number. */
43
- function positiveFinite(n: number): number | null {
44
- return Number.isFinite(n) && n > 0 ? n : null;
45
- }
46
-
47
36
  export function useFxLevelling(
48
37
  element: DomEditSelection,
49
38
  chain: HfAudioFxChain,
@@ -83,42 +72,17 @@ export function useFxLevelling(
83
72
  return next;
84
73
  };
85
74
 
86
- /**
87
- * The part of the decoded file this clip actually plays.
88
- *
89
- * A lane's `t` is seconds from the start of the CLIP, but the decode is the
90
- * whole file from its first sample — so measuring a trimmed clip produced an
91
- * envelope offset by the trim, and every correction landed early by exactly the
92
- * in-point. Slicing here is what puts the two clocks back on the same
93
- * zero.
94
- */
95
- const clipWindow = (audio: { samples: Float32Array; sampleRate: number }, rate: RateSpec) => {
96
- const { mediaStart } = readClipInPoint(element.dataAttributes);
97
- const duration = positiveFinite(Number(element.dataAttributes?.["duration"] ?? Number.NaN));
98
- const from = mediaStart
99
- ? Math.min(audio.samples.length, Math.floor(mediaStart * audio.sampleRate))
100
- : 0;
101
- const to = duration
102
- ? Math.min(
103
- audio.samples.length,
104
- from + Math.ceil(sourceTimeAt(rate, duration) * audio.sampleRate),
105
- )
106
- : audio.samples.length;
107
- return from === 0 && to === audio.samples.length
108
- ? audio.samples
109
- : audio.samples.subarray(from, to);
110
- };
111
-
112
75
  /** The levelled chain and its lane, with the lane moved from the source's clock onto the clip's. */
113
76
  const measureLevelling = (audio: { samples: Float32Array; sampleRate: number }) => {
114
- const attr = (name: string) => element.dataAttributes?.[name.slice(5)];
115
- const rate = resolveRateSpec(attr("data-automation"), readPlaybackRate(attr));
116
- const result = levellingResult(chain, clipWindow(audio, rate), audio.sampleRate);
77
+ // Measured on the part of the file the clip plays, so lane times map from that window's zero.
78
+ const clock = readClipClock((name) => element.dataAttributes?.[name.slice(5)]);
79
+ const played = clipSourceWindow(audio.samples, audio.sampleRate, clock);
80
+ const result = levellingResult(chain, played, audio.sampleRate);
117
81
  if (!result) return null;
118
82
  const lane = result.automation.lanes[0];
119
83
  const toClipTime = (point: HfAutomationPoint) => ({
120
84
  ...point,
121
- t: Number(timeAtSourceTime(rate, point.t).toFixed(3)),
85
+ t: Number(timeAtSourceTime(clock.rate, point.t).toFixed(3)),
122
86
  });
123
87
  return { chain: result.chain, lane: lane && { ...lane, points: lane.points.map(toClipTime) } };
124
88
  };
@@ -1,4 +1,4 @@
1
- import { useRef, type ReactNode, type RefObject } from "react";
1
+ import { useEffect, useRef, type ReactNode, type RefObject } from "react";
2
2
  import { InlineTextToolbar } from "./InlineTextToolbar";
3
3
  import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
4
4
  import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
@@ -12,6 +12,8 @@ import {
12
12
  } from "./domEditInlineText";
13
13
  import type { DomEditSelection } from "./domEditingTypes";
14
14
 
15
+ type KeyChord = Pick<KeyboardEvent, "key" | "shiftKey" | "ctrlKey" | "metaKey" | "altKey">;
16
+
15
17
  /**
16
18
  * The canvas' side of editing text where it sits.
17
19
  *
@@ -23,18 +25,15 @@ import type { DomEditSelection } from "./domEditingTypes";
23
25
  * props, the same way the agent surfaces in that overlay read it, and it is
24
26
  * absent in standalone player mounts, which have no project to edit.
25
27
  */
26
- export function useInlineTextEditing(selectionRef: RefObject<DomEditSelection | null>): {
28
+ export function useInlineTextEditing(
29
+ selectionRef: RefObject<DomEditSelection | null>,
30
+ { enterFromWindow = false }: { enterFromWindow?: boolean } = {},
31
+ ): {
27
32
  editing: boolean;
28
33
  /** Open an edit when this press pairs with the last one. */
29
34
  startFromPress: (event: { clientX: number; clientY: number }) => boolean;
30
35
  /** Handle a key on the canvas. Returns true when it opened an edit. */
31
- handleKeyDown: (event: {
32
- key: string;
33
- shiftKey: boolean;
34
- ctrlKey: boolean;
35
- metaKey: boolean;
36
- altKey: boolean;
37
- }) => boolean;
36
+ handleKeyDown: (event: KeyChord) => boolean;
38
37
  /**
39
38
  * The styling controls for the current selection, for the caller to render.
40
39
  *
@@ -84,6 +83,32 @@ export function useInlineTextEditing(selectionRef: RefObject<DomEditSelection |
84
83
  return { x: (event.clientX - box.left) / scale, y: (event.clientY - box.top) / scale };
85
84
  };
86
85
 
86
+ // Enter opens the selected element's text, the way every design tool does,
87
+ // and is the dependable way in: a double press has to survive the canvas'
88
+ // gesture machinery, while this is one key on a selection that has settled.
89
+ const handleKeyDown = (event: KeyChord) => {
90
+ if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey)
91
+ return false;
92
+ const target = selectionRef.current;
93
+ if (inlineText.session || !target || !canEditTextInline(target)) return false;
94
+ return inlineText.start(target.element);
95
+ };
96
+ const handleKeyDownRef = useRef(handleKeyDown);
97
+ handleKeyDownRef.current = handleKeyDown;
98
+ // A host-mode canvas takes no presses of its own, so it is rarely focused;
99
+ // with nothing focused, Enter comes via the window.
100
+ useEffect(() => {
101
+ if (!enterFromWindow) return;
102
+ const onKeyDown = (event: KeyboardEvent) => {
103
+ const { body, documentElement } = window.document;
104
+ if (event.defaultPrevented || (event.target !== body && event.target !== documentElement))
105
+ return;
106
+ if (handleKeyDownRef.current(event)) event.preventDefault();
107
+ };
108
+ window.addEventListener("keydown", onKeyDown);
109
+ return () => window.removeEventListener("keydown", onKeyDown);
110
+ }, [enterFromWindow]);
111
+
87
112
  return {
88
113
  editing: inlineText.session !== null,
89
114
  toolbar: (
@@ -92,16 +117,7 @@ export function useInlineTextEditing(selectionRef: RefObject<DomEditSelection |
92
117
  iframe={actions?.previewIframeRef?.current ?? null}
93
118
  />
94
119
  ),
95
- // Enter opens the selected element's text, the way every design tool does,
96
- // and is the dependable way in: a double press has to survive the canvas'
97
- // gesture machinery, while this is one key on a selection that has settled.
98
- handleKeyDown: (event) => {
99
- if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey)
100
- return false;
101
- const target = selectionRef.current;
102
- if (inlineText.session || !target || !canEditTextInline(target)) return false;
103
- return inlineText.start(target.element);
104
- },
120
+ handleKeyDown,
105
121
  startFromPress: (event) => {
106
122
  if (inlineText.session) return false;
107
123
  const element = elementUnderPress(event);
@@ -16,6 +16,7 @@ import { buildProjectApiPath } from "../../utils/projectRouting";
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
18
  import type { FeedbackContext } from "./feedbackTrigger";
19
+ import { isMediaFile } from "../../utils/mediaTypes";
19
20
 
20
21
  /** Written by `hyperframes init`; absent in a hand-made or copied project. */
21
22
  const CONFIG_FILE = "hyperframes.json";
@@ -23,12 +24,10 @@ const CONFIG_FILE = "hyperframes.json";
23
24
  /** Matches the CLI's own slug gate, so a hand-edited value cannot leak text. */
24
25
  const SKILL_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
25
26
 
26
- const MEDIA_EXTENSIONS = /\.(mp4|mov|webm|m4v|mp3|wav|m4a|aac|ogg|png|jpe?g|gif|webp|svg|avif)$/i;
27
-
28
27
  let snapshot: FeedbackContext = {};
29
28
 
30
29
  function countMedia(files: string[]): number {
31
- return files.filter((f) => MEDIA_EXTENSIONS.test(f)).length;
30
+ return files.filter(isMediaFile).length;
32
31
  }
33
32
 
34
33
  /**
@@ -1,7 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import { CaptionOverlay } from "../../captions/components/CaptionOverlay";
3
3
  import { useCaptionStore } from "../../captions/store";
4
- import { DomEditOverlay } from "../editor/DomEditOverlay";
4
+ import { ConnectedDomEditOverlay } from "../editor/ConnectedDomEditOverlay";
5
5
  import { TopologyLens } from "../editor/TopologyLens";
6
6
  import { MotionPathOverlay } from "../editor/MotionPathOverlay";
7
7
  import { SnapToolbar } from "../editor/SnapToolbar";
@@ -9,16 +9,7 @@ import { GridOverlay } from "../editor/GridOverlay";
9
9
  import { usePreviewReadOnly } from "../editor/previewReadOnlyContext";
10
10
  import { useCompositionDimensions } from "../../hooks/useCompositionDimensions";
11
11
  import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
12
- import {
13
- useDomEditActionsContext,
14
- useDomEditSelectionContext,
15
- } from "../../contexts/DomEditContext";
16
- import { readHfId, type DomEditSelection } from "../editor/domEditing";
17
- import { buildStableSelector } from "../editor/domEditingDom";
18
- import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
19
- import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
20
- import { useCanvasZOrderTimelineMirror } from "./useCanvasZOrderTimelineMirror";
21
- import { runZLaneGesture } from "./zLaneGesture";
12
+ import { useDomEditSelectionContext } from "../../contexts/DomEditContext";
22
13
  import type { BlockPreviewInfo } from "../sidebar/BlocksTab";
23
14
  import type { GestureRecordingState } from "../editor/GestureRecordControl";
24
15
  import type { ReactNode } from "react";
@@ -33,109 +24,12 @@ export interface PreviewOverlaysProps {
33
24
  gestureOverlay?: ReactNode;
34
25
  }
35
26
 
36
- type ZIndexReorderEntry = {
37
- element: HTMLElement;
38
- zIndex: number;
39
- id?: string;
40
- selector?: string;
41
- selectorIndex?: number;
42
- sourceFile: string;
43
- /** Timeline store key — lets the commit update the store zIndex synchronously. */
44
- key?: string;
45
- };
46
-
47
- /** Can this element be robustly re-targeted for a persisted z change? */
48
- function canTargetZIndexElement(
49
- element: HTMLElement,
50
- id: string | undefined,
51
- selector: string | undefined,
52
- ): boolean {
53
- return Boolean(id || selector || readHfId(element));
54
- }
55
-
56
- /** The selected element carries its full selection identity. */
57
- function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReorderEntry {
58
- return {
59
- element: sel.element,
60
- zIndex,
61
- id: sel.id ?? undefined,
62
- selector: sel.selector,
63
- selectorIndex: sel.selectorIndex,
64
- sourceFile: sel.sourceFile,
65
- key: deriveTimelineStoreKey({
66
- domId: sel.id ?? undefined,
67
- selector: sel.selector,
68
- selectorIndex: sel.selectorIndex,
69
- sourceFile: sel.sourceFile,
70
- }),
71
- };
72
- }
73
-
74
- /**
75
- * Sibling elements are raw iframe DOM nodes with no selection object: derive a
76
- * PatchTarget from the node itself (siblings live in the same document, so they
77
- * share the selection's sourceFile). Null when it cannot be robustly targeted
78
- * (no id and no selector) — its z stays live-only.
79
- */
80
- function siblingZIndexEntry(
81
- element: HTMLElement,
82
- zIndex: number,
83
- sourceFile: string,
84
- ): ZIndexReorderEntry | null {
85
- const id = element.id || undefined;
86
- const selector = buildStableSelector(element);
87
- if (!canTargetZIndexElement(element, id, selector)) return null;
88
- return {
89
- element,
90
- zIndex,
91
- id,
92
- selector,
93
- selectorIndex: undefined,
94
- sourceFile,
95
- key: deriveTimelineStoreKey({ domId: id, selector, sourceFile }),
96
- };
97
- }
98
-
99
- /** Short human-readable label for a dropped sibling, for the console warning below. */
100
- function describeZIndexElement(element: HTMLElement): string {
101
- if (element.id) return `#${element.id}`;
102
- const firstClass = element.classList.item(0);
103
- return firstClass
104
- ? `${element.tagName.toLowerCase()}.${firstClass}`
105
- : element.tagName.toLowerCase();
106
- }
107
-
108
- // Resolve z-index patches into commit entries; a sibling with no stable
109
- // id/selector can't be written to source, so it is returned as `dropped` for
110
- // the revert-on-reload warning (and a live-only style write, so the resolved
111
- // stacking order still renders coherently). Exported so tests can drive the
112
- // menu → commit path through the same wiring the app uses.
113
- export function resolveZIndexEntries(
114
- sel: DomEditSelection,
115
- patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
116
- ): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
117
- const entries: ZIndexReorderEntry[] = [];
118
- const dropped: Array<{ element: HTMLElement; zIndex: number }> = [];
119
- for (const patch of patches) {
120
- if (patch.element === sel.element) {
121
- entries.push(selectedZIndexEntry(sel, patch.zIndex));
122
- continue;
123
- }
124
- const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile);
125
- if (entry) entries.push(entry);
126
- else dropped.push(patch);
127
- }
128
- return { entries, dropped };
129
- }
130
-
131
27
  // fallow-ignore-next-line complexity
132
28
  export function PreviewOverlays({
133
29
  shouldShowMotionPath,
134
30
  shouldShowSelectedDomBounds,
135
31
  blockPreview,
136
32
  isGestureRecording,
137
- recordingState,
138
- onToggleRecording,
139
33
  gestureOverlay,
140
34
  }: PreviewOverlaysProps) {
141
35
  const { activeCompPath, previewIframeRef } = useStudioShellContext();
@@ -162,25 +56,7 @@ export function PreviewOverlays({
162
56
  store.setEditMode(true);
163
57
  }, []);
164
58
 
165
- const { domEditHoverSelection, domEditSelection, domEditGroupSelections } =
166
- useDomEditSelectionContext();
167
- const {
168
- handlePreviewCanvasMouseDown,
169
- handlePreviewCanvasPointerMove,
170
- handlePreviewCanvasPointerLeave,
171
- applyDomSelection,
172
- handleBlockedDomMove,
173
- handleDomManualDragStart,
174
- handleDomPathOffsetCommit,
175
- handleDomGroupPathOffsetCommit,
176
- handleDomBoxSizeCommit,
177
- handleDomRotationCommit,
178
- handleDomStyleCommit,
179
- applyMarqueeSelection,
180
- handleDomEditElementDelete,
181
- handleDomZIndexReorderCommit,
182
- } = useDomEditActionsContext();
183
- const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror();
59
+ const { domEditSelection } = useDomEditSelectionContext();
184
60
 
185
61
  if (blockPreview) {
186
62
  return (
@@ -256,68 +132,11 @@ export function PreviewOverlays({
256
132
  <>
257
133
  <TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
258
134
  <GridOverlay />
259
- <DomEditOverlay
260
- iframeRef={previewIframeRef}
135
+ <ConnectedDomEditOverlay
261
136
  activeCompositionPath={activeCompPath}
262
- hoverSelection={
263
- !previewCaptionEditMode && !compositionLoading && !isPlaying
264
- ? domEditHoverSelection
265
- : null
266
- }
267
- selection={shouldShowSelectedDomBounds ? domEditSelection : null}
268
- groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
269
- allowCanvasMovement={!isGestureRecording}
270
- onCanvasMouseDown={handlePreviewCanvasMouseDown}
271
- onCanvasPointerMove={handlePreviewCanvasPointerMove}
272
- onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
273
- onSelectionChange={applyDomSelection}
274
- onBlockedMove={handleBlockedDomMove}
275
- onManualDragStart={handleDomManualDragStart}
276
- onPathOffsetCommit={handleDomPathOffsetCommit}
277
- onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
278
- onBoxSizeCommit={handleDomBoxSizeCommit}
279
- onRotationCommit={handleDomRotationCommit}
280
- onStyleCommit={handleDomStyleCommit}
281
- onDeleteSelection={handleDomEditElementDelete}
282
- onApplyZIndex={(sel, patches, action, crossed) => {
283
- const { entries, dropped } = resolveZIndexEntries(sel, patches);
284
- if (dropped.length > 0) {
285
- // These siblings can't be written to source. Apply their live z
286
- // anyway so the resolved stacking order renders coherently — it
287
- // just reverts to the prior order on the next reload.
288
- for (const patch of dropped) patch.element.style.zIndex = String(patch.zIndex);
289
- console.warn(
290
- "[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
291
- "(will revert on reload):",
292
- dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
293
- );
294
- }
295
- if (entries.length === 0) return;
296
- // Shared undo coalesce key: passed to BOTH the z persist and the
297
- // timeline lane mirror below so editHistory folds the two records
298
- // into one undo entry (same value handleDomZIndexReorderCommit would
299
- // default to — passed explicitly so the mirror shares it by
300
- // construction, not by formula duplication).
301
- const coalesceKey = zReorderCoalesceKey(entries, action);
302
- // One serialized z→lane transaction: the mirror runs only AFTER the
303
- // z commit resolved AND reported durable targets, and a second rapid
304
- // gesture cannot interleave between the two phases — see
305
- // runZLaneGesture. A failed z commit already toasted + rolled back.
306
- runZLaneGesture({
307
- commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, action),
308
- mirror: () =>
309
- mirrorZOrderToTimeline({
310
- selectionKey: entries.find((e) => e.element === sel.element)?.key,
311
- action,
312
- crossed,
313
- sourceFile: sel.sourceFile,
314
- coalesceKey,
315
- }),
316
- }).catch(() => undefined);
317
- }}
318
- recordingState={recordingState}
319
- onToggleRecording={onToggleRecording}
320
- onMarqueeSelect={applyMarqueeSelection}
137
+ showHoverSelection={!previewCaptionEditMode && !compositionLoading && !isPlaying}
138
+ shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
139
+ isGestureRecording={isGestureRecording}
321
140
  />
322
141
  <SnapToolbar />
323
142
  {!readOnly && (
@@ -1,6 +1,6 @@
1
1
  import { vi } from "vitest";
2
2
 
3
- const previewState = vi.hoisted(() => ({ captionEditMode: false }));
3
+ const previewState = vi.hoisted(() => ({ captionEditMode: false, isPlaying: false }));
4
4
  export function getPreviewState() {
5
5
  return previewState;
6
6
  }
@@ -11,7 +11,7 @@ vi.mock("../../contexts/StudioContext", () => ({
11
11
  useStudioPlaybackContext: () => ({
12
12
  captionEditMode: previewState.captionEditMode,
13
13
  compositionLoading: false,
14
- isPlaying: false,
14
+ isPlaying: previewState.isPlaying,
15
15
  }),
16
16
  }));
17
17
  vi.mock("../../captions/store", () => {
@@ -30,7 +30,7 @@ export interface MirrorZOrderInput {
30
30
  /**
31
31
  * Mirror a successful canvas z-order menu action into a timeline LANE move.
32
32
  *
33
- * The caller (PreviewOverlays) invokes the returned callback AFTER the z commit
33
+ * The caller (ConnectedDomEditOverlay) invokes the returned callback AFTER the z commit
34
34
  * resolved — serializing the two same-file writes, exactly like the lane-drag's
35
35
  * move→z ordering (see persistMoveEdits' doc) — and with the SAME coalesce key
36
36
  * the z persist recorded, so editHistory folds both records into one undo entry.