@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.
- package/dist/assets/{hyperframes-player-zGIbZhLJ.js → hyperframes-player-BAerMCex.js} +1 -1
- package/dist/assets/{index-BiLCTunu.js → index-BUhcVhNy.js} +1 -1
- package/dist/assets/{index-DSazIt7R.js → index-BYhK_KMc.js} +193 -193
- package/dist/assets/{index-DStmfnpZ.js → index-Iv0HI7eM.js} +1 -1
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-LPZMAGS7.js +1871 -0
- package/dist/chunk-LPZMAGS7.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +669 -330
- package/dist/index.html +1 -1
- package/dist/index.js +20501 -23167
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +135 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +7 -43
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/useElementLifecycleOps.ts +1 -1
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/index.ts +45 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/playerEntry.ts +1 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /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:
|
|
79
|
+
voices: CarveClip[],
|
|
72
80
|
strength: number,
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
112
|
+
samples: await decode(voice.src, voice.clock),
|
|
102
113
|
offsetSeconds: clipStart(voice.start) - bedStart,
|
|
103
114
|
})),
|
|
104
115
|
);
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
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
|
|
110
|
-
// "how far over the voice is this bed"
|
|
111
|
-
|
|
112
|
-
|
|
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 =
|
|
117
|
-
? analyseCarveDuck(voiceMix,
|
|
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 {
|
|
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
|
-
|
|
115
|
-
const
|
|
116
|
-
const
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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 {
|
|
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
|
-
<
|
|
260
|
-
iframeRef={previewIframeRef}
|
|
135
|
+
<ConnectedDomEditOverlay
|
|
261
136
|
activeCompositionPath={activeCompPath}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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:
|
|
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 (
|
|
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.
|