@hyperframes/studio 0.7.61 → 0.7.63
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-Xvx2hkrc.js → hyperframes-player-BO2UXx-E.js} +1 -1
- package/dist/assets/{index-D-uFclFj.js → index-C0A0DlLk.js} +1 -1
- package/dist/assets/index-C_7k3kwr.js +426 -0
- package/dist/assets/index-Ceyz8Qt2.css +1 -0
- package/dist/assets/{index-BCpoiv9S.js → index-_72wCKSP.js} +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3504 -2955
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +15 -10
- package/src/components/EditorShell.tsx +12 -0
- package/src/components/StudioLeftSidebar.tsx +3 -0
- package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
- package/src/components/editor/PropertyPanelFlat.tsx +6 -0
- package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
- package/src/components/editor/domEditing.test.ts +34 -0
- package/src/components/editor/propertyPanelColor.test.tsx +31 -0
- package/src/components/editor/propertyPanelColor.tsx +150 -28
- package/src/components/editor/propertyPanelCommitField.tsx +178 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +34 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +5 -1
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +6 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
- package/src/components/editor/propertyPanelFlatTextSection.tsx +8 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +4 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -100
- package/src/components/editor/propertyPanelSections.tsx +2 -2
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +6 -0
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
- package/src/components/sidebar/CompositionsTab.tsx +45 -2
- package/src/components/sidebar/LeftSidebar.tsx +3 -0
- package/src/contexts/StudioContext.tsx +4 -0
- package/src/hooks/timelineEditingHelpers.ts +10 -4
- package/src/hooks/usePreviewPersistence.ts +7 -1
- package/src/hooks/useRazorSplit.history.test.tsx +21 -28
- package/src/hooks/useRazorSplit.test.tsx +20 -9
- package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
- package/src/hooks/useRazorSplit.ts +78 -350
- package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +52 -2
- package/src/hooks/useTimelineEditing.ts +19 -17
- package/src/hooks/useTimelineGroupEditing.ts +6 -7
- package/src/player/components/Timeline.tsx +12 -1
- package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
- package/src/player/components/timelineAuthoredTrack.ts +29 -0
- package/src/player/components/timelineCallbacks.ts +4 -0
- package/src/player/components/timelineClipDragCommit.test.ts +176 -0
- package/src/player/components/timelineClipDragCommit.ts +77 -105
- package/src/player/components/timelineClipDragPreview.test.ts +33 -1
- package/src/player/components/timelineClipDragPreview.ts +2 -1
- package/src/player/components/timelineCollision.test.ts +18 -0
- package/src/player/components/timelineCollision.ts +12 -9
- package/src/player/components/timelineDragDrop.ts +28 -7
- package/src/player/components/timelineEditCapabilities.ts +4 -1
- package/src/player/components/timelineGroupEditing.test.ts +10 -0
- package/src/player/components/timelineGroupEditing.ts +4 -3
- package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
- package/src/player/components/timelineLayout.ts +3 -1
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.ts +5 -0
- package/src/player/components/useTimelineEditPinning.ts +12 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/lib/playbackTypes.ts +2 -0
- package/src/player/lib/timelineDOM.test.ts +58 -0
- package/src/player/lib/timelineDOM.ts +18 -1
- package/src/player/lib/timelineElementHelpers.ts +21 -11
- package/src/player/store/playerStore.ts +2 -1
- package/src/utils/razorSplitTransaction.test.ts +175 -0
- package/src/utils/razorSplitTransaction.ts +200 -0
- package/src/utils/studioPreviewHelpers.test.ts +27 -0
- package/src/utils/timelineCompositionDrop.test.ts +12 -0
- package/src/utils/timelineCompositionDrop.ts +16 -0
- package/src/utils/timelineCompositionInsert.test.ts +142 -0
- package/src/utils/timelineCompositionInsert.ts +90 -0
- package/src/utils/timelineElementSplit.test.ts +14 -2
- package/src/utils/timelineElementSplit.ts +5 -1
- package/dist/assets/index-2mxh_HSy.js +0 -426
- package/dist/assets/index-BhWig0mx.css +0 -1
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act, useState } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
+
import postcss from "postcss";
|
|
6
|
+
import tailwindcss from "tailwindcss";
|
|
5
7
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
8
|
import { FlatTextLayerList, FlatTextSection } from "./propertyPanelFlatTextSection";
|
|
7
9
|
import type { DomEditSelection, DomEditTextField } from "./domEditingTypes";
|
|
@@ -256,8 +258,9 @@ describe("FlatTextFieldEditor controls", () => {
|
|
|
256
258
|
act(() => root.unmount());
|
|
257
259
|
});
|
|
258
260
|
|
|
259
|
-
it("
|
|
261
|
+
it("previews Size input immediately and persists once on blur", () => {
|
|
260
262
|
const onSetTextFieldStyle = vi.fn();
|
|
263
|
+
const onPreviewTextFieldStyle = vi.fn();
|
|
261
264
|
const { host, root } = renderInto(
|
|
262
265
|
<FlatTextSection
|
|
263
266
|
element={makeSingleFieldElement()}
|
|
@@ -265,6 +268,7 @@ describe("FlatTextFieldEditor controls", () => {
|
|
|
265
268
|
fontAssets={[]}
|
|
266
269
|
onSetText={vi.fn()}
|
|
267
270
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
|
271
|
+
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
|
|
268
272
|
onAddTextField={vi.fn()}
|
|
269
273
|
onRemoveTextField={vi.fn()}
|
|
270
274
|
/>,
|
|
@@ -275,6 +279,7 @@ describe("FlatTextFieldEditor controls", () => {
|
|
|
275
279
|
const input = sizeLabel?.parentElement?.querySelector<HTMLInputElement>("input");
|
|
276
280
|
if (!input) throw new Error("expected the Size row's input");
|
|
277
281
|
act(() => {
|
|
282
|
+
input.focus();
|
|
278
283
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
|
279
284
|
window.HTMLInputElement.prototype,
|
|
280
285
|
"value",
|
|
@@ -282,9 +287,10 @@ describe("FlatTextFieldEditor controls", () => {
|
|
|
282
287
|
nativeInputValueSetter?.call(input, "24px");
|
|
283
288
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
284
289
|
});
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
290
|
+
expect(onPreviewTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px");
|
|
291
|
+
expect(onSetTextFieldStyle).not.toHaveBeenCalled();
|
|
292
|
+
act(() => {
|
|
293
|
+
input.blur();
|
|
288
294
|
});
|
|
289
295
|
expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px");
|
|
290
296
|
act(() => root.unmount());
|
|
@@ -462,4 +468,41 @@ describe("FlatTextSection — multi-field", () => {
|
|
|
462
468
|
|
|
463
469
|
act(() => root.unmount());
|
|
464
470
|
});
|
|
471
|
+
|
|
472
|
+
it("keeps Content expandable and grows it with multiline text", async () => {
|
|
473
|
+
const element = makeMultiFieldElement();
|
|
474
|
+
element.textFields[0].value = "First line\nSecond line\nThird line";
|
|
475
|
+
|
|
476
|
+
const host = document.createElement("div");
|
|
477
|
+
document.body.append(host);
|
|
478
|
+
const root = createRoot(host);
|
|
479
|
+
act(() => {
|
|
480
|
+
root.render(
|
|
481
|
+
<FlatTextSection
|
|
482
|
+
element={element}
|
|
483
|
+
styles={{}}
|
|
484
|
+
fontAssets={[]}
|
|
485
|
+
onSetText={vi.fn()}
|
|
486
|
+
onSetTextFieldStyle={vi.fn()}
|
|
487
|
+
onAddTextField={vi.fn()}
|
|
488
|
+
onRemoveTextField={vi.fn()}
|
|
489
|
+
/>,
|
|
490
|
+
);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const contentTextarea = host.querySelector<HTMLTextAreaElement>("textarea");
|
|
494
|
+
expect(contentTextarea?.value).toBe("First line\nSecond line\nThird line");
|
|
495
|
+
expect(contentTextarea?.classList).toContain("resize-y");
|
|
496
|
+
expect(contentTextarea?.classList).toContain("overflow-y-auto");
|
|
497
|
+
|
|
498
|
+
const compiled = await postcss([
|
|
499
|
+
tailwindcss({
|
|
500
|
+
content: [{ raw: host.innerHTML, extension: "html" }],
|
|
501
|
+
corePlugins: { preflight: false },
|
|
502
|
+
}),
|
|
503
|
+
]).process("@tailwind utilities;", { from: undefined });
|
|
504
|
+
expect(compiled.css).toContain("field-sizing: content");
|
|
505
|
+
|
|
506
|
+
act(() => root.unmount());
|
|
507
|
+
});
|
|
465
508
|
});
|
|
@@ -47,6 +47,7 @@ function FlatTextFieldEditor({
|
|
|
47
47
|
onImportFonts,
|
|
48
48
|
onSetText,
|
|
49
49
|
onSetTextFieldStyle,
|
|
50
|
+
onPreviewTextFieldStyle,
|
|
50
51
|
autoFocus = false,
|
|
51
52
|
}: {
|
|
52
53
|
field: DomEditSelection["textFields"][number];
|
|
@@ -55,6 +56,7 @@ function FlatTextFieldEditor({
|
|
|
55
56
|
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
|
56
57
|
onSetText: (value: string, fieldKey?: string) => void;
|
|
57
58
|
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
|
59
|
+
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
|
|
58
60
|
autoFocus?: boolean;
|
|
59
61
|
}) {
|
|
60
62
|
const track = useTrackDesignInput();
|
|
@@ -100,6 +102,7 @@ function FlatTextFieldEditor({
|
|
|
100
102
|
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
|
|
101
103
|
tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")}
|
|
102
104
|
liveCommit
|
|
105
|
+
onPreview={(next) => onPreviewTextFieldStyle?.(field.key, "font-size", next)}
|
|
103
106
|
onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)}
|
|
104
107
|
/>
|
|
105
108
|
<div className="flex min-h-[30px] items-center justify-between">
|
|
@@ -220,6 +223,7 @@ function FlatTextFieldEditor({
|
|
|
220
223
|
flat
|
|
221
224
|
label="Color"
|
|
222
225
|
value={value ?? getTextFieldColor(field, styles)}
|
|
226
|
+
onPreview={(next) => onPreviewTextFieldStyle?.(field.key, "color", next)}
|
|
223
227
|
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "color", next))}
|
|
224
228
|
/>
|
|
225
229
|
)}
|
|
@@ -235,6 +239,7 @@ export function FlatTextSection({
|
|
|
235
239
|
onImportFonts,
|
|
236
240
|
onSetText,
|
|
237
241
|
onSetTextFieldStyle,
|
|
242
|
+
onPreviewTextFieldStyle,
|
|
238
243
|
onAddTextField,
|
|
239
244
|
onRemoveTextField,
|
|
240
245
|
}: {
|
|
@@ -244,6 +249,7 @@ export function FlatTextSection({
|
|
|
244
249
|
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
|
245
250
|
onSetText: (value: string, fieldKey?: string) => void;
|
|
246
251
|
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
|
252
|
+
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
|
|
247
253
|
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
|
248
254
|
onRemoveTextField: (fieldKey: string) => void;
|
|
249
255
|
}) {
|
|
@@ -288,6 +294,7 @@ export function FlatTextSection({
|
|
|
288
294
|
onImportFonts={onImportFonts}
|
|
289
295
|
onSetText={onSetText}
|
|
290
296
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
|
297
|
+
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
|
|
291
298
|
autoFocus
|
|
292
299
|
/>
|
|
293
300
|
</div>
|
|
@@ -303,6 +310,7 @@ export function FlatTextSection({
|
|
|
303
310
|
onImportFonts={onImportFonts}
|
|
304
311
|
onSetText={onSetText}
|
|
305
312
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
|
313
|
+
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
|
|
306
314
|
/>
|
|
307
315
|
<button
|
|
308
316
|
type="button"
|
|
@@ -356,7 +356,10 @@ describe.each(["classic", "flat"] as const)("shared %s input telemetry", (ui) =>
|
|
|
356
356
|
(input) => input.value === "#FF0000",
|
|
357
357
|
);
|
|
358
358
|
if (!hex) throw new Error("expected color hex input");
|
|
359
|
-
act(() =>
|
|
359
|
+
act(() => {
|
|
360
|
+
changeInput(hex, "#00FF00");
|
|
361
|
+
blurInput(hex);
|
|
362
|
+
});
|
|
360
363
|
|
|
361
364
|
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
|
|
362
365
|
expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
|
|
@@ -3,107 +3,10 @@ import {
|
|
|
3
3
|
DesignPanelInputProvider,
|
|
4
4
|
useTrackDesignInput,
|
|
5
5
|
} from "../../contexts/DesignPanelInputContext";
|
|
6
|
-
import {
|
|
6
|
+
import { FIELD, LABEL } from "./propertyPanelHelpers";
|
|
7
|
+
import { CommitField } from "./propertyPanelCommitField";
|
|
7
8
|
|
|
8
|
-
export
|
|
9
|
-
value,
|
|
10
|
-
disabled,
|
|
11
|
-
liveCommit,
|
|
12
|
-
align = "left",
|
|
13
|
-
onCommit,
|
|
14
|
-
}: {
|
|
15
|
-
value: string;
|
|
16
|
-
disabled?: boolean;
|
|
17
|
-
liveCommit?: boolean;
|
|
18
|
-
/** The legacy panel lays out label-then-value inline (left reads naturally);
|
|
19
|
-
* the flat inspector lays out label…value across a `justify-between` row,
|
|
20
|
-
* where a left-aligned value looks stranded at the edge of its own
|
|
21
|
-
* right-hand box instead of lining up with every other row's value. */
|
|
22
|
-
align?: "left" | "right";
|
|
23
|
-
onCommit: (nextValue: string) => void;
|
|
24
|
-
}) {
|
|
25
|
-
const [draft, setDraft] = useState(value);
|
|
26
|
-
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
27
|
-
const valueRef = useRef(value);
|
|
28
|
-
const draftRef = useRef(draft);
|
|
29
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
30
|
-
|
|
31
|
-
valueRef.current = value;
|
|
32
|
-
draftRef.current = draft;
|
|
33
|
-
|
|
34
|
-
useEffect(() => {
|
|
35
|
-
setDraft(value);
|
|
36
|
-
}, [value]);
|
|
37
|
-
|
|
38
|
-
useEffect(() => {
|
|
39
|
-
const el = inputRef.current;
|
|
40
|
-
if (!el) return;
|
|
41
|
-
const handler = (e: WheelEvent) => {
|
|
42
|
-
if (disabled || document.activeElement !== el) return;
|
|
43
|
-
const delta = e.deltaY === 0 ? e.deltaX : e.deltaY;
|
|
44
|
-
if (delta === 0) return;
|
|
45
|
-
const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, e);
|
|
46
|
-
if (!nextDraft) return;
|
|
47
|
-
e.preventDefault();
|
|
48
|
-
e.stopPropagation();
|
|
49
|
-
setDraft(nextDraft);
|
|
50
|
-
scheduleCommitRef.current(nextDraft);
|
|
51
|
-
};
|
|
52
|
-
el.addEventListener("wheel", handler, { passive: false });
|
|
53
|
-
return () => el.removeEventListener("wheel", handler);
|
|
54
|
-
}, [disabled]);
|
|
55
|
-
|
|
56
|
-
useEffect(
|
|
57
|
-
() => () => {
|
|
58
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
59
|
-
},
|
|
60
|
-
[],
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
const commitDraft = (nextDraft: string) => {
|
|
64
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
65
|
-
if (nextDraft !== valueRef.current) onCommit(nextDraft);
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const scheduleCommit = (nextDraft: string) => {
|
|
69
|
-
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
|
70
|
-
commitTimerRef.current = setTimeout(() => {
|
|
71
|
-
if (nextDraft !== valueRef.current) onCommit(nextDraft);
|
|
72
|
-
}, 120);
|
|
73
|
-
};
|
|
74
|
-
const scheduleCommitRef = useRef(scheduleCommit);
|
|
75
|
-
scheduleCommitRef.current = scheduleCommit;
|
|
76
|
-
|
|
77
|
-
return (
|
|
78
|
-
<input
|
|
79
|
-
ref={inputRef}
|
|
80
|
-
type="text"
|
|
81
|
-
value={draft}
|
|
82
|
-
disabled={disabled}
|
|
83
|
-
onChange={(e) => {
|
|
84
|
-
setDraft(e.target.value);
|
|
85
|
-
if (liveCommit) scheduleCommit(e.target.value);
|
|
86
|
-
}}
|
|
87
|
-
onBlur={() => commitDraft(draft)}
|
|
88
|
-
onKeyDown={(e) => {
|
|
89
|
-
if (e.key === "Enter") {
|
|
90
|
-
(e.target as HTMLInputElement).blur();
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
|
94
|
-
const nextDraft = adjustNumericToken(draft, e.key === "ArrowUp" ? 1 : -1, e);
|
|
95
|
-
if (!nextDraft) return;
|
|
96
|
-
e.preventDefault();
|
|
97
|
-
setDraft(nextDraft);
|
|
98
|
-
scheduleCommit(nextDraft);
|
|
99
|
-
}}
|
|
100
|
-
title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
|
|
101
|
-
className={`min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600 ${
|
|
102
|
-
align === "right" ? "text-right" : "text-left"
|
|
103
|
-
}`}
|
|
104
|
-
/>
|
|
105
|
-
);
|
|
106
|
-
}
|
|
9
|
+
export { CommitField } from "./propertyPanelCommitField";
|
|
107
10
|
|
|
108
11
|
/* ------------------------------------------------------------------ */
|
|
109
12
|
/* MetricField */
|
|
@@ -147,7 +147,7 @@ export function TextAreaField({
|
|
|
147
147
|
onFocus={handleFocus}
|
|
148
148
|
onChange={handleChange}
|
|
149
149
|
onBlur={handleBlur}
|
|
150
|
-
className="w-full resize-
|
|
150
|
+
className="[field-sizing:content] max-h-[40vh] min-h-12 w-full resize-y overflow-x-hidden overflow-y-auto bg-transparent font-mono text-[11px] leading-normal text-panel-text-0 outline-none disabled:cursor-not-allowed disabled:text-panel-text-4"
|
|
151
151
|
/>
|
|
152
152
|
</div>
|
|
153
153
|
);
|
|
@@ -165,7 +165,7 @@ export function TextAreaField({
|
|
|
165
165
|
onFocus={handleFocus}
|
|
166
166
|
onChange={handleChange}
|
|
167
167
|
onBlur={handleBlur}
|
|
168
|
-
className="w-full resize-
|
|
168
|
+
className="[field-sizing:content] max-h-[40vh] min-h-20 w-full resize-y overflow-x-hidden overflow-y-auto bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
169
169
|
/>
|
|
170
170
|
</div>
|
|
171
171
|
</label>
|
|
@@ -32,6 +32,7 @@ export interface PropertyPanelProps {
|
|
|
32
32
|
onClearSelection: () => void;
|
|
33
33
|
onUngroup?: () => void;
|
|
34
34
|
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
|
35
|
+
onPreviewStyle?: (prop: string, value: string) => void;
|
|
35
36
|
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
36
37
|
/** Commits several data-* attributes on the SAME element in ONE atomic
|
|
37
38
|
* persist call — e.g. a pinned timing range's start+duration together, so
|
|
@@ -62,6 +63,7 @@ export interface PropertyPanelProps {
|
|
|
62
63
|
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
|
|
63
64
|
onSetText: (value: string, fieldKey?: string) => void;
|
|
64
65
|
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
|
66
|
+
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
|
|
65
67
|
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
|
66
68
|
onRemoveTextField: (fieldKey: string) => void;
|
|
67
69
|
onAskAgent: () => void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
describe("useInspectorGestureTransaction", () => {
|
|
11
|
+
it("keeps a new gesture active when the prior async commit is acknowledged", () => {
|
|
12
|
+
const host = document.createElement("div");
|
|
13
|
+
const root = createRoot(host);
|
|
14
|
+
const onPreview = vi.fn();
|
|
15
|
+
const onCommit = vi.fn();
|
|
16
|
+
let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
|
|
17
|
+
|
|
18
|
+
function Probe({ sourceValue }: { sourceValue: number }) {
|
|
19
|
+
gesture = useInspectorGestureTransaction({ sourceValue, onPreview, onCommit });
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
act(() => root.render(<Probe sourceValue={10} />));
|
|
24
|
+
act(() => {
|
|
25
|
+
gesture?.preview(20);
|
|
26
|
+
gesture?.settle();
|
|
27
|
+
gesture?.preview(30);
|
|
28
|
+
});
|
|
29
|
+
act(() => root.render(<Probe sourceValue={20} />));
|
|
30
|
+
|
|
31
|
+
expect(onPreview).toHaveBeenLastCalledWith(30);
|
|
32
|
+
act(() => gesture?.settle());
|
|
33
|
+
expect(onCommit.mock.calls.map(([value]) => value)).toEqual([20, 30]);
|
|
34
|
+
|
|
35
|
+
act(() => root.unmount());
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/** One owner for continuous inspector edits: preview freely, persist once. */
|
|
4
|
+
export function useInspectorGestureTransaction<T>({
|
|
5
|
+
sourceValue,
|
|
6
|
+
onPreview,
|
|
7
|
+
onCommit,
|
|
8
|
+
}: {
|
|
9
|
+
sourceValue: T;
|
|
10
|
+
onPreview: (value: T) => void;
|
|
11
|
+
onCommit: (value: T) => void;
|
|
12
|
+
}) {
|
|
13
|
+
const sourceRef = useRef(sourceValue);
|
|
14
|
+
const activeRef = useRef<{ before: T; latest: T } | null>(null);
|
|
15
|
+
const previewRef = useRef(onPreview);
|
|
16
|
+
const commitRef = useRef(onCommit);
|
|
17
|
+
if (!activeRef.current) sourceRef.current = sourceValue;
|
|
18
|
+
previewRef.current = onPreview;
|
|
19
|
+
commitRef.current = onCommit;
|
|
20
|
+
|
|
21
|
+
const begin = useCallback(() => {
|
|
22
|
+
if (!activeRef.current) {
|
|
23
|
+
activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
|
|
24
|
+
}
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
const preview = useCallback((value: T) => {
|
|
28
|
+
if (!activeRef.current) {
|
|
29
|
+
activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
|
|
30
|
+
}
|
|
31
|
+
activeRef.current.latest = value;
|
|
32
|
+
previewRef.current(value);
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
const settle = useCallback(() => {
|
|
36
|
+
const active = activeRef.current;
|
|
37
|
+
activeRef.current = null;
|
|
38
|
+
if (active && !Object.is(active.before, active.latest)) {
|
|
39
|
+
sourceRef.current = active.latest;
|
|
40
|
+
// Restore the captured baseline before the persistent commit captures
|
|
41
|
+
// rollback state. The commit reapplies `latest` synchronously, so this
|
|
42
|
+
// is not visible but a failed save can now correctly restore `before`.
|
|
43
|
+
previewRef.current(active.before);
|
|
44
|
+
commitRef.current(active.latest);
|
|
45
|
+
}
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const cancel = useCallback(() => {
|
|
49
|
+
const active = activeRef.current;
|
|
50
|
+
activeRef.current = null;
|
|
51
|
+
if (active && !Object.is(active.before, active.latest)) {
|
|
52
|
+
sourceRef.current = active.before;
|
|
53
|
+
previewRef.current(active.before);
|
|
54
|
+
}
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
useEffect(() => cancel, [cancel]);
|
|
58
|
+
|
|
59
|
+
return { begin, preview, settle, cancel, activeRef };
|
|
60
|
+
}
|
|
@@ -96,6 +96,10 @@ export interface TimelinePaneProps {
|
|
|
96
96
|
blockName: string,
|
|
97
97
|
placement: Pick<TimelineElement, "start" | "track">,
|
|
98
98
|
) => Promise<void> | void;
|
|
99
|
+
onCompositionDrop?: (
|
|
100
|
+
sourcePath: string,
|
|
101
|
+
placement: Pick<TimelineElement, "start" | "track">,
|
|
102
|
+
) => Promise<void> | void;
|
|
99
103
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
100
104
|
onSelectTimelineElement?: (element: TimelineElement | null) => void;
|
|
101
105
|
}
|
|
@@ -109,6 +113,7 @@ export function TimelinePane({
|
|
|
109
113
|
onDeleteElement,
|
|
110
114
|
onAssetDrop,
|
|
111
115
|
onBlockDrop,
|
|
116
|
+
onCompositionDrop,
|
|
112
117
|
onBlockedEditAttempt,
|
|
113
118
|
onSelectTimelineElement,
|
|
114
119
|
}: TimelinePaneProps) {
|
|
@@ -273,6 +278,7 @@ export function TimelinePane({
|
|
|
273
278
|
onDeleteElement={handleDeleteElement}
|
|
274
279
|
onAssetDrop={onAssetDrop}
|
|
275
280
|
onBlockDrop={onBlockDrop}
|
|
281
|
+
onCompositionDrop={onCompositionDrop}
|
|
276
282
|
onMoveElement={handleMoveElement}
|
|
277
283
|
onMoveElements={handleMoveElements}
|
|
278
284
|
onResizeElement={handleResizeElement}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
|
7
|
+
import { CompositionsTab } from "./CompositionsTab";
|
|
8
|
+
|
|
9
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
10
|
+
(
|
|
11
|
+
window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
|
|
12
|
+
).happyDOM.settings.disableIframePageLoading = true;
|
|
13
|
+
|
|
14
|
+
let root: Root | null = null;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (root) act(() => root?.unmount());
|
|
18
|
+
root = null;
|
|
19
|
+
document.body.innerHTML = "";
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
|
|
23
|
+
const host = document.createElement("div");
|
|
24
|
+
document.body.append(host);
|
|
25
|
+
root = createRoot(host);
|
|
26
|
+
act(() => {
|
|
27
|
+
root?.render(
|
|
28
|
+
<CompositionsTab
|
|
29
|
+
projectId="demo"
|
|
30
|
+
compositions={["compositions/headline.html"]}
|
|
31
|
+
activeComposition={null}
|
|
32
|
+
onSelect={onSelect}
|
|
33
|
+
onAddToTimeline={onAddToTimeline}
|
|
34
|
+
/>,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
const card = host.querySelector<HTMLElement>('[draggable="true"]');
|
|
38
|
+
if (!card) throw new Error("composition card did not render");
|
|
39
|
+
return { host, card, onSelect, onAddToTimeline };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("composition card drag", () => {
|
|
43
|
+
it("keeps ordinary click navigation", () => {
|
|
44
|
+
const { card, onSelect } = mount();
|
|
45
|
+
act(() => card.click());
|
|
46
|
+
expect(onSelect).toHaveBeenCalledWith("compositions/headline.html");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("emits only source identity and suppresses the click following a drag", () => {
|
|
50
|
+
const { card, onSelect } = mount();
|
|
51
|
+
const data = new Map<string, string>();
|
|
52
|
+
const event = new Event("dragstart", { bubbles: true });
|
|
53
|
+
Object.defineProperty(event, "dataTransfer", {
|
|
54
|
+
value: {
|
|
55
|
+
effectAllowed: "none",
|
|
56
|
+
setData: (type: string, value: string) => data.set(type, value),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
act(() => {
|
|
60
|
+
card.dispatchEvent(event);
|
|
61
|
+
card.click();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(JSON.parse(data.get(TIMELINE_COMPOSITION_MIME) ?? "null")).toEqual({
|
|
65
|
+
sourcePath: "compositions/headline.html",
|
|
66
|
+
});
|
|
67
|
+
expect(card.className).toContain("select-none");
|
|
68
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("offers pointer and keyboard add-at-playhead actions without opening the card", () => {
|
|
72
|
+
const { host, onSelect, onAddToTimeline } = mount();
|
|
73
|
+
const add = host.querySelector<HTMLButtonElement>(
|
|
74
|
+
'[aria-label="Add headline to timeline at playhead"]',
|
|
75
|
+
);
|
|
76
|
+
if (!add) throw new Error("add action did not render");
|
|
77
|
+
act(() => {
|
|
78
|
+
add.click();
|
|
79
|
+
add.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
80
|
+
add.click();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(onAddToTimeline).toHaveBeenCalledTimes(2);
|
|
84
|
+
expect(onAddToTimeline).toHaveBeenLastCalledWith("compositions/headline.html");
|
|
85
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
|
|
3
|
+
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
|
3
4
|
|
|
4
5
|
interface CompositionsTabProps {
|
|
5
6
|
projectId: string;
|
|
@@ -7,6 +8,7 @@ interface CompositionsTabProps {
|
|
|
7
8
|
activeComposition: string | null;
|
|
8
9
|
onSelect: (comp: string) => void;
|
|
9
10
|
onRenderComposition?: (comp: string) => void;
|
|
11
|
+
onAddToTimeline?: (comp: string) => void;
|
|
10
12
|
isRendering?: boolean;
|
|
11
13
|
lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
|
|
12
14
|
}
|
|
@@ -115,6 +117,7 @@ function CompCard({
|
|
|
115
117
|
onRender,
|
|
116
118
|
isRendering,
|
|
117
119
|
lintInfo,
|
|
120
|
+
onAddToTimeline,
|
|
118
121
|
}: {
|
|
119
122
|
projectId: string;
|
|
120
123
|
comp: string;
|
|
@@ -123,12 +126,14 @@ function CompCard({
|
|
|
123
126
|
onRender?: () => void;
|
|
124
127
|
isRendering?: boolean;
|
|
125
128
|
lintInfo?: { count: number; messages: string[] };
|
|
129
|
+
onAddToTimeline?: () => void;
|
|
126
130
|
}) {
|
|
127
131
|
const [hovered, setHovered] = useState(false);
|
|
128
132
|
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
|
129
133
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
130
134
|
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
131
135
|
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
136
|
+
const draggedRef = useRef(false);
|
|
132
137
|
|
|
133
138
|
const requestIframePlaybackSync = useCallback((shouldPlay: boolean) => {
|
|
134
139
|
if (syncTimer.current) {
|
|
@@ -179,10 +184,32 @@ function CompCard({
|
|
|
179
184
|
|
|
180
185
|
return (
|
|
181
186
|
<div
|
|
182
|
-
|
|
187
|
+
role="button"
|
|
188
|
+
tabIndex={0}
|
|
189
|
+
draggable
|
|
190
|
+
onDragStart={(event) => {
|
|
191
|
+
draggedRef.current = true;
|
|
192
|
+
event.dataTransfer.effectAllowed = "copy";
|
|
193
|
+
event.dataTransfer.setData(TIMELINE_COMPOSITION_MIME, JSON.stringify({ sourcePath: comp }));
|
|
194
|
+
}}
|
|
195
|
+
onDragEnd={() => {
|
|
196
|
+
window.setTimeout(() => {
|
|
197
|
+
draggedRef.current = false;
|
|
198
|
+
}, 0);
|
|
199
|
+
}}
|
|
200
|
+
onClick={() => {
|
|
201
|
+
if (!draggedRef.current) onSelect();
|
|
202
|
+
}}
|
|
203
|
+
onKeyDown={(event) => {
|
|
204
|
+
if (event.target !== event.currentTarget) return;
|
|
205
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
onSelect();
|
|
208
|
+
}
|
|
209
|
+
}}
|
|
183
210
|
onPointerEnter={handleEnter}
|
|
184
211
|
onPointerLeave={handleLeave}
|
|
185
|
-
className={`group/card w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-
|
|
212
|
+
className={`group/card w-full select-none text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-grab active:cursor-grabbing ${
|
|
186
213
|
isActive
|
|
187
214
|
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
|
188
215
|
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
|
@@ -232,6 +259,20 @@ function CompCard({
|
|
|
232
259
|
</div>
|
|
233
260
|
<span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
|
|
234
261
|
</div>
|
|
262
|
+
{onAddToTimeline && (
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
title={`Add ${name} to timeline at playhead`}
|
|
266
|
+
aria-label={`Add ${name} to timeline at playhead`}
|
|
267
|
+
onClick={(event) => {
|
|
268
|
+
event.stopPropagation();
|
|
269
|
+
onAddToTimeline();
|
|
270
|
+
}}
|
|
271
|
+
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded text-neutral-600 opacity-0 transition-[color,background-color,opacity] hover:bg-neutral-800 hover:text-studio-accent group-hover/card:opacity-100 group-focus-within/card:opacity-100 focus:opacity-100"
|
|
272
|
+
>
|
|
273
|
+
<span aria-hidden="true">+</span>
|
|
274
|
+
</button>
|
|
275
|
+
)}
|
|
235
276
|
{onRender && (
|
|
236
277
|
<button
|
|
237
278
|
type="button"
|
|
@@ -274,6 +315,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
|
|
274
315
|
activeComposition,
|
|
275
316
|
onSelect,
|
|
276
317
|
onRenderComposition,
|
|
318
|
+
onAddToTimeline,
|
|
277
319
|
isRendering,
|
|
278
320
|
lintFindingsByFile,
|
|
279
321
|
}: CompositionsTabProps) {
|
|
@@ -295,6 +337,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
|
|
295
337
|
isActive={activeComposition === comp}
|
|
296
338
|
onSelect={() => onSelect(comp)}
|
|
297
339
|
onRender={onRenderComposition ? () => onRenderComposition(comp) : undefined}
|
|
340
|
+
onAddToTimeline={onAddToTimeline ? () => onAddToTimeline(comp) : undefined}
|
|
298
341
|
isRendering={isRendering}
|
|
299
342
|
lintInfo={lintFindingsByFile?.get(comp)}
|
|
300
343
|
/>
|
|
@@ -61,6 +61,7 @@ interface LeftSidebarProps {
|
|
|
61
61
|
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
|
|
62
62
|
takeoverContent?: ReactNode;
|
|
63
63
|
onAddAssetToTimeline?: (path: string) => void;
|
|
64
|
+
onAddCompositionToTimeline?: (path: string) => void;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
export const LeftSidebar = memo(
|
|
@@ -94,6 +95,7 @@ export const LeftSidebar = memo(
|
|
|
94
95
|
onPreviewBlock,
|
|
95
96
|
takeoverContent,
|
|
96
97
|
onAddAssetToTimeline,
|
|
98
|
+
onAddCompositionToTimeline,
|
|
97
99
|
},
|
|
98
100
|
ref,
|
|
99
101
|
) {
|
|
@@ -220,6 +222,7 @@ export const LeftSidebar = memo(
|
|
|
220
222
|
compositions={compositions}
|
|
221
223
|
activeComposition={activeComposition}
|
|
222
224
|
onSelect={onSelectComposition}
|
|
225
|
+
onAddToTimeline={onAddCompositionToTimeline}
|
|
223
226
|
onRenderComposition={onRenderComposition}
|
|
224
227
|
isRendering={isRendering}
|
|
225
228
|
lintFindingsByFile={lintFindingsByFile}
|
|
@@ -69,6 +69,10 @@ export function useStudioPlaybackContext(): StudioPlaybackValue {
|
|
|
69
69
|
return ctx;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
export function useStudioPlaybackContextOptional(): StudioPlaybackValue | null {
|
|
73
|
+
return useContext(StudioPlaybackContext);
|
|
74
|
+
}
|
|
75
|
+
|
|
72
76
|
/** @deprecated Use useStudioShellContext and/or useStudioPlaybackContext instead. */
|
|
73
77
|
// fallow-ignore-next-line unused-export
|
|
74
78
|
export function useStudioContext(): StudioContextValue {
|