@hyperframes/studio 0.8.78 → 0.8.79
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-BdnQGwG5.js → hyperframes-player-Bx_Kw4Ey.js} +3 -3
- package/dist/assets/{index-C_n__Uh3.js → index-CJP92F4-.js} +1 -1
- package/dist/assets/{index-CX_4MYbk.js → index-DBKySIo7.js} +187 -187
- package/dist/assets/{index-DdpuHaGN.js → index-ORZXYB0k.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 +22 -15
- package/dist/index.html +1 -1
- package/dist/index.js +452 -256
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +8 -2
- package/src/hooks/timelineAudioGroupVolume.ts +21 -11
- package/src/hooks/timelineEditPermission.ts +47 -13
- package/src/hooks/timelineElementFxAttribute.ts +19 -13
- package/src/hooks/useClipboard.source.test.ts +340 -0
- package/src/hooks/useClipboard.ts +141 -62
- package/src/hooks/useElementPicker.test.ts +172 -0
- package/src/hooks/useElementPicker.ts +141 -93
- 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 +42 -33
- package/src/index.ts +1 -0
- package/src/player/components/Player.test.ts +5 -0
- package/src/player/components/Player.tsx +1 -0
- package/src/player/components/VideoThumbnail.test.tsx +13 -0
- package/src/player/components/VideoThumbnail.tsx +1 -1
- package/src/player/components/timelineCallbacks.ts +3 -2
- 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/thumbnailVideoDecoder.test.ts +102 -6
- package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
- package/src/utils/authoredSource.ts +92 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +3 -7
- /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
- /package/dist/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
|
@@ -19,6 +19,13 @@ import { findElementForSelection } from "../components/editor/domEditingElement"
|
|
|
19
19
|
import { findTimelineElementInIframe, readFileContent } from "./timelineEditingHelpers";
|
|
20
20
|
import { buildTimelineElementKey } from "../player/lib/timelineElementHelpers";
|
|
21
21
|
import { timeRangesOverlap } from "../player/components/timelineCollision";
|
|
22
|
+
import {
|
|
23
|
+
authoredMarkup,
|
|
24
|
+
findAuthoredElement,
|
|
25
|
+
findAuthoredElementById,
|
|
26
|
+
parseSavedSource,
|
|
27
|
+
} from "../utils/authoredSource";
|
|
28
|
+
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
22
29
|
|
|
23
30
|
interface RecordEditInput {
|
|
24
31
|
label: string;
|
|
@@ -37,6 +44,7 @@ interface UseClipboardOptions {
|
|
|
37
44
|
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
|
|
38
45
|
handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
|
|
39
46
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
47
|
+
waitForPendingDomEditSaves: () => Promise<void>;
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
/** The timeline element(s) a copy/cut/duplicate acts on: the multi-selection
|
|
@@ -62,11 +70,11 @@ function clipToast(verbPast: string, clipCount: number): string {
|
|
|
62
70
|
return clipCount > 1 ? `${verbPast} ${clipCount} clips` : `${verbPast} clip`;
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
function
|
|
73
|
+
function getSelectedDomElement(
|
|
66
74
|
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
|
|
67
75
|
selection: DomEditSelection,
|
|
68
76
|
activeCompositionPath: string | null,
|
|
69
|
-
):
|
|
77
|
+
): Element | null {
|
|
70
78
|
let doc: Document | null = null;
|
|
71
79
|
try {
|
|
72
80
|
doc = iframeRef.current?.contentDocument ?? null;
|
|
@@ -75,7 +83,27 @@ function getElementOuterHtml(
|
|
|
75
83
|
}
|
|
76
84
|
if (!doc) return null;
|
|
77
85
|
|
|
78
|
-
return findElementForSelection(doc, selection, activeCompositionPath)
|
|
86
|
+
return findElementForSelection(doc, selection, activeCompositionPath);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function savedMarkupElseLive(saved: Document, live: Element, sourceFile: string): string {
|
|
90
|
+
const authored = findAuthoredElement(saved, live) ?? findAuthoredElementById(saved, live);
|
|
91
|
+
return authored ? authoredMarkup(authored, live, sourceFile) : live.outerHTML;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readSavedMarkup(
|
|
95
|
+
readSaved: (path: string) => Promise<string>,
|
|
96
|
+
paths: string[],
|
|
97
|
+
lives: Element[],
|
|
98
|
+
): Promise<string[]> {
|
|
99
|
+
const sources = new Map<string, Promise<Document>>();
|
|
100
|
+
return Promise.all(
|
|
101
|
+
paths.map(async (path, index) => {
|
|
102
|
+
if (!sources.has(path)) sources.set(path, readSaved(path).then(parseSavedSource));
|
|
103
|
+
const saved = await (sources.get(path) as Promise<Document>);
|
|
104
|
+
return savedMarkupElseLive(saved, lives[index] as Element, path);
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
79
107
|
}
|
|
80
108
|
|
|
81
109
|
export interface PlacedClip {
|
|
@@ -195,86 +223,130 @@ export function useClipboard({
|
|
|
195
223
|
handleTimelineElementsDelete,
|
|
196
224
|
handleDomEditElementDelete,
|
|
197
225
|
previewIframeRef,
|
|
226
|
+
waitForPendingDomEditSaves,
|
|
198
227
|
}: UseClipboardOptions) {
|
|
199
|
-
const clipboardRef = useRef<ClipboardPayload | null>(null);
|
|
228
|
+
const clipboardRef = useRef<Promise<ClipboardPayload | null> | null>(null);
|
|
200
229
|
const projectIdRef = useRef(projectId);
|
|
201
230
|
projectIdRef.current = projectId;
|
|
202
231
|
|
|
232
|
+
// After any save still in flight on the file, so a copy right after an edit takes the edit.
|
|
233
|
+
const readSaved = useCallback(
|
|
234
|
+
async (path: string): Promise<string> => {
|
|
235
|
+
const pid = projectIdRef.current;
|
|
236
|
+
if (!pid) throw new Error("No project is open.");
|
|
237
|
+
// Only the order matters here; a failed save already shows its own banner.
|
|
238
|
+
await waitForPendingDomEditSaves().catch(() => {});
|
|
239
|
+
return serializeStudioFileMutations(writeProjectFile, [path], () =>
|
|
240
|
+
readFileContent(pid, path),
|
|
241
|
+
);
|
|
242
|
+
},
|
|
243
|
+
[waitForPendingDomEditSaves, writeProjectFile],
|
|
244
|
+
);
|
|
245
|
+
|
|
203
246
|
// Resolved through findTimelineElementInIframe, the same composition-aware
|
|
204
247
|
// lookup every other timeline editor uses — unlike findElementForSelection,
|
|
205
248
|
// it can address a composition-instance clip's root.
|
|
206
|
-
const
|
|
249
|
+
const findSelectedClips = useCallback((): {
|
|
207
250
|
elements: TimelineElement[];
|
|
208
|
-
|
|
251
|
+
lives: Element[];
|
|
209
252
|
} | null => {
|
|
210
253
|
const selected = getSelectedElements();
|
|
211
254
|
if (selected.length === 0) return null;
|
|
212
|
-
|
|
213
|
-
const clips: TimelineClipboardClip[] = [];
|
|
255
|
+
const lives: Element[] = [];
|
|
214
256
|
for (const element of selected) {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
null;
|
|
218
|
-
if (!html) {
|
|
257
|
+
const live = findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath);
|
|
258
|
+
if (!live) {
|
|
219
259
|
showToast(`Unable to copy "${element.label ?? element.id}".`, "info");
|
|
220
260
|
return null;
|
|
221
261
|
}
|
|
222
|
-
|
|
223
|
-
|
|
262
|
+
lives.push(live);
|
|
263
|
+
}
|
|
264
|
+
return { elements: selected, lives };
|
|
265
|
+
}, [activeCompPath, previewIframeRef, showToast]);
|
|
266
|
+
|
|
267
|
+
const readClips = useCallback(
|
|
268
|
+
async (targets: {
|
|
269
|
+
elements: TimelineElement[];
|
|
270
|
+
lives: Element[];
|
|
271
|
+
}): Promise<TimelineClipboardClip[]> => {
|
|
272
|
+
const paths = targets.elements.map((el) => el.sourceFile || activeCompPath || "index.html");
|
|
273
|
+
const markup = await readSavedMarkup(readSaved, paths, targets.lives);
|
|
274
|
+
return targets.elements.map((element, index) => ({
|
|
275
|
+
html: markup[index] as string,
|
|
224
276
|
start: element.start,
|
|
225
277
|
duration: element.duration,
|
|
226
278
|
// authoredTrack, not track: `track` can be a display-lane number
|
|
227
279
|
// remapped by normalizeToZones, and writing THAT into data-track-index
|
|
228
280
|
// re-targets the wrong track in the sparse authored file.
|
|
229
281
|
track: element.authoredTrack ?? element.track,
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
// Two independent copy modes (timeline clip vs DOM element) in one handler.
|
|
236
|
-
// fallow-ignore-next-line complexity
|
|
237
|
-
const handleCopy = useCallback((): boolean => {
|
|
238
|
-
if (usePlayerStore.getState().selectedElementId) {
|
|
239
|
-
const result = collectSelectedClips();
|
|
240
|
-
if (!result) return false;
|
|
241
|
-
const targetPath = result.elements[0]?.sourceFile || activeCompPath || "index.html";
|
|
242
|
-
clipboardRef.current = { kind: "timeline-clip", clips: result.clips, sourceFile: targetPath };
|
|
243
|
-
showToast(
|
|
244
|
-
result.clips.length > 1 ? `Copied ${result.clips.length} clips` : "Copied clip",
|
|
245
|
-
"info",
|
|
246
|
-
);
|
|
247
|
-
return true;
|
|
248
|
-
}
|
|
282
|
+
}));
|
|
283
|
+
},
|
|
284
|
+
[activeCompPath, readSaved],
|
|
285
|
+
);
|
|
249
286
|
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
287
|
+
const copyTimelineSelection = useCallback((): Promise<ClipboardPayload> | null => {
|
|
288
|
+
const targets = findSelectedClips();
|
|
289
|
+
if (!targets) return null;
|
|
290
|
+
const sourceFile = targets.elements[0]?.sourceFile || activeCompPath || "index.html";
|
|
291
|
+
return readClips(targets).then((clips) => {
|
|
292
|
+
showToast(clips.length > 1 ? `Copied ${clips.length} clips` : "Copied clip", "info");
|
|
293
|
+
return { kind: "timeline-clip", clips, sourceFile };
|
|
294
|
+
});
|
|
295
|
+
}, [activeCompPath, findSelectedClips, readClips, showToast]);
|
|
296
|
+
|
|
297
|
+
const copyDomSelection = useCallback(
|
|
298
|
+
(domSelection: DomEditSelection): Promise<ClipboardPayload> | null => {
|
|
299
|
+
const live = getSelectedDomElement(previewIframeRef, domSelection, activeCompPath);
|
|
300
|
+
if (!live) {
|
|
255
301
|
showToast("Unable to copy this element.", "info");
|
|
256
|
-
return
|
|
302
|
+
return null;
|
|
257
303
|
}
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
304
|
+
const sourceFile = domSelection.sourceFile || activeCompPath || "index.html";
|
|
305
|
+
return readSaved(sourceFile).then((content) => {
|
|
306
|
+
showToast("Copied element", "info");
|
|
307
|
+
return {
|
|
308
|
+
kind: "dom-element",
|
|
309
|
+
html: savedMarkupElseLive(parseSavedSource(content), live, sourceFile),
|
|
310
|
+
sourceFile,
|
|
311
|
+
originSelector: domSelection.selector,
|
|
312
|
+
originSelectorIndex: domSelection.selectorIndex,
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
},
|
|
316
|
+
[activeCompPath, previewIframeRef, readSaved, showToast],
|
|
317
|
+
);
|
|
269
318
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
319
|
+
// The key handler needs its answer now, so the targets are found here and only the file read
|
|
320
|
+
// is pending. Returns this copy's own result; a failed copy leaves the clipboard as it was.
|
|
321
|
+
const copyToClipboard = useCallback((): Promise<ClipboardPayload | null> | null => {
|
|
322
|
+
const domSelection = domEditSelectionRef.current;
|
|
323
|
+
let pending: Promise<ClipboardPayload> | null;
|
|
324
|
+
if (usePlayerStore.getState().selectedElementId) pending = copyTimelineSelection();
|
|
325
|
+
else if (domSelection) pending = copyDomSelection(domSelection);
|
|
326
|
+
else {
|
|
327
|
+
showToast("Nothing selected to copy.", "info");
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
if (!pending) return null;
|
|
331
|
+
const own = pending.catch((error: unknown) => {
|
|
332
|
+
showToast(error instanceof Error ? error.message : "Failed to copy", "error");
|
|
333
|
+
return null;
|
|
334
|
+
});
|
|
335
|
+
const previous = clipboardRef.current;
|
|
336
|
+
const settled = own.then((payload) => payload ?? previous);
|
|
337
|
+
clipboardRef.current = settled;
|
|
338
|
+
void settled.then((payload) => {
|
|
339
|
+
if (!payload && clipboardRef.current === settled) clipboardRef.current = null;
|
|
340
|
+
});
|
|
341
|
+
return own;
|
|
342
|
+
}, [copyDomSelection, copyTimelineSelection, domEditSelectionRef, showToast]);
|
|
343
|
+
|
|
344
|
+
const handleCopy = useCallback((): boolean => copyToClipboard() !== null, [copyToClipboard]);
|
|
273
345
|
|
|
274
346
|
// Two independent paste modes (timeline clip vs DOM element) behind one guarded save.
|
|
275
347
|
// fallow-ignore-next-line complexity
|
|
276
348
|
const handlePaste = useCallback(async () => {
|
|
277
|
-
const payload = clipboardRef.current;
|
|
349
|
+
const payload = await clipboardRef.current;
|
|
278
350
|
if (!payload) {
|
|
279
351
|
showToast("Nothing to paste.", "info");
|
|
280
352
|
return;
|
|
@@ -339,16 +411,17 @@ export function useClipboard({
|
|
|
339
411
|
// pasteTimelineClips with handlePaste; only the anchor and clip source
|
|
340
412
|
// differ (the selection's own end here, the playhead there).
|
|
341
413
|
const handleDuplicate = useCallback(async (): Promise<boolean> => {
|
|
342
|
-
const
|
|
343
|
-
if (!
|
|
414
|
+
const targets = findSelectedClips();
|
|
415
|
+
if (!targets) return false;
|
|
344
416
|
const pid = projectIdRef.current;
|
|
345
417
|
if (!pid) return false;
|
|
346
418
|
|
|
347
|
-
const { elements
|
|
419
|
+
const { elements } = targets;
|
|
348
420
|
const targetPath = elements[0]?.sourceFile || activeCompPath || "index.html";
|
|
349
421
|
const anchorTime = Math.max(...elements.map((el) => el.start + el.duration));
|
|
350
422
|
|
|
351
423
|
try {
|
|
424
|
+
const clips = await readClips(targets);
|
|
352
425
|
const originalContent = await readFileContent(pid, targetPath);
|
|
353
426
|
const liveElements = usePlayerStore.getState().elements;
|
|
354
427
|
const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
|
|
@@ -381,7 +454,8 @@ export function useClipboard({
|
|
|
381
454
|
}
|
|
382
455
|
}, [
|
|
383
456
|
activeCompPath,
|
|
384
|
-
|
|
457
|
+
findSelectedClips,
|
|
458
|
+
readClips,
|
|
385
459
|
recordEdit,
|
|
386
460
|
reloadPreview,
|
|
387
461
|
showToast,
|
|
@@ -390,8 +464,9 @@ export function useClipboard({
|
|
|
390
464
|
|
|
391
465
|
const handleCut = useCallback(async (): Promise<boolean> => {
|
|
392
466
|
const selected = getSelectedElements();
|
|
393
|
-
const
|
|
394
|
-
|
|
467
|
+
const domSelection = domEditSelectionRef.current;
|
|
468
|
+
const copied = copyToClipboard();
|
|
469
|
+
if (!copied || !(await copied)) return false;
|
|
395
470
|
|
|
396
471
|
if (selected.length > 0) {
|
|
397
472
|
// One call for the whole selection, not one per element: the batched
|
|
@@ -401,13 +476,17 @@ export function useClipboard({
|
|
|
401
476
|
return true;
|
|
402
477
|
}
|
|
403
478
|
|
|
404
|
-
const domSelection = domEditSelectionRef.current;
|
|
405
479
|
if (domSelection) {
|
|
406
480
|
await handleDomEditElementDelete(domSelection);
|
|
407
481
|
return true;
|
|
408
482
|
}
|
|
409
483
|
return true;
|
|
410
|
-
}, [
|
|
484
|
+
}, [
|
|
485
|
+
copyToClipboard,
|
|
486
|
+
domEditSelectionRef,
|
|
487
|
+
handleTimelineElementsDelete,
|
|
488
|
+
handleDomEditElementDelete,
|
|
489
|
+
]);
|
|
411
490
|
|
|
412
491
|
const canPaste = useCallback(() => clipboardRef.current !== null, []);
|
|
413
492
|
|
|
@@ -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
|
+
});
|