@hyperframes/studio 0.8.96 → 0.8.97

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 (69) hide show
  1. package/dist/assets/{hyperframes-player-ClfxdXeC.js → hyperframes-player-Biu5_W0l.js} +1 -1
  2. package/dist/assets/{index-D8ztHPd3.js → index-B5Z-1RW9.js} +1 -1
  3. package/dist/assets/{index-CSHEHeOo.js → index-BdV5T-QI.js} +200 -194
  4. package/dist/assets/{index-BNQLP5_v.js → index-CWuozokW.js} +1 -1
  5. package/dist/assets/{index-Dx79V1Se.css → index-xB5O0hPw.css} +1 -1
  6. package/dist/{chunk-QZ6HNNFG.js → chunk-6GAO75IU.js} +16 -4
  7. package/dist/chunk-6GAO75IU.js.map +1 -0
  8. package/dist/{chunk-PQFEGZWM.js → chunk-MTAVNAGW.js} +2 -2
  9. package/dist/chunk-MTAVNAGW.js.map +1 -0
  10. package/dist/{chunk-QPSOTK6M.js → chunk-U3EE4ACX.js} +5 -5
  11. package/dist/chunk-U3EE4ACX.js.map +1 -0
  12. package/dist/{domEditingLayers-3BAP6NNO.js → domEditingLayers-NU6Z5QWA.js} +2 -2
  13. package/dist/index.d.ts +7 -2
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +1981 -1779
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles.css +1 -1
  19. package/dist/ui.d.ts +3 -1
  20. package/dist/ui.js +1 -1
  21. package/package.json +8 -8
  22. package/src/components/TimelineToolPicker.tsx +19 -15
  23. package/src/components/TimelineToolbar.test.tsx +62 -0
  24. package/src/components/TimelineToolbar.tsx +3 -1
  25. package/src/components/editor/domEditing.test.ts +9 -1
  26. package/src/components/editor/domEditingDom.ts +2 -1
  27. package/src/components/editor/fontAssets.test.ts +69 -0
  28. package/src/components/editor/fontAssets.ts +11 -2
  29. package/src/components/ui/Menu.test.tsx +21 -0
  30. package/src/components/ui/Popover.tsx +21 -1
  31. package/src/hooks/domSelectionTimelineMirror.ts +5 -4
  32. package/src/hooks/domStyleCommit.fonts.test.ts +55 -0
  33. package/src/hooks/useAppHotkeys.test.ts +7 -5
  34. package/src/hooks/useClipboard.ts +25 -7
  35. package/src/hooks/useDomSelection.test.ts +56 -32
  36. package/src/hooks/useDomSelection.ts +4 -3
  37. package/src/player/components/BeatStrip.tsx +6 -3
  38. package/src/player/components/Timeline.readOnly.test.tsx +670 -0
  39. package/src/player/components/Timeline.rulerSeek.test.tsx +1 -25
  40. package/src/player/components/TimelineAutomationLane.test.tsx +23 -0
  41. package/src/player/components/TimelineClipDiamonds.tsx +7 -2
  42. package/src/player/components/TimelineFxButton.tsx +3 -0
  43. package/src/player/components/TimelineLanes.tsx +3 -2
  44. package/src/player/components/TimelineProvider.tsx +23 -7
  45. package/src/player/components/TimelineTypes.ts +2 -0
  46. package/src/player/components/studioShortcuts.ts +1 -1
  47. package/src/player/components/timelineClipGestureHandlers.ts +3 -1
  48. package/src/player/components/timelineDragDrop.ts +11 -1
  49. package/src/player/components/timelineEditCapabilities.ts +1 -0
  50. package/src/player/components/timelineMountTestEnv.ts +24 -0
  51. package/src/player/components/timelineReadOnly.ts +64 -0
  52. package/src/player/components/useAutomationLaneGestures.ts +13 -2
  53. package/src/player/components/useTimelineKeyframeHandlers.ts +7 -0
  54. package/src/player/components/useTimelineProviderState.tsx +42 -14
  55. package/src/player/hooks/useShadowPreviewReload.ts +2 -2
  56. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +69 -0
  57. package/src/player/hooks/useTimelinePlayer.ts +6 -0
  58. package/src/player/lib/timelineDOM.test.ts +25 -0
  59. package/src/player/lib/timelineDOM.ts +4 -2
  60. package/src/player/lib/timelineIframeHelpers.test.ts +15 -0
  61. package/src/player/store/playerStore.ts +4 -4
  62. package/src/utils/clipboardLook.test.ts +130 -0
  63. package/src/utils/clipboardLook.ts +82 -0
  64. package/src/utils/clipboardPayload.ts +8 -2
  65. package/src/utils/studioFontHelpers.ts +6 -3
  66. package/dist/chunk-PQFEGZWM.js.map +0 -1
  67. package/dist/chunk-QPSOTK6M.js.map +0 -1
  68. package/dist/chunk-QZ6HNNFG.js.map +0 -1
  69. /package/dist/{domEditingLayers-3BAP6NNO.js.map → domEditingLayers-NU6Z5QWA.js.map} +0 -0
@@ -543,6 +543,75 @@ describe("shadow reload store ownership and readiness", () => {
543
543
  unmount(root);
544
544
  });
545
545
 
546
+ it("takes a section the edit removed off the timeline, though the old document still has it", () => {
547
+ const { getApi, gen, liveIframe, shadowIframe, root } = setup();
548
+ const host = (id: string) =>
549
+ `<div id="${id}" data-composition-id="${id}" data-composition-src="compositions/${id}.html" data-start="0" data-duration="3"></div>`;
550
+ liveIframe.contentDocument!.body.innerHTML = `<div data-composition-id="main">${host("benefit")}<p id="a"></p></div>`;
551
+ shadowIframe.contentDocument!.body.innerHTML = `<div data-composition-id="main"><p id="a"></p></div>`;
552
+ const a = { id: "a", domId: "a", tag: "p", start: 0, duration: 3, track: 0 };
553
+ const benefit = {
554
+ id: "benefit",
555
+ domId: "benefit",
556
+ tag: "div",
557
+ kind: "composition" as const,
558
+ start: 0,
559
+ duration: 3,
560
+ track: 1,
561
+ compositionSrc: "compositions/benefit.html",
562
+ };
563
+ act(() => usePlayerStore.getState().setElements([a, benefit]));
564
+ (shadowIframe.contentWindow as Record<string, unknown>).__clipManifest = {
565
+ source: "hf-preview",
566
+ type: "timeline",
567
+ durationInFrames: 1260,
568
+ clips: [
569
+ { id: "a", label: "A", start: 0, duration: 3, track: 0, kind: "element", tagName: "p" },
570
+ ],
571
+ };
572
+ act(() => getApi().onShadowIframeLoad(gen));
573
+ act(() => getApi().onShadowReadyChange(gen, true));
574
+ expect(usePlayerStore.getState().elements.map((element) => element.id)).toEqual(["a"]);
575
+ unmount(root);
576
+ });
577
+
578
+ it("keeps a section the new document still hosts but its manifest misses, without dropping it once", () => {
579
+ const { getApi, gen, liveIframe, shadowIframe, root } = setup();
580
+ const film = `<div data-composition-id="main"><div id="benefit" data-composition-id="benefit"
581
+ data-composition-src="compositions/benefit.html" data-start="0" data-duration="3"></div><p id="a"></p></div>`;
582
+ liveIframe.contentDocument!.body.innerHTML = film;
583
+ shadowIframe.contentDocument!.body.innerHTML = film;
584
+ const a = { id: "a", domId: "a", tag: "p", start: 0, duration: 3, track: 0 };
585
+ // As enrichment adds it: no kind.
586
+ const benefit = {
587
+ id: "benefit",
588
+ domId: "benefit",
589
+ tag: "div",
590
+ start: 0,
591
+ duration: 3,
592
+ track: 1,
593
+ compositionSrc: "compositions/benefit.html",
594
+ };
595
+ act(() => usePlayerStore.getState().setElements([a, benefit]));
596
+ const seen: string[][] = [];
597
+ const stop = usePlayerStore.subscribe((state) =>
598
+ seen.push(state.elements.map((element) => element.id)),
599
+ );
600
+ (shadowIframe.contentWindow as Record<string, unknown>).__clipManifest = {
601
+ source: "hf-preview",
602
+ type: "timeline",
603
+ durationInFrames: 1260,
604
+ clips: [
605
+ { id: "a", label: "A", start: 0, duration: 3, track: 0, kind: "element", tagName: "p" },
606
+ ],
607
+ };
608
+ act(() => getApi().onShadowIframeLoad(gen));
609
+ act(() => getApi().onShadowReadyChange(gen, true));
610
+ stop();
611
+ expect(seen.every((ids) => ids.includes("benefit"))).toBe(true);
612
+ unmount(root);
613
+ });
614
+
546
615
  it("never writes a failed shadow's timeline into the store", () => {
547
616
  vi.spyOn(console, "error").mockImplementation(() => {});
548
617
  const { getApi, gen, root } = setup();
@@ -28,6 +28,7 @@ export {
28
28
  import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
29
29
  import { releaseStaticSeekCache, type StaticSeekCacheEntry } from "../lib/playbackAdapter";
30
30
  import { mergeTimelineElementsPreservingDowngrades } from "../lib/timelineDOM";
31
+ import { findTimelineElementInIframe } from "../../hooks/timelineEditingHelpers";
31
32
  import { normalizeToZones } from "../components/timelineZones";
32
33
  import { applyPreviewAudioFlags, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
33
34
  import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
@@ -92,6 +93,11 @@ export function useTimelinePlayer({
92
93
  elements,
93
94
  state.duration,
94
95
  resolvedDuration,
96
+ (element) =>
97
+ findTimelineElementInIframe(iframeRef.current, {
98
+ ...element,
99
+ kind: "composition",
100
+ }) !== null,
95
101
  ),
96
102
  state.timelineProjectId,
97
103
  ),
@@ -71,6 +71,20 @@ describe("parseTimelineFromDOM — nested master time", () => {
71
71
  });
72
72
  });
73
73
 
74
+ describe("parseTimelineFromDOM — repeated sections", () => {
75
+ it("names each instance by its authored id and keeps them apart", () => {
76
+ const doc = makeDoc(`
77
+ <div data-composition-id="main" data-start="0" data-duration="20">
78
+ <div data-composition-id="card__hf1" data-hf-original-composition-id="card" data-start="0" data-duration="4"></div>
79
+ <div data-composition-id="card__hf2" data-hf-original-composition-id="card" data-start="4" data-duration="4"></div>
80
+ </div>
81
+ `);
82
+ const rows = parseTimelineFromDOM(doc, 20);
83
+ expect(rows.map((e) => e.label)).toEqual(["card", "card"]);
84
+ expect(new Set(rows.map((e) => e.id)).size).toBe(2);
85
+ });
86
+ });
87
+
74
88
  describe("parseTimelineFromDOM — nested rows' keyframe basis", () => {
75
89
  const doc = () =>
76
90
  makeDoc(`
@@ -534,6 +548,17 @@ describe("mergeTimelineElementsPreservingDowngrades — genuine removal vs trans
534
548
  expect(merged.map((e) => e.id).sort()).toEqual(["a", "sub-child"]);
535
549
  });
536
550
 
551
+ it("drops a section whose host left the preview (undo of an agent's build)", () => {
552
+ const current = [
553
+ el("a"),
554
+ el("benefit-fresh", { compositionSrc: "compositions/benefit-fresh.html" }),
555
+ ];
556
+ const next = [el("a")]; // the reverted film's manifest: the built section is gone
557
+ const inPreview = (element: { id: string }) => element.id !== "benefit-fresh";
558
+ const merged = mergeTimelineElementsPreservingDowngrades(current, next, 30, 30, inPreview);
559
+ expect(merged.map((e) => e.id)).toEqual(["a"]);
560
+ });
561
+
537
562
  it("trusts the fresh scan fully when it is not shorter", () => {
538
563
  const current = [el("a"), el("b", { compositionSrc: "sub.html" })];
539
564
  const next = [el("a"), el("c")];
@@ -265,7 +265,7 @@ export function parseTimelineFromDOM(
265
265
  const sourceFile = getTimelineElementSourceFile(el);
266
266
  const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
267
267
  const label = getTimelineElementDisplayLabel({
268
- id: el.id || compId || null,
268
+ id: el.id || el.getAttribute("data-hf-original-composition-id") || compId || null,
269
269
  label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
270
270
  tag: tagLower,
271
271
  });
@@ -382,6 +382,7 @@ export function mergeTimelineElementsPreservingDowngrades(
382
382
  nextElements: TimelineElement[],
383
383
  currentDuration: number,
384
384
  nextDuration: number,
385
+ stillInPreview: (element: TimelineElement) => boolean = () => true,
385
386
  ): TimelineElement[] {
386
387
  const safeCurrentDuration = Number.isFinite(currentDuration) ? currentDuration : 0;
387
388
  const safeNextDuration = Number.isFinite(nextDuration) ? nextDuration : 0;
@@ -403,7 +404,8 @@ export function mergeTimelineElementsPreservingDowngrades(
403
404
  // re-adds. A TOP-LEVEL element missing from the fresh scan was genuinely
404
405
  // removed (undo of a split, a delete), so let it go — otherwise undoing a
405
406
  // split leaves a ghost clip in the timeline even though the file is reverted.
406
- element.compositionSrc != null,
407
+ element.compositionSrc != null &&
408
+ stillInPreview(element),
407
409
  );
408
410
  if (preserved.length === 0) return nextElements;
409
411
  return [...nextElements, ...preserved];
@@ -9,6 +9,7 @@ import {
9
9
  stopScrubPreviewAudio,
10
10
  } from "./timelineIframeHelpers";
11
11
  import type { IframeWindow } from "./playbackTypes";
12
+ import { findTimelineElementInIframe } from "../../hooks/timelineEditingHelpers";
12
13
 
13
14
  function makeDoc(html: string): Document {
14
15
  const d = document.implementation.createHTMLDocument();
@@ -42,6 +43,20 @@ describe("buildMissingCompositionElements — hfId (R7)", () => {
42
43
  expect([patched, updatedEls[0]?.compositionSrc]).toEqual([true, "compositions/scene.html"]);
43
44
  });
44
45
 
46
+ it("adds a host the lookup finds in its preview, as a composition", () => {
47
+ const doc = makeDoc(`
48
+ <div data-composition-id="root">
49
+ <div id="benefit-fresh" data-composition-id="benefit-fresh" data-composition-src="compositions/benefit-fresh.html"
50
+ data-start="0" data-duration="3"></div>
51
+ </div>
52
+ `);
53
+ const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
54
+ const iframe = { contentDocument: doc } as unknown as HTMLIFrameElement;
55
+ expect(findTimelineElementInIframe(iframe, { ...missing[0]!, kind: "composition" })).toBe(
56
+ doc.getElementById("benefit-fresh"),
57
+ );
58
+ });
59
+
45
60
  it("harvests hfId from data-hf-id on composition host elements", () => {
46
61
  const doc = makeDoc(`
47
62
  <div data-composition-id="root">
@@ -257,10 +257,10 @@ interface BeatHistoryEntry {
257
257
  /** Selects like the marquee: the primary first, so its resets run, then the whole set. */
258
258
  function selectAroundPlayhead(
259
259
  state: PlayerState,
260
- keep: (start: number, playhead: number) => boolean,
260
+ keep: (el: TimelineElement, playhead: number) => boolean,
261
261
  ): void {
262
262
  const playhead = state.isPlaying ? liveTime.latest() : state.currentTime;
263
- const ids = state.elements.filter((el) => keep(el.start, playhead)).map((el) => el.key ?? el.id);
263
+ const ids = state.elements.filter((el) => keep(el, playhead)).map((el) => el.key ?? el.id);
264
264
  state.setSelectedElementId(ids[0] ?? null);
265
265
  state.setSelectedElementIds(new Set(ids));
266
266
  }
@@ -289,8 +289,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
289
289
 
290
290
  activeTool: "select",
291
291
  setActiveTool: (tool) => set({ activeTool: tool }),
292
- selectLeftward: () => selectAroundPlayhead(get(), (start, playhead) => start < playhead),
293
- selectRightward: () => selectAroundPlayhead(get(), (start, playhead) => start >= playhead),
292
+ selectLeftward: () => selectAroundPlayhead(get(), (el, t) => el.start < t),
293
+ selectRightward: () => selectAroundPlayhead(get(), (el, t) => el.start + el.duration > t),
294
294
 
295
295
  ...createKeyframeSlice(set, () => ({
296
296
  timelineProjectId: get().timelineProjectId,
@@ -0,0 +1,130 @@
1
+ // @vitest-environment jsdom
2
+ import gsap from "gsap";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { pasteTimelineClips } from "../hooks/useClipboard";
5
+
6
+ const GOODBYE =
7
+ '<h2 id="goodbye" class="clip" data-start="1" data-duration="3" data-track-index="3">Goodbye</h2>';
8
+ const FILM = `<!doctype html>
9
+ <html>
10
+ <head>
11
+ <style>
12
+ #goodbye {
13
+ color: #f97316;
14
+ font-size: 96px;
15
+ }
16
+ #title { font-size: 72px; }
17
+ </style>
18
+ </head>
19
+ <body>
20
+ <div id="root" data-composition-id="main" data-start="0" data-duration="10">
21
+ <h1 id="title" class="clip" data-start="0" data-duration="10" data-track-index="0">Title</h1>
22
+ ${GOODBYE}
23
+ </div>
24
+ <script>
25
+ const tl = gsap.timeline({ paused: true });
26
+ tl.from("#goodbye", { opacity: 0, y: 40, duration: 0.5 }, 1);
27
+ tl.to("#title", { opacity: 0.5, duration: 2 });
28
+ window.__timelines["main"] = tl;
29
+ </script>
30
+ </body>
31
+ </html>`;
32
+
33
+ const goodbye = { html: GOODBYE, start: 1, duration: 3, track: 3 };
34
+
35
+ /** Runs the film's own script with real GSAP and says when each tween starts, by its target's id. */
36
+ function tweenStarts(html: string): Array<[string, number]> {
37
+ const doc = new DOMParser().parseFromString(html, "text/html");
38
+ document.body.innerHTML = doc.body.innerHTML;
39
+ const win = window as unknown as { __timelines: Record<string, gsap.core.Timeline> };
40
+ win.__timelines = {};
41
+ new Function("gsap", doc.querySelector("script")?.textContent ?? "")(gsap);
42
+ return win.__timelines
43
+ .main!.getChildren()
44
+ .map((tween) => [(tween.targets()[0] as Element).id, tween.startTime()] as [string, number])
45
+ .sort(([a], [b]) => a.localeCompare(b));
46
+ }
47
+
48
+ /** The declarations the film's styles give the rules for `selector`. */
49
+ function rulesFor(html: string, selector: string): string[] {
50
+ const sheet = new CSSStyleSheet();
51
+ sheet.replaceSync(
52
+ new DOMParser().parseFromString(html, "text/html").querySelector("style")!.textContent!,
53
+ );
54
+ return Array.from(sheet.cssRules)
55
+ .filter(
56
+ (rule): rule is CSSStyleRule =>
57
+ rule instanceof CSSStyleRule && rule.selectorText === selector,
58
+ )
59
+ .map((rule) => rule.style.cssText);
60
+ }
61
+
62
+ describe("a pasted clip takes its original's look and motion", () => {
63
+ afterEach(() => {
64
+ document.body.innerHTML = "";
65
+ });
66
+
67
+ it("copies the id's rules and tweens for the renamed copy, moved to where it lands", () => {
68
+ const { content } = pasteTimelineClips(FILM, [goodbye], 4, [], true);
69
+
70
+ expect(content).toContain('id="goodbye-2"');
71
+ expect(rulesFor(content, "#goodbye-2")).toEqual(rulesFor(FILM, "#goodbye"));
72
+ expect(tweenStarts(content)).toEqual([
73
+ ["goodbye", 1],
74
+ ["goodbye-2", 4],
75
+ ["title", 1.5],
76
+ ]);
77
+ // The original's own motion is untouched: the copy's tween does not push the title's.
78
+ expect(tweenStarts(FILM)).toEqual([
79
+ ["goodbye", 1],
80
+ ["title", 1.5],
81
+ ]);
82
+ });
83
+
84
+ it("never moves the film's own tweens, whatever their timing, and runs a timeline declared in a block", () => {
85
+ const film = FILM.replace(
86
+ / <script>[\s\S]*<\/script>/,
87
+ ` <script>
88
+ if (gsap) {
89
+ const tl = gsap.timeline({ paused: true });
90
+ const title = "#title";
91
+ tl.from("#goodbye", { opacity: 0, duration: 0.5, stagger: 0.1, delay: 0.2 }, 1);
92
+ tl.to(title, { opacity: 0.5, duration: 1 });
93
+ tl.addLabel("late", "+=0.5");
94
+ tl.to("#goodbye", { y: 10, duration: 0.5 }, "late");
95
+ for (const id of ["#title"]) tl.to(id, { x: 5, duration: 0.5 });
96
+ tl.to("#goodbye", { scale: 1.1, duration: 0.5 });
97
+ tl.to(title, { y: 5, duration: 0.5 });
98
+ window.__timelines["main"] = tl;
99
+ }
100
+ </script>`,
101
+ );
102
+ const { content } = pasteTimelineClips(film, [goodbye], 4, [], true);
103
+ const own = (html: string) => tweenStarts(html).filter(([id]) => id !== "goodbye-2");
104
+ expect(own(content)).toEqual(own(film));
105
+ // Only the tween at a number is copied: its copy starts 3 s after it (1.2 s with its delay), like the clip.
106
+ expect(tweenStarts(content).filter(([id]) => id === "goodbye-2")).toEqual([["goodbye-2", 4.2]]);
107
+ });
108
+
109
+ it("copies only the selectors naming the original, and leaves a style inside a script alone", () => {
110
+ const styleInScript = `document.head.insertAdjacentHTML("beforeend", "<style>#goodbye{color:red}</style>");`;
111
+ const film = FILM.replace(
112
+ " #title { font-size: 72px; }",
113
+ " #title, #goodbye { letter-spacing: 2px; }\n #title { letter-spacing: 9px; }",
114
+ ).replace(" window.__timelines", ` ${styleInScript}\n window.__timelines`);
115
+ const { content } = pasteTimelineClips(film, [goodbye], 4, [], true);
116
+
117
+ expect(rulesFor(content, "#goodbye-2")).toEqual([
118
+ ...rulesFor(film, "#goodbye"),
119
+ "letter-spacing: 2px;",
120
+ ]);
121
+ expect(content.match(/#title/g)?.length).toBe(film.match(/#title/g)?.length);
122
+ expect(content).toContain(styleInScript);
123
+ });
124
+
125
+ it("copies nothing for a clip from another file, whose id means something else here", () => {
126
+ const { content } = pasteTimelineClips(FILM, [goodbye], 4, []);
127
+ expect(rulesFor(content, "#goodbye-2")).toEqual([]);
128
+ expect(tweenStarts(content).map(([id]) => id)).toEqual(["goodbye", "title"]);
129
+ });
130
+ });
@@ -0,0 +1,82 @@
1
+ import { copyAnimationsInScript } from "@hyperframes/parsers/gsap-writer-acorn";
2
+ import { ID_ATTR_RE } from "./clipboardPayload";
3
+ import { escapeRegex } from "./sourcePatcher";
4
+
5
+ const ID_ATTRS = new RegExp(ID_ATTR_RE.source, "g");
6
+ // One pass over both, so a `<style>` written inside a script's text is left as script.
7
+ const BLOCKS =
8
+ /(<script\b(?![^>]*\bsrc=)[^>]*>)([\s\S]*?)(<\/script>)|(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi;
9
+
10
+ /** Each id a paste renamed (`goodbye` to `goodbye-2`), from the same markup before and after the rename. */
11
+ export function renamedIds(before: string, after: string): Map<string, string> {
12
+ const ids = (html: string) => Array.from(html.matchAll(ID_ATTRS), (match) => match[1] as string);
13
+ const renamed = ids(after);
14
+ const renames = new Map<string, string>();
15
+ ids(before).forEach((from, index) => {
16
+ const to = renamed[index];
17
+ if (to && to !== from) renames.set(from, to);
18
+ });
19
+ return renames;
20
+ }
21
+
22
+ /** Gives each renamed copy the look and motion its original has in `html`: a copy of every CSS rule keyed to
23
+ * the original's id, and of every tween on it, moved by `delta` seconds. */
24
+ export function carryLook(
25
+ html: string,
26
+ renames: ReadonlyMap<string, string>,
27
+ delta: number,
28
+ ): string {
29
+ let result = html;
30
+ for (const [from, to] of renames) {
31
+ result = result.replace(
32
+ BLOCKS,
33
+ (block, scriptOpen, script, scriptClose, styleOpen, css, styleClose) =>
34
+ scriptOpen !== undefined
35
+ ? scriptOpen + copyAnimationsInScript(script, `#${from}`, `#${to}`, delta) + scriptClose
36
+ : styleOpen !== undefined
37
+ ? styleOpen + withCopiedRules(css, from, to) + styleClose
38
+ : block,
39
+ );
40
+ }
41
+ return result;
42
+ }
43
+
44
+ // Top-level rules only: a rule inside @media or @supports is not copied.
45
+ function withCopiedRules(css: string, from: string, to: string): string {
46
+ const sheet = new CSSStyleSheet();
47
+ sheet.replaceSync(css);
48
+ const id = new RegExp(`#${escapeRegex(from)}(?![\\w-])`, "g");
49
+ const excluded = new RegExp(`:not\\([^)]*#${escapeRegex(from)}(?![\\w-])`);
50
+ const indent = css.match(/\n([ \t]*)\S/)?.[1] ?? "";
51
+ const copies = Array.from(sheet.cssRules).flatMap((rule) => {
52
+ if (!(rule instanceof CSSStyleRule)) return [];
53
+ // Only the selectors naming the original, so the copy restyles nothing else.
54
+ const selectors = selectorList(rule.selectorText).filter(
55
+ (selector) => selector.match(id) && !excluded.test(selector),
56
+ );
57
+ if (selectors.length === 0) return [];
58
+ const copied = selectors.map((selector) => selector.replace(id, `#${to}`)).join(", ");
59
+ return [`\n${indent}${copied}${rule.cssText.slice(rule.selectorText.length)}`];
60
+ });
61
+ if (copies.length === 0) return css;
62
+ const body = css.trimEnd();
63
+ return body + copies.join("") + css.slice(body.length);
64
+ }
65
+
66
+ /** A selector list's own entries: its commas outside brackets and parentheses. */
67
+ function selectorList(text: string): string[] {
68
+ const selectors: string[] = [];
69
+ let depth = 0;
70
+ let start = 0;
71
+ for (let index = 0; index < text.length; index++) {
72
+ const char = text[index];
73
+ if (char === "(" || char === "[") depth++;
74
+ else if (char === ")" || char === "]") depth--;
75
+ else if (char === "," && depth === 0) {
76
+ selectors.push(text.slice(start, index).trim());
77
+ start = index + 1;
78
+ }
79
+ }
80
+ selectors.push(text.slice(start).trim());
81
+ return selectors;
82
+ }
@@ -12,7 +12,12 @@ export interface TimelineClipboardClip {
12
12
  }
13
13
 
14
14
  export type ClipboardPayload =
15
- | { kind: "timeline-clip"; clips: TimelineClipboardClip[]; sourceFile: string }
15
+ | {
16
+ kind: "timeline-clip";
17
+ clips: TimelineClipboardClip[];
18
+ sourceFile: string;
19
+ projectId?: string;
20
+ }
16
21
  | {
17
22
  kind: "dom-element";
18
23
  html: string;
@@ -56,7 +61,8 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
56
61
  );
57
62
  });
58
63
  if (clips.length === 0) return null;
59
- return { kind: "timeline-clip", clips, sourceFile: obj.sourceFile };
64
+ const projectId = typeof obj.projectId === "string" ? obj.projectId : undefined;
65
+ return { kind: "timeline-clip", clips, sourceFile: obj.sourceFile, projectId };
60
66
  }
61
67
  if (obj.kind === "dom-element") {
62
68
  if (typeof obj.html !== "string") return null;
@@ -52,7 +52,7 @@ export function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string):
52
52
  }
53
53
 
54
54
  export function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
55
- const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
55
+ const id = `studio-imported-font-${asset.path.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
56
56
  if (doc.getElementById(id)) return;
57
57
  const style = doc.createElement("style");
58
58
  style.id = id;
@@ -72,12 +72,15 @@ export function ensureImportedFontFace(
72
72
  const styleMatch = styleRe.exec(html);
73
73
  if (styleMatch) {
74
74
  const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
75
- return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
75
+ return html.replace(
76
+ styleMatch[0],
77
+ () => `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`,
78
+ );
76
79
  }
77
80
 
78
81
  const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
79
82
  if (/<\/head>/i.test(html)) {
80
- return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
83
+ return html.replace(/<\/head>/i, () => ` ${styleTag}\n </head>`);
81
84
  }
82
85
  return `${styleTag}\n${html}`;
83
86
  }