@hyperframes/studio 0.7.60 → 0.7.62

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 (110) hide show
  1. package/dist/assets/hyperframes-player-CWpmBjzv.js +459 -0
  2. package/dist/assets/{index-D6etaey-.js → index-BJzSiIBj.js} +1 -1
  3. package/dist/assets/index-Ceyz8Qt2.css +1 -0
  4. package/dist/assets/{index-Dh_WhagG.js → index-DJQPB78e.js} +1 -1
  5. package/dist/assets/index-vDRVQtTK.js +426 -0
  6. package/dist/index.d.ts +12 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4392 -3314
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +15 -15
  12. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  13. package/src/components/EditorShell.tsx +12 -0
  14. package/src/components/StudioHeader.tsx +2 -3
  15. package/src/components/StudioLeftSidebar.tsx +3 -0
  16. package/src/components/StudioRightPanel.tsx +34 -26
  17. package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
  18. package/src/components/editor/PromotableControl.tsx +4 -2
  19. package/src/components/editor/PropertyPanel.tsx +151 -149
  20. package/src/components/editor/PropertyPanelFlat.tsx +50 -42
  21. package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
  22. package/src/components/editor/domEditing.test.ts +34 -0
  23. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  24. package/src/components/editor/manualEditingAvailability.ts +5 -3
  25. package/src/components/editor/propertyPanelColor.test.tsx +31 -0
  26. package/src/components/editor/propertyPanelColor.tsx +150 -28
  27. package/src/components/editor/propertyPanelCommitField.tsx +178 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +44 -0
  29. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -0
  30. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  31. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +26 -10
  32. package/src/components/editor/propertyPanelFlatStyleSections.tsx +21 -32
  33. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
  34. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -2
  35. package/src/components/editor/propertyPanelInputCoverage.test.tsx +18 -1
  36. package/src/components/editor/propertyPanelPrimitives.tsx +3 -92
  37. package/src/components/editor/propertyPanelSections.tsx +2 -2
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
  40. package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
  41. package/src/components/nle/TimelinePane.tsx +6 -0
  42. package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
  43. package/src/components/sidebar/CompositionsTab.tsx +45 -2
  44. package/src/components/sidebar/LeftSidebar.tsx +3 -0
  45. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  46. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  47. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  48. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  49. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  50. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  51. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  52. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  53. package/src/components/storyboard/useFrameComments.ts +22 -6
  54. package/src/contexts/StudioContext.tsx +4 -0
  55. package/src/contexts/ViewModeContext.tsx +60 -7
  56. package/src/hooks/timelineEditingHelpers.ts +10 -4
  57. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  58. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  59. package/src/hooks/usePreviewPersistence.ts +7 -1
  60. package/src/hooks/useRazorSplit.history.test.tsx +21 -28
  61. package/src/hooks/useRazorSplit.test.tsx +20 -9
  62. package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
  63. package/src/hooks/useRazorSplit.ts +78 -350
  64. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  65. package/src/hooks/useSlideshowTabState.ts +61 -0
  66. package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
  67. package/src/hooks/useTimelineAssetDropOps.ts +52 -2
  68. package/src/hooks/useTimelineEditing.ts +19 -17
  69. package/src/hooks/useTimelineGroupEditing.ts +6 -7
  70. package/src/player/components/Timeline.tsx +12 -1
  71. package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
  72. package/src/player/components/timelineAuthoredTrack.ts +29 -0
  73. package/src/player/components/timelineCallbacks.ts +4 -0
  74. package/src/player/components/timelineClipDragCommit.test.ts +176 -0
  75. package/src/player/components/timelineClipDragCommit.ts +77 -105
  76. package/src/player/components/timelineClipDragPreview.test.ts +33 -1
  77. package/src/player/components/timelineClipDragPreview.ts +2 -1
  78. package/src/player/components/timelineCollision.test.ts +18 -0
  79. package/src/player/components/timelineCollision.ts +12 -9
  80. package/src/player/components/timelineDragDrop.ts +28 -7
  81. package/src/player/components/timelineEditCapabilities.ts +4 -1
  82. package/src/player/components/timelineGroupEditing.test.ts +10 -0
  83. package/src/player/components/timelineGroupEditing.ts +4 -3
  84. package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
  85. package/src/player/components/timelineLayout.ts +3 -1
  86. package/src/player/components/timelineZMirror.ts +1 -1
  87. package/src/player/components/useTimelineClipDrag.ts +5 -0
  88. package/src/player/components/useTimelineEditPinning.ts +12 -0
  89. package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
  90. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  91. package/src/player/lib/playbackTypes.ts +9 -0
  92. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  93. package/src/player/lib/timelineDOM.test.ts +58 -0
  94. package/src/player/lib/timelineDOM.ts +26 -12
  95. package/src/player/lib/timelineElementHelpers.ts +21 -11
  96. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  97. package/src/player/store/playerStore.ts +2 -1
  98. package/src/utils/razorSplitTransaction.test.ts +175 -0
  99. package/src/utils/razorSplitTransaction.ts +200 -0
  100. package/src/utils/studioPreviewHelpers.test.ts +27 -0
  101. package/src/utils/timelineCompositionDrop.test.ts +12 -0
  102. package/src/utils/timelineCompositionDrop.ts +16 -0
  103. package/src/utils/timelineCompositionInsert.test.ts +142 -0
  104. package/src/utils/timelineCompositionInsert.ts +90 -0
  105. package/src/utils/timelineElementSplit.test.ts +14 -2
  106. package/src/utils/timelineElementSplit.ts +5 -1
  107. package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
  108. package/dist/assets/index-DXbu6IPT.css +0 -1
  109. package/dist/assets/index-cH6NfVV_.js +0 -426
  110. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -0,0 +1,175 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type { TimelineElement } from "../player";
3
+ import { buildAtomicCutIntents, runAtomicCutTransaction } from "./razorSplitTransaction";
4
+
5
+ const element = (over: Partial<TimelineElement> = {}): TimelineElement => ({
6
+ id: "clip",
7
+ domId: "clip",
8
+ tag: "div",
9
+ start: 0,
10
+ duration: 4,
11
+ track: 0,
12
+ timingSource: "authored",
13
+ sourceFile: "index.html",
14
+ ...over,
15
+ });
16
+
17
+ afterEach(() => vi.unstubAllGlobals());
18
+
19
+ describe("buildAtomicCutIntents", () => {
20
+ it("deduplicates runtime aliases but keeps repeated authored hosts distinct", () => {
21
+ const intents = buildAtomicCutIntents(
22
+ [
23
+ element({ id: "runtime-a", domId: "host-a", hfId: "stable-a" }),
24
+ element({ id: "alias-a", domId: "host-a", hfId: "stable-a" }),
25
+ element({ id: "runtime-b", domId: "host-b", hfId: "stable-b" }),
26
+ ],
27
+ 2,
28
+ "index.html",
29
+ );
30
+
31
+ expect(intents).toHaveLength(1);
32
+ expect(intents[0].targets).toHaveLength(2);
33
+ expect(intents[0].targets.map((target) => target.originalId)).toEqual(["host-a", "host-b"]);
34
+ });
35
+
36
+ it("rebases each nested target into its own source-file coordinates", () => {
37
+ const intents = buildAtomicCutIntents(
38
+ [element({ start: 8, duration: 4, expandedParentStart: 6, sourceFile: "scene.html" })],
39
+ 10,
40
+ "index.html",
41
+ );
42
+
43
+ expect(intents[0].targets[0]).toMatchObject({ splitTime: 4, elementStart: 2 });
44
+ });
45
+ });
46
+
47
+ function installCutServer(options: { status?: number } = {}) {
48
+ const requests: Array<{ url: string; body?: unknown }> = [];
49
+ vi.stubGlobal(
50
+ "fetch",
51
+ vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
52
+ const url = String(input);
53
+ requests.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined });
54
+ if (url.includes("/files/")) {
55
+ return new Response(JSON.stringify({ content: "before", version: '"v0"' }), {
56
+ status: 200,
57
+ headers: { "Content-Type": "application/json" },
58
+ });
59
+ }
60
+ if (options.status) {
61
+ return new Response(JSON.stringify({ error: "stale base" }), {
62
+ status: options.status,
63
+ headers: { "Content-Type": "application/json" },
64
+ });
65
+ }
66
+ return new Response(
67
+ JSON.stringify({
68
+ ok: true,
69
+ outcome: "committed",
70
+ files: [
71
+ {
72
+ path: "index.html",
73
+ before: "before",
74
+ after: "after",
75
+ version: '"v1"',
76
+ writeToken: "cut-1",
77
+ splitCount: 1,
78
+ skippedSelectors: [],
79
+ },
80
+ ],
81
+ }),
82
+ { status: 200, headers: { "Content-Type": "application/json" } },
83
+ );
84
+ }),
85
+ );
86
+ return requests;
87
+ }
88
+
89
+ describe("runAtomicCutTransaction", () => {
90
+ it("records canonical snapshots once and performs no client forward write", async () => {
91
+ const requests = installCutServer();
92
+ const writeProjectFile = vi.fn();
93
+ const recordEdit = vi.fn().mockResolvedValue(undefined);
94
+ const observe = vi.fn();
95
+ const synchronize = vi.fn();
96
+
97
+ const result = await runAtomicCutTransaction({
98
+ projectId: "launch/demo",
99
+ intents: buildAtomicCutIntents([element()], 2, "index.html"),
100
+ label: "Split timeline clip",
101
+ writeProjectFile,
102
+ recordEdit,
103
+ observeProjectFileVersion: observe,
104
+ synchronize,
105
+ });
106
+
107
+ expect(requests.filter((request) => request.url.includes("split-batch"))).toHaveLength(1);
108
+ expect(requests.map((request) => request.url)).toEqual([
109
+ "/api/projects/launch%2Fdemo/files/index.html",
110
+ "/api/projects/launch%2Fdemo/file-mutations/split-batch",
111
+ ]);
112
+ expect(writeProjectFile).not.toHaveBeenCalled();
113
+ expect(recordEdit).toHaveBeenCalledWith({
114
+ label: "Split timeline clip",
115
+ kind: "timeline",
116
+ files: { "index.html": { before: "before", after: "after" } },
117
+ });
118
+ expect(observe).toHaveBeenCalledWith("index.html", '"v1"');
119
+ expect(synchronize).toHaveBeenCalledTimes(1);
120
+ expect(result).toMatchObject({ splitCount: 1, syncFailed: false });
121
+ });
122
+
123
+ it("CAS-restores durable bytes when history registration fails", async () => {
124
+ installCutServer();
125
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
126
+
127
+ await expect(
128
+ runAtomicCutTransaction({
129
+ projectId: "p1",
130
+ intents: buildAtomicCutIntents([element()], 2, "index.html"),
131
+ label: "Split timeline clip",
132
+ writeProjectFile,
133
+ recordEdit: vi.fn().mockRejectedValue(new Error("history unavailable")),
134
+ synchronize: vi.fn(),
135
+ }),
136
+ ).rejects.toThrow("history unavailable");
137
+ expect(writeProjectFile).toHaveBeenCalledTimes(1);
138
+ expect(writeProjectFile).toHaveBeenCalledWith("index.html", "before", "after");
139
+ });
140
+
141
+ it("reports an initial version conflict with no history or client write", async () => {
142
+ installCutServer({ status: 409 });
143
+ const writeProjectFile = vi.fn();
144
+ const recordEdit = vi.fn();
145
+
146
+ await expect(
147
+ runAtomicCutTransaction({
148
+ projectId: "p1",
149
+ intents: buildAtomicCutIntents([element()], 2, "index.html"),
150
+ label: "Split timeline clip",
151
+ writeProjectFile,
152
+ recordEdit,
153
+ synchronize: vi.fn(),
154
+ }),
155
+ ).rejects.toThrow("Cut conflict");
156
+ expect(writeProjectFile).not.toHaveBeenCalled();
157
+ expect(recordEdit).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it("keeps a durable recorded cut when synchronization fails", async () => {
161
+ installCutServer();
162
+ const result = await runAtomicCutTransaction({
163
+ projectId: "p1",
164
+ intents: buildAtomicCutIntents([element()], 2, "index.html"),
165
+ label: "Split timeline clip",
166
+ writeProjectFile: vi.fn(),
167
+ recordEdit: vi.fn().mockResolvedValue(undefined),
168
+ synchronize: () => {
169
+ throw new Error("preview unavailable");
170
+ },
171
+ });
172
+
173
+ expect(result.syncFailed).toBe(true);
174
+ });
175
+ });
@@ -0,0 +1,200 @@
1
+ import type { TimelineElement } from "../player";
2
+ import type { RecordEditInput } from "../hooks/timelineEditingHelpers";
3
+ import { buildPatchTarget } from "./timelineElementSplit";
4
+ import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
5
+ import { buildProjectApiPath } from "./projectRouting";
6
+
7
+ type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
8
+
9
+ interface CutTarget {
10
+ target: NonNullable<ReturnType<typeof buildPatchTarget>>;
11
+ originalId?: string;
12
+ splitTime: number;
13
+ elementStart: number;
14
+ elementDuration: number;
15
+ playbackStart?: number;
16
+ playbackRate?: number;
17
+ isComposition?: boolean;
18
+ }
19
+
20
+ interface CutFileIntent {
21
+ path: string;
22
+ targets: CutTarget[];
23
+ }
24
+
25
+ interface CutFileResult {
26
+ path: string;
27
+ before: string;
28
+ after: string;
29
+ version: string;
30
+ writeToken: string;
31
+ splitCount: number;
32
+ skippedSelectors: string[];
33
+ }
34
+
35
+ interface CutBatchResponse {
36
+ ok: true;
37
+ outcome: "committed";
38
+ files: CutFileResult[];
39
+ }
40
+
41
+ export interface AtomicCutResult {
42
+ splitCount: number;
43
+ skippedSelectors: string[];
44
+ syncFailed: boolean;
45
+ }
46
+
47
+ function targetIdentity(
48
+ path: string,
49
+ target: NonNullable<ReturnType<typeof buildPatchTarget>>,
50
+ ): string {
51
+ if (target.hfId) return `${path}|hf:${target.hfId}`;
52
+ if (target.id) return `${path}|id:${target.id}`;
53
+ return `${path}|selector:${target.selector ?? ""}:${target.selectorIndex ?? 0}`;
54
+ }
55
+
56
+ function buildCutTarget(
57
+ element: TimelineElement,
58
+ target: CutTarget["target"],
59
+ splitTime: number,
60
+ ): CutTarget {
61
+ const basis = element.expandedParentStart;
62
+ return {
63
+ target,
64
+ ...(element.domId ? { originalId: element.domId } : {}),
65
+ splitTime: basis === undefined ? splitTime : Math.max(0, splitTime - basis),
66
+ elementStart: basis === undefined ? element.start : element.start - basis,
67
+ elementDuration: element.duration,
68
+ ...(element.playbackStart != null ? { playbackStart: element.playbackStart } : {}),
69
+ ...(element.playbackRate != null ? { playbackRate: element.playbackRate } : {}),
70
+ ...(element.kind === "composition" ? { isComposition: true } : {}),
71
+ };
72
+ }
73
+
74
+ /** Group one immutable cut time by file and collapse runtime aliases once. */
75
+ export function buildAtomicCutIntents(
76
+ elements: readonly TimelineElement[],
77
+ splitTime: number,
78
+ activeCompPath: string | null,
79
+ ): CutFileIntent[] {
80
+ const byPath = new Map<string, CutFileIntent>();
81
+ const seen = new Set<string>();
82
+ for (const element of elements) {
83
+ const target = buildPatchTarget(element);
84
+ if (!target) throw new Error("Clip is missing a patchable target.");
85
+ const path = element.sourceFile || activeCompPath || "index.html";
86
+ const identity = targetIdentity(path, target);
87
+ if (seen.has(identity)) continue;
88
+ seen.add(identity);
89
+
90
+ const intent = byPath.get(path) ?? { path, targets: [] };
91
+ intent.targets.push(buildCutTarget(element, target, splitTime));
92
+ byPath.set(path, intent);
93
+ }
94
+ return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
95
+ }
96
+
97
+ async function readFileVersion(projectId: string, path: string): Promise<string> {
98
+ const response = await fetch(
99
+ buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
100
+ );
101
+ if (!response.ok) throw new Error(`Failed to read ${path} before cut (${response.status})`);
102
+ const body = (await response.json()) as { version?: string };
103
+ const version = body.version ?? response.headers.get("etag") ?? undefined;
104
+ if (!version) throw new Error(`Missing content version for ${path}`);
105
+ return version;
106
+ }
107
+
108
+ async function requestAtomicCut(
109
+ projectId: string,
110
+ intents: CutFileIntent[],
111
+ ): Promise<CutBatchResponse> {
112
+ const files = [];
113
+ for (const intent of intents) {
114
+ files.push({
115
+ ...intent,
116
+ expectedVersion: await readFileVersion(projectId, intent.path),
117
+ });
118
+ }
119
+ const transactionToken = `cut:${crypto.randomUUID()}`;
120
+ const response = await fetch(buildProjectApiPath(projectId, "/file-mutations/split-batch"), {
121
+ method: "POST",
122
+ headers: {
123
+ "Content-Type": "application/json",
124
+ "X-Hyperframes-Write-Token": transactionToken,
125
+ },
126
+ body: JSON.stringify({ files, transactionToken }),
127
+ });
128
+ const body = (await response.json().catch(() => null)) as
129
+ | (Partial<CutBatchResponse> & { error?: string; outcome?: string })
130
+ | null;
131
+ if (!response.ok || body?.ok !== true || !Array.isArray(body.files)) {
132
+ const prefix = response.status === 409 ? "Cut conflict" : "Cut failed";
133
+ throw new Error(`${prefix}: ${body?.error ?? `server returned ${response.status}`}`);
134
+ }
135
+ return body as CutBatchResponse;
136
+ }
137
+
138
+ async function rollbackUnrecordedCut(
139
+ files: readonly CutFileResult[],
140
+ writeProjectFile: ProjectFileWriter,
141
+ ): Promise<void> {
142
+ const failures: unknown[] = [];
143
+ for (const file of [...files].reverse()) {
144
+ try {
145
+ await writeProjectFile(file.path, file.before, file.after);
146
+ } catch (error) {
147
+ failures.push(error);
148
+ }
149
+ }
150
+ if (failures.length > 0) {
151
+ throw new AggregateError(
152
+ failures,
153
+ "Cut history failed and externally changed files could not be safely restored",
154
+ );
155
+ }
156
+ }
157
+
158
+ interface RunAtomicCutInput {
159
+ projectId: string;
160
+ intents: CutFileIntent[];
161
+ label: string;
162
+ writeProjectFile: ProjectFileWriter;
163
+ recordEdit: (input: RecordEditInput) => Promise<void>;
164
+ observeProjectFileVersion?: (path: string, version: string | null) => void;
165
+ synchronize: () => void;
166
+ }
167
+
168
+ /** One coordinator owns request, history registration, safe rollback, and resync. */
169
+ export function runAtomicCutTransaction(input: RunAtomicCutInput): Promise<AtomicCutResult> {
170
+ const paths = input.intents.map((intent) => intent.path);
171
+ return serializeStudioFileMutations(input.writeProjectFile, paths, async () => {
172
+ const result = await requestAtomicCut(input.projectId, input.intents);
173
+ const snapshots = Object.fromEntries(
174
+ result.files.map((file) => [file.path, { before: file.before, after: file.after }]),
175
+ );
176
+ try {
177
+ await input.recordEdit({ label: input.label, kind: "timeline", files: snapshots });
178
+ } catch (error) {
179
+ try {
180
+ await rollbackUnrecordedCut(result.files, input.writeProjectFile);
181
+ } catch (rollbackError) {
182
+ throw new AggregateError([error, rollbackError], "Cut aborted with rollback conflicts");
183
+ }
184
+ throw error;
185
+ }
186
+
187
+ for (const file of result.files) input.observeProjectFileVersion?.(file.path, file.version);
188
+ let syncFailed = false;
189
+ try {
190
+ input.synchronize();
191
+ } catch {
192
+ syncFailed = true;
193
+ }
194
+ return {
195
+ splitCount: result.files.reduce((count, file) => count + file.splitCount, 0),
196
+ skippedSelectors: [...new Set(result.files.flatMap((file) => file.skippedSelectors))],
197
+ syncFailed,
198
+ };
199
+ });
200
+ }
@@ -114,6 +114,33 @@ describe("pauseStudioPreviewPlayback", () => {
114
114
  });
115
115
 
116
116
  describe("getPreviewTargetFromPointer", () => {
117
+ it("chooses the deepest headline through a transparent overflow mask", () => {
118
+ const { iframe, doc } = createPreviewIframe();
119
+ doc.body.innerHTML = `
120
+ <template id="source-template"></template>
121
+ <main data-composition-id="scene">
122
+ <section class="hl-block">
123
+ <div class="hl-mask" style="overflow: hidden; background: transparent">
124
+ <h1 class="hl-text">Launch title</h1>
125
+ </div>
126
+ </section>
127
+ </main>
128
+ `;
129
+ const scene = doc.querySelector<HTMLElement>("main")!;
130
+ const block = doc.querySelector<HTMLElement>(".hl-block")!;
131
+ const mask = doc.querySelector<HTMLElement>(".hl-mask")!;
132
+ const headline = doc.querySelector<HTMLElement>(".hl-text")!;
133
+ stubRect(iframe, domRect(0, 0, 400, 300));
134
+ stubRect(scene, domRect(0, 0, 400, 300));
135
+ stubRect(block, domRect(30, 30, 300, 100));
136
+ stubRect(mask, domRect(40, 40, 260, 64));
137
+ stubRect(headline, domRect(44, 44, 220, 48));
138
+ doc.elementsFromPoint = () => [headline, mask, block, scene];
139
+
140
+ expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
141
+ iframe.remove();
142
+ });
143
+
117
144
  it("skips candidates hidden from author hit-testing by inherited pointer-events:none", () => {
118
145
  const { iframe, doc } = createPreviewIframe();
119
146
 
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseTimelineCompositionPayload } from "./timelineCompositionDrop";
3
+
4
+ describe("timeline composition drop", () => {
5
+ it("parses valid composition payloads and rejects malformed ones", () => {
6
+ expect(parseTimelineCompositionPayload('{"sourcePath":"scene.html"}')).toEqual({
7
+ sourcePath: "scene.html",
8
+ });
9
+ expect(parseTimelineCompositionPayload('{"path":"scene.html"}')).toBeNull();
10
+ expect(parseTimelineCompositionPayload("nope")).toBeNull();
11
+ });
12
+ });
@@ -0,0 +1,16 @@
1
+ export const TIMELINE_COMPOSITION_MIME = "application/x-hyperframes-composition";
2
+
3
+ export interface TimelineCompositionPayload {
4
+ sourcePath: string;
5
+ }
6
+
7
+ export function parseTimelineCompositionPayload(raw: string): TimelineCompositionPayload | null {
8
+ try {
9
+ const value: unknown = JSON.parse(raw);
10
+ if (typeof value !== "object" || value === null || !("sourcePath" in value)) return null;
11
+ const sourcePath = value.sourcePath;
12
+ return typeof sourcePath === "string" && sourcePath.trim() ? { sourcePath } : null;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
@@ -0,0 +1,142 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { commitTimelineCompositionInsertion } from "./timelineCompositionInsert";
3
+
4
+ afterEach(() => vi.unstubAllGlobals());
5
+
6
+ function response(body: unknown, status = 200): Response {
7
+ return new Response(JSON.stringify(body), {
8
+ status,
9
+ headers: { "Content-Type": "application/json" },
10
+ });
11
+ }
12
+
13
+ describe("commitTimelineCompositionInsertion", () => {
14
+ it("records one history entry, then selects and refreshes once", async () => {
15
+ const fetchMock = vi
16
+ .fn()
17
+ .mockResolvedValueOnce(response({ content: "before", version: "v1" }))
18
+ .mockResolvedValueOnce(
19
+ response({
20
+ path: "index.html",
21
+ hostId: "headline",
22
+ before: "before",
23
+ after: "after",
24
+ version: "v2",
25
+ }),
26
+ );
27
+ vi.stubGlobal("fetch", fetchMock);
28
+ const writeFile = vi.fn();
29
+ const recordEdit = vi.fn();
30
+ const observeVersion = vi.fn();
31
+ const selectHost = vi.fn();
32
+ const resync = vi.fn();
33
+ const refresh = vi.fn();
34
+
35
+ await commitTimelineCompositionInsertion({
36
+ projectId: "launch/demo",
37
+ targetPath: "index.html",
38
+ sourcePath: "headline.html",
39
+ start: 4,
40
+ track: 2,
41
+ writeFile,
42
+ recordEdit,
43
+ observeVersion,
44
+ selectHost,
45
+ resync,
46
+ refresh,
47
+ });
48
+
49
+ expect(fetchMock).toHaveBeenCalledTimes(2);
50
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
51
+ "/api/projects/launch%2Fdemo/files/index.html",
52
+ "/api/projects/launch%2Fdemo/file-mutations/insert-composition/index.html",
53
+ ]);
54
+ expect(recordEdit).toHaveBeenCalledOnce();
55
+ expect(writeFile).not.toHaveBeenCalled();
56
+ expect(observeVersion).toHaveBeenCalledWith("index.html", "v2");
57
+ expect(selectHost).toHaveBeenCalledWith("index.html#headline");
58
+ expect(resync).toHaveBeenCalledOnce();
59
+ expect(refresh).toHaveBeenCalledOnce();
60
+ });
61
+
62
+ it("CAS-restores the server write when history registration fails", async () => {
63
+ vi.stubGlobal(
64
+ "fetch",
65
+ vi
66
+ .fn()
67
+ .mockResolvedValueOnce(response({ content: "before", version: "v1" }))
68
+ .mockResolvedValueOnce(
69
+ response({
70
+ path: "index.html",
71
+ hostId: "headline",
72
+ before: "before",
73
+ after: "after",
74
+ version: "v2",
75
+ }),
76
+ ),
77
+ );
78
+ const writeFile = vi.fn();
79
+ const refresh = vi.fn();
80
+
81
+ await expect(
82
+ commitTimelineCompositionInsertion({
83
+ projectId: "demo",
84
+ targetPath: "index.html",
85
+ sourcePath: "headline.html",
86
+ start: 4,
87
+ track: 2,
88
+ writeFile,
89
+ recordEdit: vi.fn().mockRejectedValue(new Error("history failed")),
90
+ selectHost: vi.fn(),
91
+ refresh,
92
+ }),
93
+ ).rejects.toThrow("history failed");
94
+
95
+ expect(writeFile).toHaveBeenCalledWith("index.html", "before", "after");
96
+ expect(refresh).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it("keeps a durable insertion successful and refreshes when resync fails", async () => {
100
+ vi.stubGlobal(
101
+ "fetch",
102
+ vi
103
+ .fn()
104
+ .mockResolvedValueOnce(response({ content: "before", version: "v1" }))
105
+ .mockResolvedValueOnce(
106
+ response({
107
+ path: "index.html",
108
+ hostId: "headline",
109
+ before: "before",
110
+ after: "after",
111
+ version: "v2",
112
+ }),
113
+ ),
114
+ );
115
+ const refresh = vi.fn();
116
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
117
+
118
+ await expect(
119
+ commitTimelineCompositionInsertion({
120
+ projectId: "demo",
121
+ targetPath: "index.html",
122
+ sourcePath: "headline.html",
123
+ start: 4,
124
+ track: 2,
125
+ writeFile: vi.fn(),
126
+ recordEdit: vi.fn(),
127
+ selectHost: vi.fn(),
128
+ resync: () => {
129
+ throw new Error("resync failed");
130
+ },
131
+ refresh,
132
+ }),
133
+ ).resolves.toBeUndefined();
134
+
135
+ expect(refresh).toHaveBeenCalledOnce();
136
+ expect(consoleError).toHaveBeenCalledWith(
137
+ "[Studio] Composition insertion committed but preview resync failed",
138
+ expect.any(Error),
139
+ );
140
+ consoleError.mockRestore();
141
+ });
142
+ });
@@ -0,0 +1,90 @@
1
+ import { createStudioSaveHttpError } from "./studioSaveDiagnostics";
2
+ import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
3
+ import type { RecordEditInput } from "./studioFileHistory";
4
+ import { buildProjectApiPath } from "./projectRouting";
5
+
6
+ interface TimelineCompositionInsertionResult {
7
+ path: string;
8
+ hostId: string;
9
+ before: string;
10
+ after: string;
11
+ version: string;
12
+ }
13
+
14
+ async function insertTimelineComposition(input: {
15
+ projectId: string;
16
+ targetPath: string;
17
+ sourcePath: string;
18
+ start: number;
19
+ track: number;
20
+ }): Promise<TimelineCompositionInsertionResult> {
21
+ const current = await fetch(
22
+ buildProjectApiPath(input.projectId, `/files/${encodeURIComponent(input.targetPath)}`),
23
+ );
24
+ if (!current.ok) {
25
+ throw await createStudioSaveHttpError(current, `Failed to read ${input.targetPath}`);
26
+ }
27
+ const snapshot = (await current.json()) as { version?: string };
28
+ if (typeof snapshot.version !== "string") throw new Error("Missing composition file version");
29
+
30
+ const response = await fetch(
31
+ buildProjectApiPath(
32
+ input.projectId,
33
+ `/file-mutations/insert-composition/${encodeURIComponent(input.targetPath)}`,
34
+ ),
35
+ {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify({
39
+ sourcePath: input.sourcePath,
40
+ start: input.start,
41
+ track: input.track,
42
+ expectedVersion: snapshot.version,
43
+ }),
44
+ },
45
+ );
46
+ if (!response.ok) {
47
+ throw await createStudioSaveHttpError(response, "Failed to add composition to timeline");
48
+ }
49
+ return (await response.json()) as TimelineCompositionInsertionResult;
50
+ }
51
+
52
+ export async function commitTimelineCompositionInsertion(input: {
53
+ projectId: string;
54
+ targetPath: string;
55
+ sourcePath: string;
56
+ start: number;
57
+ track: number;
58
+ writeFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
59
+ recordEdit: (entry: RecordEditInput) => Promise<void>;
60
+ observeVersion?: (path: string, version: string | null) => void;
61
+ selectHost: (key: string) => void;
62
+ resync?: () => void;
63
+ refresh: () => void;
64
+ }): Promise<void> {
65
+ await serializeStudioFileMutation(input.writeFile, input.targetPath, async () => {
66
+ const result = await insertTimelineComposition(input);
67
+ input.observeVersion?.(input.targetPath, result.version);
68
+ try {
69
+ await input.recordEdit({
70
+ label: "Add composition to timeline",
71
+ kind: "timeline",
72
+ files: { [input.targetPath]: { before: result.before, after: result.after } },
73
+ });
74
+ } catch (error) {
75
+ await input.writeFile(input.targetPath, result.before, result.after);
76
+ throw error;
77
+ }
78
+ input.selectHost(`${input.targetPath}#${result.hostId}`);
79
+ try {
80
+ input.resync?.();
81
+ } catch (error) {
82
+ console.error("[Studio] Composition insertion committed but preview resync failed", error);
83
+ }
84
+ try {
85
+ input.refresh();
86
+ } catch (error) {
87
+ console.error("[Studio] Composition insertion committed but refresh failed", error);
88
+ }
89
+ });
90
+ }