@hyperframes/studio 0.8.77 → 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.
Files changed (47) hide show
  1. package/dist/assets/hyperframes-player-Bx_Kw4Ey.js +464 -0
  2. package/dist/assets/{index-DcxWQ7Za.js → index-CJP92F4-.js} +1 -1
  3. package/dist/assets/{index-2GgHjQq-.js → index-DBKySIo7.js} +187 -187
  4. package/dist/assets/{index-CsFCRdMo.js → index-ORZXYB0k.js} +1 -1
  5. package/dist/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
  6. package/dist/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
  7. package/dist/index.d.ts +23 -15
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +459 -257
  10. package/dist/index.js.map +1 -1
  11. package/package.json +8 -7
  12. package/src/App.tsx +1 -0
  13. package/src/components/nle/useTimelineEditCallbacks.ts +8 -2
  14. package/src/hooks/timelineAudioGroupVolume.ts +21 -11
  15. package/src/hooks/timelineEditPermission.ts +47 -13
  16. package/src/hooks/timelineElementFxAttribute.ts +19 -13
  17. package/src/hooks/useClipboard.source.test.ts +340 -0
  18. package/src/hooks/useClipboard.ts +141 -62
  19. package/src/hooks/useElementPicker.test.ts +172 -0
  20. package/src/hooks/useElementPicker.ts +141 -93
  21. package/src/hooks/useFrameCapture.test.tsx +67 -0
  22. package/src/hooks/useFrameCapture.ts +18 -9
  23. package/src/hooks/usePersistentEditHistory.test.ts +2 -2
  24. package/src/hooks/usePersistentEditHistory.ts +2 -2
  25. package/src/hooks/useTimelineEditing.test.tsx +59 -0
  26. package/src/hooks/useTimelineEditing.ts +42 -33
  27. package/src/index.ts +8 -0
  28. package/src/player/components/Player.test.ts +5 -0
  29. package/src/player/components/Player.tsx +1 -0
  30. package/src/player/components/VideoThumbnail.test.tsx +13 -0
  31. package/src/player/components/VideoThumbnail.tsx +1 -1
  32. package/src/player/components/timelineCallbacks.ts +3 -2
  33. package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
  34. package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
  35. package/src/player/hooks/useTimelinePlayer.ts +25 -28
  36. package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
  37. package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
  38. package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
  39. package/src/telemetry/client.test.ts +26 -0
  40. package/src/telemetry/policy.test.ts +10 -0
  41. package/src/telemetry/policy.ts +7 -0
  42. package/src/utils/authoredSource.ts +92 -0
  43. package/src/utils/gsapSoftReload.test.ts +29 -0
  44. package/src/utils/gsapSoftReload.ts +3 -7
  45. package/dist/assets/hyperframes-player-BSDTJ9EF.js +0 -464
  46. /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
  47. /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 getElementOuterHtml(
73
+ function getSelectedDomElement(
66
74
  iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
67
75
  selection: DomEditSelection,
68
76
  activeCompositionPath: string | null,
69
- ): string | null {
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)?.outerHTML ?? null;
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 collectSelectedClips = useCallback((): {
249
+ const findSelectedClips = useCallback((): {
207
250
  elements: TimelineElement[];
208
- clips: TimelineClipboardClip[];
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 html =
216
- findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath)?.outerHTML ??
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
- clips.push({
223
- html,
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
- return { elements: selected, clips };
233
- }, [activeCompPath, previewIframeRef, showToast]);
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
- // DOM element copy
251
- const domSelection = domEditSelectionRef.current;
252
- if (domSelection) {
253
- const html = getElementOuterHtml(previewIframeRef, domSelection, activeCompPath);
254
- if (!html) {
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 false;
302
+ return null;
257
303
  }
258
- const targetPath = domSelection.sourceFile || activeCompPath || "index.html";
259
- clipboardRef.current = {
260
- kind: "dom-element",
261
- html,
262
- sourceFile: targetPath,
263
- originSelector: domSelection.selector,
264
- originSelectorIndex: domSelection.selectorIndex,
265
- };
266
- showToast("Copied element", "info");
267
- return true;
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
- showToast("Nothing selected to copy.", "info");
271
- return false;
272
- }, [activeCompPath, collectSelectedClips, domEditSelectionRef, previewIframeRef, showToast]);
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 result = collectSelectedClips();
343
- if (!result) return false;
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, clips } = result;
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
- collectSelectedClips,
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 copied = handleCopy();
394
- if (!copied) return false;
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
- }, [handleCopy, domEditSelectionRef, handleTimelineElementsDelete, handleDomEditElementDelete]);
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
+ });