@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.
- package/dist/assets/{hyperframes-player-ClfxdXeC.js → hyperframes-player-Biu5_W0l.js} +1 -1
- package/dist/assets/{index-D8ztHPd3.js → index-B5Z-1RW9.js} +1 -1
- package/dist/assets/{index-CSHEHeOo.js → index-BdV5T-QI.js} +200 -194
- package/dist/assets/{index-BNQLP5_v.js → index-CWuozokW.js} +1 -1
- package/dist/assets/{index-Dx79V1Se.css → index-xB5O0hPw.css} +1 -1
- package/dist/{chunk-QZ6HNNFG.js → chunk-6GAO75IU.js} +16 -4
- package/dist/chunk-6GAO75IU.js.map +1 -0
- package/dist/{chunk-PQFEGZWM.js → chunk-MTAVNAGW.js} +2 -2
- package/dist/chunk-MTAVNAGW.js.map +1 -0
- package/dist/{chunk-QPSOTK6M.js → chunk-U3EE4ACX.js} +5 -5
- package/dist/chunk-U3EE4ACX.js.map +1 -0
- package/dist/{domEditingLayers-3BAP6NNO.js → domEditingLayers-NU6Z5QWA.js} +2 -2
- package/dist/index.d.ts +7 -2
- package/dist/index.html +2 -2
- package/dist/index.js +1981 -1779
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.d.ts +3 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolPicker.tsx +19 -15
- package/src/components/TimelineToolbar.test.tsx +62 -0
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/domEditing.test.ts +9 -1
- package/src/components/editor/domEditingDom.ts +2 -1
- package/src/components/editor/fontAssets.test.ts +69 -0
- package/src/components/editor/fontAssets.ts +11 -2
- package/src/components/ui/Menu.test.tsx +21 -0
- package/src/components/ui/Popover.tsx +21 -1
- package/src/hooks/domSelectionTimelineMirror.ts +5 -4
- package/src/hooks/domStyleCommit.fonts.test.ts +55 -0
- package/src/hooks/useAppHotkeys.test.ts +7 -5
- package/src/hooks/useClipboard.ts +25 -7
- package/src/hooks/useDomSelection.test.ts +56 -32
- package/src/hooks/useDomSelection.ts +4 -3
- package/src/player/components/BeatStrip.tsx +6 -3
- package/src/player/components/Timeline.readOnly.test.tsx +670 -0
- package/src/player/components/Timeline.rulerSeek.test.tsx +1 -25
- package/src/player/components/TimelineAutomationLane.test.tsx +23 -0
- package/src/player/components/TimelineClipDiamonds.tsx +7 -2
- package/src/player/components/TimelineFxButton.tsx +3 -0
- package/src/player/components/TimelineLanes.tsx +3 -2
- package/src/player/components/TimelineProvider.tsx +23 -7
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/studioShortcuts.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.ts +3 -1
- package/src/player/components/timelineDragDrop.ts +11 -1
- package/src/player/components/timelineEditCapabilities.ts +1 -0
- package/src/player/components/timelineMountTestEnv.ts +24 -0
- package/src/player/components/timelineReadOnly.ts +64 -0
- package/src/player/components/useAutomationLaneGestures.ts +13 -2
- package/src/player/components/useTimelineKeyframeHandlers.ts +7 -0
- package/src/player/components/useTimelineProviderState.tsx +42 -14
- package/src/player/hooks/useShadowPreviewReload.ts +2 -2
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +69 -0
- package/src/player/hooks/useTimelinePlayer.ts +6 -0
- package/src/player/lib/timelineDOM.test.ts +25 -0
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineIframeHelpers.test.ts +15 -0
- package/src/player/store/playerStore.ts +4 -4
- package/src/utils/clipboardLook.test.ts +130 -0
- package/src/utils/clipboardLook.ts +82 -0
- package/src/utils/clipboardPayload.ts +8 -2
- package/src/utils/studioFontHelpers.ts +6 -3
- package/dist/chunk-PQFEGZWM.js.map +0 -1
- package/dist/chunk-QPSOTK6M.js.map +0 -1
- package/dist/chunk-QZ6HNNFG.js.map +0 -1
- /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: (
|
|
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
|
|
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(), (
|
|
293
|
-
selectRightward: () => selectAroundPlayhead(get(), (
|
|
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
|
-
| {
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
}
|