@hyperframes/studio 0.8.78 → 0.8.80
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-CgnoxDoD.js +464 -0
- package/dist/assets/{index-CX_4MYbk.js → index-CsG24Jub.js} +190 -187
- package/dist/assets/{index-C_n__Uh3.js → index-DZGVvqDT.js} +1 -1
- package/dist/assets/{index-DdpuHaGN.js → index-J9HXe7mU.js} +1 -1
- package/dist/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
- package/dist/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
- package/dist/index.d.ts +40 -16
- package/dist/index.html +1 -1
- package/dist/index.js +1918 -1408
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -1
- package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +4 -0
- package/src/components/editor/useFxAudition.ts +3 -3
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -2
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/TimelineEditContext.tsx +2 -0
- package/src/hooks/liveLanes.ts +147 -0
- package/src/hooks/timelineAudioGroupVolume.ts +97 -53
- package/src/hooks/timelineEditPermission.ts +55 -13
- package/src/hooks/timelineEditingHelpers.test.ts +16 -0
- package/src/hooks/timelineEditingHelpers.ts +69 -43
- package/src/hooks/timelineElementFxAttribute.ts +92 -40
- package/src/hooks/useAppHotkeys.ts +1 -1
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
- package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
- package/src/hooks/useBlockHandlers.test.tsx +65 -0
- package/src/hooks/useBlockHandlers.ts +7 -4
- package/src/hooks/useClipboard.source.test.ts +340 -0
- package/src/hooks/useClipboard.ts +141 -62
- package/src/hooks/useDomEditAttributeCommits.ts +3 -17
- package/src/hooks/useDomEditSession.ts +1 -0
- package/src/hooks/useEditHistoryActions.ts +4 -3
- package/src/hooks/useElementPicker.test.ts +172 -0
- package/src/hooks/useElementPicker.ts +141 -93
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
- package/src/hooks/useFrameCapture.test.tsx +67 -0
- package/src/hooks/useFrameCapture.ts +18 -9
- package/src/hooks/usePersistentEditHistory.test.ts +2 -2
- package/src/hooks/usePersistentEditHistory.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +59 -0
- package/src/hooks/useTimelineEditing.ts +59 -47
- package/src/hooks/useTimelineGroupEditing.ts +2 -1
- package/src/hooks/useToast.ts +2 -1
- package/src/index.ts +1 -0
- package/src/player/components/ImageThumbnail.tsx +3 -12
- package/src/player/components/Player.test.ts +28 -0
- package/src/player/components/Player.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
- package/src/player/components/TimelineAutomationLane.tsx +18 -13
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
- package/src/player/components/TimelineClipFades.test.tsx +24 -3
- package/src/player/components/TimelineClipFades.tsx +5 -2
- package/src/player/components/TimelineFxButton.tsx +1 -1
- package/src/player/components/TimelineGroupHeader.tsx +1 -1
- package/src/player/components/TimelineGroupRow.test.tsx +0 -1
- package/src/player/components/TimelineGroupRow.tsx +17 -11
- package/src/player/components/TimelineTrackHeader.tsx +21 -6
- package/src/player/components/VideoThumbnail.test.tsx +13 -0
- package/src/player/components/VideoThumbnail.tsx +1 -1
- package/src/player/components/groupAutomationElement.ts +9 -1
- package/src/player/components/timelineCallbacks.ts +5 -2
- package/src/player/components/timelineClipDragPreview.test.ts +41 -0
- package/src/player/components/timelineClipDragPreview.ts +15 -24
- package/src/player/components/timelineEditing.test.ts +99 -1
- package/src/player/components/timelineEditing.ts +5 -8
- package/src/player/components/timelineGroupEditing.test.ts +27 -0
- package/src/player/components/timelineGroupEditing.ts +18 -15
- package/src/player/components/timelineStackingSync.ts +5 -5
- package/src/player/components/timelineZones.ts +2 -3
- package/src/player/components/useAutomationEdgeStretch.ts +4 -4
- package/src/player/components/useAutomationLaneGestures.ts +1 -1
- package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
- package/src/player/components/useAutomationLanes.ts +58 -74
- package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
- package/src/player/hooks/useTimelinePlayer.ts +25 -28
- package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
- package/src/player/lib/automationStoreSync.ts +30 -3
- package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
- package/src/player/lib/thumbnailImageDecoder.ts +73 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
- package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
- package/src/player/lib/timelineDOM.test.ts +28 -8
- package/src/player/lib/timelineElementHelpers.ts +3 -4
- package/src/player/lib/timelineGroupInfo.ts +23 -23
- package/src/utils/authoredSource.ts +92 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +3 -7
- package/src/utils/studioFileHistory.ts +36 -30
- package/dist/assets/hyperframes-player-BdnQGwG5.js +0 -464
- package/src/player/components/automationGestureKeys.test.ts +0 -49
- package/src/player/components/automationGestureKeys.ts +0 -49
- /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
- /package/dist/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
|
@@ -29,15 +29,6 @@ interface DataAttributeCommitOptions {
|
|
|
29
29
|
skipRefresh: boolean;
|
|
30
30
|
refreshAfter?: boolean;
|
|
31
31
|
onSettled?: (ok: boolean) => void;
|
|
32
|
-
/**
|
|
33
|
-
* Undo grouping for a gesture that spans several commits.
|
|
34
|
-
*
|
|
35
|
-
* Without it the key is derived from the prefix, attribute and element, and the
|
|
36
|
-
* window is history's own 300ms — so a drag's moves and the release that ends it
|
|
37
|
-
* landed in different entries, and a drag slower than the window split further.
|
|
38
|
-
* A caller that knows a gesture is in progress passes one key for all of it.
|
|
39
|
-
*/
|
|
40
|
-
coalesce?: { key: string; ms: number };
|
|
41
32
|
/**
|
|
42
33
|
* Apply to the preview and stop there — no file write, no history entry.
|
|
43
34
|
*
|
|
@@ -150,9 +141,7 @@ export function useDomEditAttributeCommits({
|
|
|
150
141
|
if (!domEditSelection) return;
|
|
151
142
|
const iframe = previewIframeRef.current;
|
|
152
143
|
const fullAttr = resolveFullAttrName(attr, true);
|
|
153
|
-
const commitKey =
|
|
154
|
-
options.coalesce?.key ??
|
|
155
|
-
`${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`;
|
|
144
|
+
const commitKey = `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`;
|
|
156
145
|
const isLatestCommit = bumpDomEditCommitMapVersion(
|
|
157
146
|
domAttributeCommitVersionRef.current,
|
|
158
147
|
commitKey,
|
|
@@ -184,7 +173,6 @@ export function useDomEditAttributeCommits({
|
|
|
184
173
|
persistDomEditOperations(domEditSelection, [op], {
|
|
185
174
|
label: options.label,
|
|
186
175
|
coalesceKey: commitKey,
|
|
187
|
-
...(options.coalesce ? { coalesceMs: options.coalesce.ms } : {}),
|
|
188
176
|
skipRefresh: options.skipRefresh,
|
|
189
177
|
}),
|
|
190
178
|
shouldRevert: () => isLatestCommit(),
|
|
@@ -338,14 +326,13 @@ export function useDomEditAttributeCommits({
|
|
|
338
326
|
attr: string,
|
|
339
327
|
value: string | null,
|
|
340
328
|
onSettled?: (ok: boolean) => void,
|
|
341
|
-
live?: {
|
|
329
|
+
live?: { previewOnly?: boolean },
|
|
342
330
|
) => {
|
|
343
331
|
await commitDataAttribute(attr, value, {
|
|
344
332
|
label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
|
|
345
333
|
coalescePrefix: "attr-live",
|
|
346
334
|
skipRefresh: true,
|
|
347
335
|
onSettled,
|
|
348
|
-
...(live?.coalesce ? { coalesce: live.coalesce } : {}),
|
|
349
336
|
...(live?.previewOnly ? { previewOnly: true } : {}),
|
|
350
337
|
});
|
|
351
338
|
},
|
|
@@ -362,13 +349,12 @@ export function useDomEditAttributeCommits({
|
|
|
362
349
|
* edit computes from a pre-edit value and appears to do nothing.
|
|
363
350
|
*/
|
|
364
351
|
const handleDomAttributeQuietCommit = useCallback(
|
|
365
|
-
async (attr: string, value: string | null
|
|
352
|
+
async (attr: string, value: string | null) => {
|
|
366
353
|
await commitDataAttribute(attr, value, {
|
|
367
354
|
label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
|
|
368
355
|
coalescePrefix: "attr-quiet",
|
|
369
356
|
skipRefresh: true,
|
|
370
357
|
refreshAfter: true,
|
|
371
|
-
...(coalesce ? { coalesce } : {}),
|
|
372
358
|
});
|
|
373
359
|
},
|
|
374
360
|
[commitDataAttribute],
|
|
@@ -33,7 +33,7 @@ export interface UseEditHistoryActionsOptions {
|
|
|
33
33
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
34
34
|
syncHistoryPreviewAfterApply: (restore: Pick<HistoryResult, "paths" | "files">) => Promise<void>;
|
|
35
35
|
waitForPendingDomEditSaves: () => Promise<void>;
|
|
36
|
-
onAfterUndoRedo?: () => void;
|
|
36
|
+
onAfterUndoRedo?: (restore: Pick<HistoryResult, "paths" | "files">) => void;
|
|
37
37
|
/** Active composition path — decides whether undo/redo must resync the SDK session. */
|
|
38
38
|
activeCompPath?: string | null;
|
|
39
39
|
/** Reloads the SDK session after a revert of the active comp, past the self-write suppress window. */
|
|
@@ -84,11 +84,12 @@ export function useEditHistoryActions({
|
|
|
84
84
|
return;
|
|
85
85
|
}
|
|
86
86
|
if (result.ok && result.label) {
|
|
87
|
-
|
|
87
|
+
const restore = { paths: result.paths, files: result.files };
|
|
88
|
+
onAfterUndoRedo?.(restore);
|
|
88
89
|
if (activeCompPath && result.paths?.includes(activeCompPath)) {
|
|
89
90
|
forceReloadSdkSession?.();
|
|
90
91
|
}
|
|
91
|
-
await syncHistoryPreviewAfterApply(
|
|
92
|
+
await syncHistoryPreviewAfterApply(restore);
|
|
92
93
|
showToast(result.label, "info");
|
|
93
94
|
}
|
|
94
95
|
},
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React, { act, useState } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
|
5
|
+
import { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol";
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { useElementPicker } from "./useElementPicker";
|
|
8
|
+
|
|
9
|
+
// As the host leaves it on disk: hf-ids pinned, no element ids.
|
|
10
|
+
const SAVED = ensureHfIds(`<!doctype html><html><body>
|
|
11
|
+
<div data-composition-id="main" data-start="0" data-duration="10">
|
|
12
|
+
<h1 class="clip" data-start="2" data-duration="3" data-track-index="0">Title</h1>
|
|
13
|
+
</div></body></html>`);
|
|
14
|
+
|
|
15
|
+
let root: Root | null = null;
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
act(() => root?.unmount());
|
|
18
|
+
root = null;
|
|
19
|
+
document.body.innerHTML = "";
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// The preview page: the saved markup plus what the runtime adds to it.
|
|
23
|
+
function mountPreview(mounted = ""): HTMLIFrameElement {
|
|
24
|
+
const iframe = document.createElement("iframe");
|
|
25
|
+
document.body.appendChild(iframe);
|
|
26
|
+
const doc = iframe.contentDocument as Document;
|
|
27
|
+
doc.open();
|
|
28
|
+
doc.write(SAVED);
|
|
29
|
+
doc.close();
|
|
30
|
+
(doc.querySelector("h1") as HTMLElement).style.cssText = "visibility: hidden; display: none";
|
|
31
|
+
doc.body.append(Object.assign(doc.createElement("script"), { textContent: "/* runtime */" }));
|
|
32
|
+
doc.body.insertAdjacentHTML("beforeend", mounted);
|
|
33
|
+
return iframe;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mountPicker(
|
|
37
|
+
files: Record<string, string>,
|
|
38
|
+
selector = "h1",
|
|
39
|
+
mounted = "",
|
|
40
|
+
hostAppliesWrites = true,
|
|
41
|
+
) {
|
|
42
|
+
const iframe = mountPreview(mounted);
|
|
43
|
+
const synced: Record<string, string>[] = [];
|
|
44
|
+
let api: ReturnType<typeof useElementPicker> | null = null;
|
|
45
|
+
let setHostFiles: (next: Record<string, string>) => void = () => {};
|
|
46
|
+
// Like a Studio host: it holds the files in state and rerenders after each write.
|
|
47
|
+
function Harness() {
|
|
48
|
+
const [workspaceFiles, setFiles] = useState(files);
|
|
49
|
+
setHostFiles = setFiles;
|
|
50
|
+
api = useElementPicker(
|
|
51
|
+
{ current: iframe },
|
|
52
|
+
{
|
|
53
|
+
workspaceFiles,
|
|
54
|
+
onSyncFiles: (changed) => {
|
|
55
|
+
synced.push(changed);
|
|
56
|
+
if (hostAppliesWrites) setFiles((prev) => ({ ...prev, ...changed }));
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
root = createRoot(document.createElement("div"));
|
|
63
|
+
act(() => root?.render(React.createElement(Harness)));
|
|
64
|
+
act(() => {
|
|
65
|
+
window.dispatchEvent(
|
|
66
|
+
new MessageEvent("message", {
|
|
67
|
+
source: iframe.contentWindow,
|
|
68
|
+
data: {
|
|
69
|
+
source: "hf-preview",
|
|
70
|
+
type: "element-picked",
|
|
71
|
+
elementInfo: { selector, tagName: "h1" },
|
|
72
|
+
...runtimeProtocolMetadata(30),
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
const picker = () => api as ReturnType<typeof useElementPicker>;
|
|
78
|
+
return {
|
|
79
|
+
picker,
|
|
80
|
+
synced,
|
|
81
|
+
setHostFiles: (next: Record<string, string>) => act(() => setHostFiles(next)),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("an edit to a picked element without an id", () => {
|
|
86
|
+
it("writes only that edit into the saved file", () => {
|
|
87
|
+
const { picker, synced } = mountPicker({ "index.html": SAVED });
|
|
88
|
+
expect(picker().pickedElement?.selector).toBe("h1");
|
|
89
|
+
act(() => picker().setStyle("color", "red"));
|
|
90
|
+
expect(synced).toHaveLength(1);
|
|
91
|
+
const written = synced[0]?.["index.html"] ?? "";
|
|
92
|
+
expect(written).toMatch(/<h1 [^>]*style="color: red"[^>]*>Title<\/h1>/);
|
|
93
|
+
expect(written).not.toContain("display: none");
|
|
94
|
+
expect(written).not.toContain("/* runtime */");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("persists a text edit", () => {
|
|
98
|
+
const { picker, synced } = mountPicker({ "index.html": SAVED });
|
|
99
|
+
act(() => picker().setTextContent("Hello"));
|
|
100
|
+
expect(synced[0]?.["index.html"]).toMatch(/>Hello<\/h1>/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// The same h1 in index.html and compositions/b.html, so both carry one hf-id.
|
|
104
|
+
const SUB = ensureHfIds(`<template><div data-composition-id="b">
|
|
105
|
+
<h1 class="clip" data-start="2" data-duration="3" data-track-index="0">Title</h1>
|
|
106
|
+
</div></template>`);
|
|
107
|
+
const subH1 = new DOMParser()
|
|
108
|
+
.parseFromString(SUB, "text/html")
|
|
109
|
+
.querySelector("template")
|
|
110
|
+
?.content.querySelector("h1");
|
|
111
|
+
const host = `<div data-composition-file="compositions/b.html">${subH1?.outerHTML}</div>`;
|
|
112
|
+
|
|
113
|
+
it.each([
|
|
114
|
+
["index.html first", { "index.html": SAVED, "compositions/b.html": SUB }],
|
|
115
|
+
["the sub-composition first", { "compositions/b.html": SUB, "index.html": SAVED }],
|
|
116
|
+
])("writes a twin element into its own file (%s)", (_order, files) => {
|
|
117
|
+
expect(SAVED).toContain(`data-hf-id="${subH1?.getAttribute("data-hf-id")}"`);
|
|
118
|
+
const inSub = mountPicker(files, "[data-composition-file] h1", host);
|
|
119
|
+
act(() => inSub.picker().setStyle("color", "red"));
|
|
120
|
+
expect(inSub.synced.map((changed) => Object.keys(changed))).toEqual([["compositions/b.html"]]);
|
|
121
|
+
act(() => root?.unmount());
|
|
122
|
+
const inRoot = mountPicker(files, "h1", host);
|
|
123
|
+
act(() => inRoot.picker().setStyle("color", "red"));
|
|
124
|
+
expect(inRoot.synced.map((changed) => Object.keys(changed))).toEqual([["index.html"]]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("keeps both of two edits made before the host rerenders", () => {
|
|
128
|
+
const { picker, synced } = mountPicker({ "index.html": SAVED });
|
|
129
|
+
act(() => {
|
|
130
|
+
picker().setStyle("color", "red");
|
|
131
|
+
picker().setStyle("background", "blue");
|
|
132
|
+
});
|
|
133
|
+
expect(synced.at(-1)?.["index.html"]).toMatch(/<h1 [^>]*style="color: red; background: blue"/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("builds the next edit on a file the host changed meanwhile", () => {
|
|
137
|
+
const { picker, synced, setHostFiles } = mountPicker({ "index.html": SAVED });
|
|
138
|
+
act(() => picker().setStyle("color", "red"));
|
|
139
|
+
setHostFiles({ "index.html": SAVED.replace(">Title<", ">Renamed<") });
|
|
140
|
+
act(() => picker().setStyle("background", "blue"));
|
|
141
|
+
const written = synced.at(-1)?.["index.html"] ?? "";
|
|
142
|
+
expect(written).toContain(">Renamed</h1>");
|
|
143
|
+
expect(written).not.toContain("color: red");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("builds the next edit on the host's undo", () => {
|
|
147
|
+
const { picker, synced, setHostFiles } = mountPicker({ "index.html": SAVED });
|
|
148
|
+
act(() => picker().setStyle("color", "red"));
|
|
149
|
+
setHostFiles({ "index.html": SAVED });
|
|
150
|
+
act(() => picker().setStyle("background", "blue"));
|
|
151
|
+
expect(synced.at(-1)?.["index.html"]).not.toContain("color: red");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("keeps later edits while the host has applied only the first", () => {
|
|
155
|
+
const { picker, synced, setHostFiles } = mountPicker({ "index.html": SAVED }, "h1", "", false);
|
|
156
|
+
act(() => {
|
|
157
|
+
picker().setStyle("color", "red");
|
|
158
|
+
picker().setStyle("background", "blue");
|
|
159
|
+
});
|
|
160
|
+
setHostFiles(synced[0] as Record<string, string>);
|
|
161
|
+
act(() => picker().setStyle("border", "0"));
|
|
162
|
+
expect(synced.at(-1)?.["index.html"]).toMatch(
|
|
163
|
+
/style="color: red; background: blue; border: 0"/,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("writes nothing when no saved file holds the element", () => {
|
|
168
|
+
const { picker, synced } = mountPicker({ "index.html": "<div>other</div>" });
|
|
169
|
+
act(() => picker().setStyle("color", "red"));
|
|
170
|
+
expect(synced).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { useState, useCallback, useRef } from "react";
|
|
2
2
|
import { useMountEffect } from "./useMountEffect";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
resolveSourceFile,
|
|
5
|
+
applyPatch,
|
|
6
|
+
applyPatchByTarget,
|
|
7
|
+
findTagByTarget,
|
|
8
|
+
type PatchOperation,
|
|
9
|
+
} from "../utils/sourcePatcher";
|
|
4
10
|
import {
|
|
5
11
|
acceptStudioRuntimeMessage,
|
|
6
12
|
postRuntimeControlMessage,
|
|
7
13
|
} from "../player/lib/runtimeProtocol";
|
|
14
|
+
import { compositionPathOfPreviewUrl } from "../player/components/CompositionThumbnail";
|
|
15
|
+
import { getSourceFileForElement } from "../components/editor/domEditingDom";
|
|
8
16
|
|
|
9
17
|
export interface PickedElement {
|
|
10
18
|
id: string | null;
|
|
@@ -91,6 +99,8 @@ export function useElementPicker(
|
|
|
91
99
|
|
|
92
100
|
// Listen for picker messages from the iframe
|
|
93
101
|
useMountEffect(() => {
|
|
102
|
+
// One guard per message field, then one branch per message type.
|
|
103
|
+
// fallow-ignore-next-line complexity
|
|
94
104
|
const handleMessage = (e: MessageEvent) => {
|
|
95
105
|
const data = e.data;
|
|
96
106
|
if (data?.source !== "hf-preview") return;
|
|
@@ -101,40 +111,13 @@ export function useElementPicker(
|
|
|
101
111
|
if (e.source !== activeIframe.contentWindow && e.source !== iframeRef.current?.contentWindow)
|
|
102
112
|
return;
|
|
103
113
|
|
|
104
|
-
if (data.type === "element-picked") {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const styles = readComputedStyles(activeIframe, el.selector);
|
|
108
|
-
setPickedElement({
|
|
109
|
-
id: el.id ?? null,
|
|
110
|
-
tagName: el.tagName ?? "div",
|
|
111
|
-
selector: el.selector ?? "",
|
|
112
|
-
label: el.label ?? el.tagName ?? "Element",
|
|
113
|
-
boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
114
|
-
textContent: el.textContent ?? null,
|
|
115
|
-
src: el.src ?? null,
|
|
116
|
-
dataAttributes: el.dataAttributes ?? {},
|
|
117
|
-
computedStyles: styles,
|
|
118
|
-
});
|
|
119
|
-
setIsPickMode(false);
|
|
120
|
-
}
|
|
114
|
+
if (data.type === "element-picked" && data.elementInfo) {
|
|
115
|
+
setPickedElement(toPickedElement(data.elementInfo, activeIframe));
|
|
116
|
+
setIsPickMode(false);
|
|
121
117
|
} else if (data.type === "element-pick-candidates") {
|
|
122
118
|
// Multiple candidates at click point — pick the first one
|
|
123
119
|
const el = data.candidates?.[data.selectedIndex ?? 0];
|
|
124
|
-
if (el)
|
|
125
|
-
const styles = readComputedStyles(activeIframe, el.selector);
|
|
126
|
-
setPickedElement({
|
|
127
|
-
id: el.id ?? null,
|
|
128
|
-
tagName: el.tagName ?? "div",
|
|
129
|
-
selector: el.selector ?? "",
|
|
130
|
-
label: el.label ?? el.tagName ?? "Element",
|
|
131
|
-
boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
132
|
-
textContent: el.textContent ?? null,
|
|
133
|
-
src: el.src ?? null,
|
|
134
|
-
dataAttributes: el.dataAttributes ?? {},
|
|
135
|
-
computedStyles: styles,
|
|
136
|
-
});
|
|
137
|
-
}
|
|
120
|
+
if (el) setPickedElement(toPickedElement(el, activeIframe));
|
|
138
121
|
}
|
|
139
122
|
|
|
140
123
|
if (data.type === "pick-mode-cancelled") {
|
|
@@ -149,29 +132,26 @@ export function useElementPicker(
|
|
|
149
132
|
// Ref for options to avoid stale closures in debounced callback
|
|
150
133
|
const optionsRef = useRef(options);
|
|
151
134
|
optionsRef.current = options;
|
|
135
|
+
const pendingWritesRef = useRef(new Map<string, PendingWrite>());
|
|
136
|
+
settlePendingWrites(pendingWritesRef.current, options?.workspaceFiles);
|
|
152
137
|
|
|
153
138
|
// Sync immediately (not debounced) — save on every change for reliability
|
|
154
139
|
const syncToSource = useCallback(
|
|
155
|
-
(
|
|
156
|
-
elementId: string,
|
|
157
|
-
selector: string,
|
|
158
|
-
op: {
|
|
159
|
-
type: "inline-style" | "attribute" | "text-content";
|
|
160
|
-
property: string;
|
|
161
|
-
value: string;
|
|
162
|
-
},
|
|
163
|
-
) => {
|
|
140
|
+
(picked: PickedElement, live: HTMLElement, iframe: HTMLIFrameElement, op: PatchOperation) => {
|
|
164
141
|
const opts = optionsRef.current;
|
|
165
|
-
if (!opts?.workspaceFiles || !opts.onSyncFiles
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
142
|
+
if (!opts?.workspaceFiles || !opts.onSyncFiles) return;
|
|
143
|
+
// No id: the preview's hf-id names the element, in the file it was served from.
|
|
144
|
+
const hfId = live.getAttribute("data-hf-id");
|
|
145
|
+
const pending = pendingWritesRef.current;
|
|
146
|
+
const files = withPendingWrites(opts.workspaceFiles, pending);
|
|
147
|
+
const patch = picked.id
|
|
148
|
+
? patchById(files, picked.id, picked.selector, op)
|
|
149
|
+
: hfId
|
|
150
|
+
? patchByHfId(files, hfId, ownSourceFile(live, iframe), op)
|
|
151
|
+
: null;
|
|
152
|
+
if (!patch || patch.after === patch.before) return;
|
|
153
|
+
recordPendingWrite(pending, patch.path, opts.workspaceFiles[patch.path], patch.after);
|
|
154
|
+
opts.onSyncFiles({ [patch.path]: patch.after });
|
|
175
155
|
},
|
|
176
156
|
[],
|
|
177
157
|
);
|
|
@@ -193,32 +173,11 @@ export function useElementPicker(
|
|
|
193
173
|
}
|
|
194
174
|
: null,
|
|
195
175
|
);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
property: prop,
|
|
202
|
-
value,
|
|
203
|
-
});
|
|
204
|
-
} else {
|
|
205
|
-
// No ID — save the full composition HTML from the iframe
|
|
206
|
-
// This captures ALL inline style changes, not just the targeted one
|
|
207
|
-
try {
|
|
208
|
-
const fullHtml = activeIframe.contentDocument?.documentElement.outerHTML;
|
|
209
|
-
if (fullHtml && optionsRef.current?.onSyncFiles) {
|
|
210
|
-
// Determine which file this iframe represents
|
|
211
|
-
const src = activeIframe.getAttribute("src") ?? "";
|
|
212
|
-
const compMatch = src.match(/\/comp\/(.+?)(?:\?|$)/);
|
|
213
|
-
const filePath = compMatch ? compMatch[1] : "index.html";
|
|
214
|
-
optionsRef.current.onSyncFiles({
|
|
215
|
-
[filePath]: `<!DOCTYPE html>\n<html>${fullHtml.replace(/<html[^>]*>/, "")}`,
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
} catch {
|
|
219
|
-
/* cross-origin */
|
|
220
|
-
}
|
|
221
|
-
}
|
|
176
|
+
syncToSource(pickedElement, el, activeIframe, {
|
|
177
|
+
type: "inline-style",
|
|
178
|
+
property: prop,
|
|
179
|
+
value,
|
|
180
|
+
});
|
|
222
181
|
}
|
|
223
182
|
} catch {
|
|
224
183
|
/* cross-origin */
|
|
@@ -244,14 +203,11 @@ export function useElementPicker(
|
|
|
244
203
|
}
|
|
245
204
|
: null,
|
|
246
205
|
);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
value,
|
|
253
|
-
});
|
|
254
|
-
}
|
|
206
|
+
syncToSource(pickedElement, el as HTMLElement, activeIframe, {
|
|
207
|
+
type: "attribute",
|
|
208
|
+
property: attr,
|
|
209
|
+
value,
|
|
210
|
+
});
|
|
255
211
|
}
|
|
256
212
|
} catch {
|
|
257
213
|
/* cross-origin */
|
|
@@ -270,14 +226,11 @@ export function useElementPicker(
|
|
|
270
226
|
if (el) {
|
|
271
227
|
el.textContent = text;
|
|
272
228
|
setPickedElement((prev) => (prev ? { ...prev, textContent: text } : null));
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
value: text,
|
|
279
|
-
});
|
|
280
|
-
}
|
|
229
|
+
syncToSource(pickedElement, el as HTMLElement, activeIframe, {
|
|
230
|
+
type: "text-content",
|
|
231
|
+
property: "textContent",
|
|
232
|
+
value: text,
|
|
233
|
+
});
|
|
281
234
|
}
|
|
282
235
|
} catch {
|
|
283
236
|
/* cross-origin */
|
|
@@ -305,6 +258,101 @@ export function useElementPicker(
|
|
|
305
258
|
};
|
|
306
259
|
}
|
|
307
260
|
|
|
261
|
+
type PickedElementInfo = Partial<Omit<PickedElement, "computedStyles">>;
|
|
262
|
+
|
|
263
|
+
// workspaceFiles lags this hook's own writes until the host rerenders.
|
|
264
|
+
interface PendingWrite {
|
|
265
|
+
hostSource: string | undefined;
|
|
266
|
+
writes: string[];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function settlePendingWrites(
|
|
270
|
+
pending: Map<string, PendingWrite>,
|
|
271
|
+
files: Record<string, string> | undefined,
|
|
272
|
+
): void {
|
|
273
|
+
for (const [path, write] of pending) {
|
|
274
|
+
const hostSource = files?.[path];
|
|
275
|
+
if (hostSource === write.hostSource) continue;
|
|
276
|
+
const caughtUpTo = hostSource === undefined ? -1 : write.writes.indexOf(hostSource);
|
|
277
|
+
if (caughtUpTo < 0 || caughtUpTo === write.writes.length - 1) {
|
|
278
|
+
pending.delete(path);
|
|
279
|
+
} else {
|
|
280
|
+
write.hostSource = hostSource;
|
|
281
|
+
write.writes = write.writes.slice(caughtUpTo + 1);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function withPendingWrites(
|
|
287
|
+
files: Record<string, string>,
|
|
288
|
+
pending: Map<string, PendingWrite>,
|
|
289
|
+
): Record<string, string> {
|
|
290
|
+
const merged = { ...files };
|
|
291
|
+
for (const [path, write] of pending) merged[path] = write.writes.at(-1) ?? merged[path];
|
|
292
|
+
return merged;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function recordPendingWrite(
|
|
296
|
+
pending: Map<string, PendingWrite>,
|
|
297
|
+
path: string,
|
|
298
|
+
hostSource: string | undefined,
|
|
299
|
+
after: string,
|
|
300
|
+
): void {
|
|
301
|
+
const write = pending.get(path) ?? { hostSource, writes: [] };
|
|
302
|
+
write.writes.push(after);
|
|
303
|
+
pending.set(path, write);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// A default per field of the runtime's element info.
|
|
307
|
+
// fallow-ignore-next-line complexity
|
|
308
|
+
function toPickedElement(el: PickedElementInfo, iframe: HTMLIFrameElement): PickedElement {
|
|
309
|
+
return {
|
|
310
|
+
id: el.id ?? null,
|
|
311
|
+
tagName: el.tagName ?? "div",
|
|
312
|
+
selector: el.selector ?? "",
|
|
313
|
+
label: el.label ?? el.tagName ?? "Element",
|
|
314
|
+
boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
315
|
+
textContent: el.textContent ?? null,
|
|
316
|
+
src: el.src ?? null,
|
|
317
|
+
dataAttributes: el.dataAttributes ?? {},
|
|
318
|
+
computedStyles: readComputedStyles(iframe, el.selector ?? ""),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
interface SourcePatch {
|
|
323
|
+
path: string;
|
|
324
|
+
before: string;
|
|
325
|
+
after: string;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function patchById(
|
|
329
|
+
files: Record<string, string>,
|
|
330
|
+
id: string,
|
|
331
|
+
selector: string,
|
|
332
|
+
op: PatchOperation,
|
|
333
|
+
): SourcePatch | null {
|
|
334
|
+
const path = resolveSourceFile(id, selector, files);
|
|
335
|
+
const before = path ? files[path] : undefined;
|
|
336
|
+
return path && before ? { path, before, after: applyPatch(before, id, op) } : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function ownSourceFile(live: HTMLElement, iframe: HTMLIFrameElement): string {
|
|
340
|
+
const previewed = compositionPathOfPreviewUrl(iframe.getAttribute("src") ?? "");
|
|
341
|
+
return getSourceFileForElement(live, previewed).sourceFile;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function patchByHfId(
|
|
345
|
+
files: Record<string, string>,
|
|
346
|
+
hfId: string,
|
|
347
|
+
ownFile: string,
|
|
348
|
+
op: PatchOperation,
|
|
349
|
+
): SourcePatch | null {
|
|
350
|
+
const matches = Object.keys(files).filter((file) => findTagByTarget(files[file] ?? "", { hfId }));
|
|
351
|
+
const path = matches.includes(ownFile) ? ownFile : matches.length === 1 ? matches[0] : undefined;
|
|
352
|
+
const before = path ? files[path] : undefined;
|
|
353
|
+
return path && before ? { path, before, after: applyPatchByTarget(before, { hfId }, op) } : null;
|
|
354
|
+
}
|
|
355
|
+
|
|
308
356
|
/** Read a subset of computed styles from an element in the iframe */
|
|
309
357
|
function readComputedStyles(iframe: HTMLIFrameElement, selector: string): Record<string, string> {
|
|
310
358
|
const styles: Record<string, string> = {};
|
|
@@ -94,6 +94,55 @@ describe("external file change coordinator", () => {
|
|
|
94
94
|
expect(refreshFileTree).toHaveBeenCalledOnce();
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
it("refreshes the file tree but not Preview for a file the preview never loaded", async () => {
|
|
98
|
+
const order: string[] = [];
|
|
99
|
+
await mountCoordinator({
|
|
100
|
+
reloadPreview: () => order.push("preview"),
|
|
101
|
+
reloadSdkSession: () => order.push("sdk"),
|
|
102
|
+
refreshFileTree: () => {
|
|
103
|
+
order.push("tree");
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
await act(async () =>
|
|
107
|
+
handler?.({ path: "notes.md", content: "notes", version: "v1", affectsPreview: false }),
|
|
108
|
+
);
|
|
109
|
+
expect(order).toEqual(["sdk", "tree"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("still reloads Preview when a notes change replaces a waiting film change", async () => {
|
|
113
|
+
let release = () => {};
|
|
114
|
+
const inFlight = new Promise<void>((resolve) => (release = resolve));
|
|
115
|
+
const reloadPreview = vi.fn();
|
|
116
|
+
const reloadSdkSession = vi.fn();
|
|
117
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
118
|
+
let drains = 0;
|
|
119
|
+
await mountCoordinator({
|
|
120
|
+
reloadPreview,
|
|
121
|
+
reloadSdkSession,
|
|
122
|
+
onAcceptedPersistedFileChange,
|
|
123
|
+
drainPendingChanges: async () => {
|
|
124
|
+
if (drains++ === 0) await inFlight;
|
|
125
|
+
return { status: "clean" as const };
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
await act(async () => handler?.({ path: "index.html", content: "a", version: "v1" }));
|
|
129
|
+
await act(async () =>
|
|
130
|
+
handler?.({
|
|
131
|
+
path: "scene.html",
|
|
132
|
+
content: "b",
|
|
133
|
+
version: "v2",
|
|
134
|
+
affectedCompositions: ["scene.html"],
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
await act(async () =>
|
|
138
|
+
handler?.({ path: "notes.md", content: "c", version: "v3", affectsPreview: false }),
|
|
139
|
+
);
|
|
140
|
+
await act(async () => release());
|
|
141
|
+
expect(reloadPreview).toHaveBeenCalledTimes(2);
|
|
142
|
+
expect(reloadSdkSession).toHaveBeenLastCalledWith("scene.html");
|
|
143
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("scene.html", ["scene.html"]);
|
|
144
|
+
});
|
|
145
|
+
|
|
97
146
|
it("does not refresh the tree for a suppressed self-write echo", async () => {
|
|
98
147
|
const refreshFileTree = vi.fn();
|
|
99
148
|
await mountCoordinator({ refreshFileTree });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
|
2
2
|
import {
|
|
3
3
|
readFileChangeAffectedCompositions,
|
|
4
|
+
readFileChangeAffectsPreview,
|
|
4
5
|
readFileChangeField,
|
|
5
6
|
readStudioFileChangePath,
|
|
6
7
|
} from "../components/editor/manualEdits";
|
|
@@ -231,9 +232,12 @@ export function useExternalFileChangeCoordinator({
|
|
|
231
232
|
}, [loadConflictSnapshot, projectId, recoveryFilePath]);
|
|
232
233
|
|
|
233
234
|
const reloadAcceptedGeneration = useCallback(
|
|
234
|
-
(path: string) => {
|
|
235
|
-
logReload("reload"
|
|
236
|
-
|
|
235
|
+
(path: string, affectsPreview = true) => {
|
|
236
|
+
logReload(affectsPreview ? "reload" : "file-tree only", {
|
|
237
|
+
path,
|
|
238
|
+
by: "external-change coordinator",
|
|
239
|
+
});
|
|
240
|
+
if (affectsPreview) reloadPreview();
|
|
237
241
|
reloadSdkSession(path);
|
|
238
242
|
// Fire-and-forget: a failed refresh leaves the tree as stale as it was,
|
|
239
243
|
// which is the status quo this exists to improve on, not a new failure
|
|
@@ -279,7 +283,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
279
283
|
if (!mountedRef.current || generation !== generationRef.current) return;
|
|
280
284
|
setBlocked(null);
|
|
281
285
|
onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(payload));
|
|
282
|
-
reloadAcceptedGeneration(path);
|
|
286
|
+
reloadAcceptedGeneration(path, readFileChangeAffectsPreview(payload));
|
|
283
287
|
return;
|
|
284
288
|
}
|
|
285
289
|
const content = readFileChangeContent(payload);
|
|
@@ -410,7 +414,12 @@ export function useExternalFileChangeCoordinator({
|
|
|
410
414
|
return;
|
|
411
415
|
}
|
|
412
416
|
|
|
413
|
-
pendingPayloadRef.current
|
|
417
|
+
const waiting = pendingPayloadRef.current?.payload;
|
|
418
|
+
const waitingChangeOutranksThis =
|
|
419
|
+
waiting != null &&
|
|
420
|
+
readFileChangeAffectsPreview(waiting) &&
|
|
421
|
+
!readFileChangeAffectsPreview(payload);
|
|
422
|
+
if (!waitingChangeOutranksThis) pendingPayloadRef.current = { payload };
|
|
414
423
|
void startDrainLoop();
|
|
415
424
|
},
|
|
416
425
|
[projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
|
|
@@ -456,7 +465,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
456
465
|
await deleteConflictSnapshot?.(projectId, path);
|
|
457
466
|
setBlocked(null);
|
|
458
467
|
onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(current.payload));
|
|
459
|
-
reloadAcceptedGeneration(path);
|
|
468
|
+
reloadAcceptedGeneration(path, readFileChangeAffectsPreview(current.payload));
|
|
460
469
|
},
|
|
461
470
|
[
|
|
462
471
|
deleteConflictSnapshot,
|