@hyperframes/studio 0.8.96 → 0.8.98
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-C6muFtfk.js} +1 -1
- package/dist/assets/{index-D8ztHPd3.js → index-B6vU39NM.js} +1 -1
- package/dist/assets/{index-BNQLP5_v.js → index-BxnoLgxG.js} +1 -1
- package/dist/assets/{index-CSHEHeOo.js → index-itc1XziP.js} +198 -192
- 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 +23 -6
- package/dist/index.html +2 -2
- package/dist/index.js +2153 -1969
- 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 +9 -8
- package/src/components/EditorShell.tsx +7 -0
- 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/DomEditOverlay.tsx +0 -27
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
- package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
- package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
- 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/editor/propertyPanelFont.test.tsx +48 -5
- package/src/components/editor/propertyPanelFont.tsx +2 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
- package/src/components/nle/TimelinePane.tsx +4 -0
- 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/useCommitPreflightCapabilities.ts +3 -11
- package/src/hooks/useDomSelection.test.ts +56 -32
- package/src/hooks/useDomSelection.ts +4 -3
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
- package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
- package/src/hooks/useGsapTweenCache.ts +0 -24
- package/src/index.ts +4 -1
- package/src/player/components/BeatStrip.tsx +6 -3
- package/src/player/components/ClipContextMenu.test.tsx +182 -0
- package/src/player/components/ClipContextMenu.tsx +52 -10
- 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/TimelineOverlays.test.ts +30 -7
- package/src/player/components/TimelineOverlays.tsx +7 -1
- package/src/player/components/TimelineProvider.tsx +24 -7
- package/src/player/components/TimelineTypes.ts +12 -0
- package/src/player/components/menuKeyboardNav.ts +12 -5
- 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 +44 -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
|
@@ -2,6 +2,8 @@ export interface ImportedFontAsset {
|
|
|
2
2
|
family: string;
|
|
3
3
|
path: string;
|
|
4
4
|
url: string;
|
|
5
|
+
weight?: string;
|
|
6
|
+
style?: "normal" | "italic";
|
|
5
7
|
}
|
|
6
8
|
|
|
7
9
|
const FONT_EXT_RE = /\.(eot|otf|ttc|ttf|woff2?)$/i;
|
|
@@ -9,7 +11,7 @@ const FONT_STYLE_SUFFIX_RE =
|
|
|
9
11
|
/\s+(thin|extralight|extra light|light|regular|roman|medium|semibold|semi bold|bold|extrabold|extra bold|black|italic|oblique|variable)$/i;
|
|
10
12
|
|
|
11
13
|
function cssString(value: string): string {
|
|
12
|
-
return JSON.stringify(value);
|
|
14
|
+
return JSON.stringify(value).replace(/</g, "\\3c ");
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export function fontFamilyFromAssetPath(path: string): string {
|
|
@@ -28,5 +30,12 @@ export function fontFamilyFromAssetPath(path: string): string {
|
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export function importedFontFaceCss(asset: ImportedFontAsset, url: string = asset.url): string {
|
|
31
|
-
|
|
33
|
+
const weight =
|
|
34
|
+
asset.weight && /^(?:[1-9]\d{0,2}|1000)(?: (?:[1-9]\d{0,2}|1000))?$/.test(asset.weight)
|
|
35
|
+
? ` font-weight: ${asset.weight};`
|
|
36
|
+
: "";
|
|
37
|
+
const style = asset.style
|
|
38
|
+
? ` font-style: ${asset.style === "italic" ? "italic" : "normal"};`
|
|
39
|
+
: "";
|
|
40
|
+
return `@font-face { font-family: ${cssString(asset.family)}; src: url(${cssString(url)});${weight}${style} font-display: swap; }`;
|
|
32
41
|
}
|
|
@@ -2,13 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { FontFamilyField } from "./propertyPanelFont";
|
|
7
|
+
import { sortFontOptions } from "./propertyPanelHelpers";
|
|
8
|
+
|
|
9
|
+
vi.mock("./propertyPanelHelpers", async (importOriginal) => {
|
|
10
|
+
const actual = await importOriginal<typeof import("./propertyPanelHelpers")>();
|
|
11
|
+
return { ...actual, sortFontOptions: vi.fn(actual.sortFontOptions) };
|
|
12
|
+
});
|
|
7
13
|
|
|
8
14
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
15
|
|
|
16
|
+
// The font APIs are stubbed and every family is off the Google lists, so no test reaches the network.
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.stubGlobal(
|
|
19
|
+
"fetch",
|
|
20
|
+
vi.fn(async (url: string) => ({
|
|
21
|
+
ok: true,
|
|
22
|
+
json: async () => ({ fonts: url.includes("google") ? ["Roboto Slab"] : ["Arial"] }),
|
|
23
|
+
})),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
10
27
|
afterEach(() => {
|
|
11
28
|
document.body.innerHTML = "";
|
|
29
|
+
document.head.innerHTML = "";
|
|
30
|
+
vi.unstubAllGlobals();
|
|
12
31
|
});
|
|
13
32
|
|
|
14
33
|
describe("FontFamilyField flat trigger", () => {
|
|
@@ -17,14 +36,38 @@ describe("FontFamilyField flat trigger", () => {
|
|
|
17
36
|
document.body.append(host);
|
|
18
37
|
const root = createRoot(host);
|
|
19
38
|
act(() => {
|
|
20
|
-
root.render(
|
|
21
|
-
<FontFamilyField flat value="JetBrains Mono" importedFonts={[]} onCommit={vi.fn()} />,
|
|
22
|
-
);
|
|
39
|
+
root.render(<FontFamilyField flat value="Georgia" importedFonts={[]} onCommit={vi.fn()} />);
|
|
23
40
|
});
|
|
24
41
|
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
|
|
25
42
|
expect(trigger).not.toBeNull();
|
|
26
43
|
expect(trigger?.className).not.toContain("border-neutral-800");
|
|
27
|
-
expect(host.textContent).toContain("
|
|
44
|
+
expect(host.textContent).toContain("Georgia");
|
|
28
45
|
act(() => root.unmount());
|
|
29
46
|
});
|
|
30
47
|
});
|
|
48
|
+
|
|
49
|
+
describe("FontFamilyField font list", () => {
|
|
50
|
+
it("builds the list only while the dropdown is open", async () => {
|
|
51
|
+
vi.mocked(sortFontOptions).mockClear();
|
|
52
|
+
const host = document.createElement("div");
|
|
53
|
+
document.body.append(host);
|
|
54
|
+
const root = createRoot(host);
|
|
55
|
+
const render = (value: string) =>
|
|
56
|
+
root.render(<FontFamilyField flat value={value} importedFonts={[]} onCommit={vi.fn()} />);
|
|
57
|
+
try {
|
|
58
|
+
await act(async () => render("Arial"));
|
|
59
|
+
// The font lists arrive and the value changes while the dropdown is closed, as during a drag.
|
|
60
|
+
await act(async () => render("Georgia"));
|
|
61
|
+
expect(sortFontOptions).not.toHaveBeenCalled();
|
|
62
|
+
|
|
63
|
+
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
|
|
64
|
+
await act(async () => trigger?.click());
|
|
65
|
+
expect(sortFontOptions).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(host.textContent).toContain("Roboto Slab");
|
|
67
|
+
expect(document.head.querySelector('link[href*="fonts.googleapis.com"]')).toBeNull();
|
|
68
|
+
} finally {
|
|
69
|
+
act(() => root.unmount());
|
|
70
|
+
host.remove();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -276,6 +276,7 @@ export function FontFamilyField({
|
|
|
276
276
|
);
|
|
277
277
|
|
|
278
278
|
const options = useMemo(() => {
|
|
279
|
+
if (!open) return [];
|
|
279
280
|
const documentFonts = collectDocumentFontFamilies();
|
|
280
281
|
const googleSet = new Set(googleFonts.map((f) => f.toLowerCase()));
|
|
281
282
|
const taggedLocal = localFonts.map(
|
|
@@ -294,7 +295,7 @@ export function FontFamilyField({
|
|
|
294
295
|
...DEFAULT_FONT_FAMILIES.map((f): FontOption => ({ family: f, source: "System" })),
|
|
295
296
|
]),
|
|
296
297
|
);
|
|
297
|
-
}, [currentFamily, googleFonts, localFonts, projectFontAssets]);
|
|
298
|
+
}, [open, currentFamily, googleFonts, localFonts, projectFontAssets]);
|
|
298
299
|
|
|
299
300
|
const filteredOptions = useMemo(() => {
|
|
300
301
|
const matches = options.filter((o) => fontMatchesQuery(o.family, query));
|
|
@@ -193,7 +193,6 @@ describe("a shift press on a selected box starts the drag", () => {
|
|
|
193
193
|
selectionKey="box"
|
|
194
194
|
groupSelectionCount={0}
|
|
195
195
|
gestures={spies as never}
|
|
196
|
-
onBoxMouseDown={vi.fn()}
|
|
197
196
|
onBoxClick={vi.fn()}
|
|
198
197
|
/>
|
|
199
198
|
));
|
|
@@ -207,7 +206,6 @@ describe("a shift press on a selected box starts the drag", () => {
|
|
|
207
206
|
allowBodyDrag
|
|
208
207
|
groupCanMove={groupCanMove}
|
|
209
208
|
gestures={spies as never}
|
|
210
|
-
onBoxMouseDown={vi.fn()}
|
|
211
209
|
onBoxClick={vi.fn()}
|
|
212
210
|
/>
|
|
213
211
|
));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { Timeline } from "../../player";
|
|
3
3
|
import type { TimelineElement, TimelineTimeRange } from "../../player";
|
|
4
|
+
import type { TimelineProps } from "../../player/components/TimelineTypes";
|
|
4
5
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
5
6
|
import { AudioMeterStrip } from "./AudioMeterStrip";
|
|
6
7
|
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
|
@@ -45,6 +46,7 @@ export interface TimelinePaneProps {
|
|
|
45
46
|
onPasteClip?: () => Promise<void>;
|
|
46
47
|
onDuplicateClip?: () => Promise<boolean>;
|
|
47
48
|
canPasteClip?: () => boolean;
|
|
49
|
+
clipMenuItems?: TimelineProps["clipMenuItems"];
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
export function TimelinePane({
|
|
@@ -64,6 +66,7 @@ export function TimelinePane({
|
|
|
64
66
|
onPasteClip,
|
|
65
67
|
onDuplicateClip,
|
|
66
68
|
canPasteClip,
|
|
69
|
+
clipMenuItems,
|
|
67
70
|
}: TimelinePaneProps) {
|
|
68
71
|
const {
|
|
69
72
|
seek,
|
|
@@ -119,6 +122,7 @@ export function TimelinePane({
|
|
|
119
122
|
onPasteClip={onPasteClip}
|
|
120
123
|
onDuplicateClip={onDuplicateClip}
|
|
121
124
|
canPasteClip={canPasteClip}
|
|
125
|
+
clipMenuItems={clipMenuItems}
|
|
122
126
|
/>
|
|
123
127
|
</div>
|
|
124
128
|
<AudioMeterStrip />
|
|
@@ -298,6 +298,27 @@ describe("Popover", () => {
|
|
|
298
298
|
expect(document.activeElement).toBe(input);
|
|
299
299
|
expect(document.querySelector('[role="menu"]')).toBeNull();
|
|
300
300
|
});
|
|
301
|
+
|
|
302
|
+
it("points at its trigger only when asked", async () => {
|
|
303
|
+
const renderOpen = (arrow: boolean) => {
|
|
304
|
+
render(
|
|
305
|
+
<Popover trigger={<button data-testid="trigger">Speed</button>} arrow={arrow} defaultOpen>
|
|
306
|
+
<span>1x</span>
|
|
307
|
+
</Popover>,
|
|
308
|
+
);
|
|
309
|
+
};
|
|
310
|
+
renderOpen(false);
|
|
311
|
+
await settle();
|
|
312
|
+
expect(one("[role=dialog]", "popup").querySelector("svg")).toBeNull();
|
|
313
|
+
act(() => mounted?.root.unmount());
|
|
314
|
+
mounted?.host.remove();
|
|
315
|
+
mounted = null;
|
|
316
|
+
|
|
317
|
+
renderOpen(true);
|
|
318
|
+
await settle();
|
|
319
|
+
const arrow = one("[role=dialog] svg", "arrow").parentElement!;
|
|
320
|
+
expect(arrow.getAttribute("data-side")).toBe("bottom");
|
|
321
|
+
});
|
|
301
322
|
});
|
|
302
323
|
|
|
303
324
|
describe("hotkey classification", () => {
|
|
@@ -14,6 +14,15 @@ type PortalContainer = ComponentPropsWithoutRef<typeof BasePopover.Portal>["cont
|
|
|
14
14
|
const SIDE_OFFSET = 6;
|
|
15
15
|
const VIEWPORT_MARGIN = 8;
|
|
16
16
|
|
|
17
|
+
/** The arrow's 14x8 box overlaps the popup's border by 1px, so its fill hides the border where it meets the popup. */
|
|
18
|
+
const ARROW_PX = 7;
|
|
19
|
+
const ARROW_CORNER_CLEARANCE = 10;
|
|
20
|
+
const arrowPlace = cn(
|
|
21
|
+
"data-[side=bottom]:-top-[7px] data-[side=top]:-bottom-[7px] data-[side=top]:rotate-180",
|
|
22
|
+
"data-[side=left]:-right-[10px] data-[side=left]:rotate-90",
|
|
23
|
+
"data-[side=right]:-left-[10px] data-[side=right]:-rotate-90",
|
|
24
|
+
);
|
|
25
|
+
|
|
17
26
|
interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof BasePopover.Root>, "children"> {
|
|
18
27
|
/** A single element. It becomes the trigger; no wrapper is added around it. */
|
|
19
28
|
trigger: ReactElement;
|
|
@@ -22,6 +31,8 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof BasePopover.
|
|
|
22
31
|
side?: "top" | "bottom" | "left" | "right";
|
|
23
32
|
align?: "start" | "center" | "end";
|
|
24
33
|
sideOffset?: number;
|
|
34
|
+
/** Points the popup at its trigger. Off by default; the default gap from the trigger grows by the arrow's height. */
|
|
35
|
+
arrow?: boolean;
|
|
25
36
|
/** Portal target. Pass the shadow root when the trigger lives in one. */
|
|
26
37
|
container?: PortalContainer;
|
|
27
38
|
/** Names the popup for assistive tech. */
|
|
@@ -40,7 +51,8 @@ export function Popover({
|
|
|
40
51
|
children,
|
|
41
52
|
side = "bottom",
|
|
42
53
|
align = "center",
|
|
43
|
-
|
|
54
|
+
arrow = false,
|
|
55
|
+
sideOffset = arrow ? SIDE_OFFSET + ARROW_PX : SIDE_OFFSET,
|
|
44
56
|
container,
|
|
45
57
|
className,
|
|
46
58
|
initialFocus,
|
|
@@ -57,6 +69,7 @@ export function Popover({
|
|
|
57
69
|
align={align}
|
|
58
70
|
sideOffset={sideOffset}
|
|
59
71
|
collisionPadding={VIEWPORT_MARGIN}
|
|
72
|
+
arrowPadding={ARROW_CORNER_CLEARANCE}
|
|
60
73
|
className="z-200"
|
|
61
74
|
>
|
|
62
75
|
<BasePopover.Popup
|
|
@@ -65,6 +78,13 @@ export function Popover({
|
|
|
65
78
|
data-preview-state={previewState}
|
|
66
79
|
className={cn(popupSurface, "p-3 text-step-11 text-text-1 shadow-popover", className)}
|
|
67
80
|
>
|
|
81
|
+
{arrow && (
|
|
82
|
+
<BasePopover.Arrow className={arrowPlace}>
|
|
83
|
+
<svg width="14" height="8" viewBox="0 0 14 8" aria-hidden="true" className="block">
|
|
84
|
+
<path d="M0 8 L7 1 L14 8" className="fill-surface stroke-border-input" />
|
|
85
|
+
</svg>
|
|
86
|
+
</BasePopover.Arrow>
|
|
87
|
+
)}
|
|
68
88
|
{children}
|
|
69
89
|
</BasePopover.Popup>
|
|
70
90
|
</BasePopover.Positioner>
|
|
@@ -28,6 +28,7 @@ export function announceTimelineSelection(
|
|
|
28
28
|
deps: TimelineMirrorDeps,
|
|
29
29
|
group: DomEditSelection[],
|
|
30
30
|
primary: DomEditSelection | null,
|
|
31
|
+
replaceSet = false,
|
|
31
32
|
): void {
|
|
32
33
|
const {
|
|
33
34
|
timelineElements,
|
|
@@ -72,10 +73,10 @@ export function announceTimelineSelection(
|
|
|
72
73
|
}
|
|
73
74
|
return;
|
|
74
75
|
}
|
|
75
|
-
// A late async primary
|
|
76
|
-
// group
|
|
77
|
-
// first; otherwise `preserveSet` clears
|
|
78
|
-
if (group.length > 1 || !getTimelineSelectionSet().has(timelineAnchor)) {
|
|
76
|
+
// A late async primary inside the live set keeps the group. A click outside it, or a
|
|
77
|
+
// group the caller states in full (`replaceSet`: shift toggle, marquee, timeline sync), is published
|
|
78
|
+
// first; otherwise `preserveSet` clears or keeps a stale set and sync undoes the canvas.
|
|
79
|
+
if (replaceSet || group.length > 1 || !getTimelineSelectionSet().has(timelineAnchor)) {
|
|
79
80
|
setTimelineSelectionSet(publishedMembers);
|
|
80
81
|
}
|
|
81
82
|
setSelectedTimelineElementId(timelineAnchor, { preserveSet: true });
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { expect, it } from "vitest";
|
|
3
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
4
|
+
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
5
|
+
import { commitDomStyles, type DomStyleCommitContext } from "./domStyleCommit";
|
|
6
|
+
|
|
7
|
+
const selection = (id: string): DomEditSelection =>
|
|
8
|
+
({
|
|
9
|
+
id,
|
|
10
|
+
selector: `#${id}`,
|
|
11
|
+
label: id,
|
|
12
|
+
tagName: "p",
|
|
13
|
+
sourceFile: "index.html",
|
|
14
|
+
compositionPath: "index.html",
|
|
15
|
+
textFields: [],
|
|
16
|
+
inlineStyles: {},
|
|
17
|
+
computedStyles: {},
|
|
18
|
+
dataAttributes: {},
|
|
19
|
+
capabilities: { canSelect: true, canEditStyles: true },
|
|
20
|
+
}) as unknown as DomEditSelection;
|
|
21
|
+
|
|
22
|
+
const poppins = (file: string, weight: string): ImportedFontAsset => ({
|
|
23
|
+
family: "Poppins",
|
|
24
|
+
path: `assets/fonts/Poppins/${file}`,
|
|
25
|
+
url: `/preview/assets/fonts/Poppins/${file}`,
|
|
26
|
+
weight,
|
|
27
|
+
style: "normal",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("each font a host hands over is saved as its own face, with the weight and style it draws", async () => {
|
|
31
|
+
let html = '<html><head></head><body><h1 id="title"></h1><p id="subtitle"></p></body></html>';
|
|
32
|
+
let listed: ImportedFontAsset | null = null;
|
|
33
|
+
const context: DomStyleCommitContext = {
|
|
34
|
+
activeCompPath: "index.html",
|
|
35
|
+
previewIframeRef: { current: null },
|
|
36
|
+
showToast: () => undefined,
|
|
37
|
+
versions: new Map(),
|
|
38
|
+
resolveImportedFontAsset: () => listed,
|
|
39
|
+
persistDomEditOperations: (async (
|
|
40
|
+
_selection: DomEditSelection,
|
|
41
|
+
_operations: unknown,
|
|
42
|
+
options?: { prepareContent?: (html: string, sourceFile: string) => string },
|
|
43
|
+
) => {
|
|
44
|
+
html = options?.prepareContent?.(html, "index.html") ?? html;
|
|
45
|
+
}) as unknown as DomStyleCommitContext["persistDomEditOperations"],
|
|
46
|
+
};
|
|
47
|
+
listed = poppins("Poppins-SemiBold.ttf", "600");
|
|
48
|
+
await commitDomStyles(context, selection("title"), { "font-family": "Poppins" });
|
|
49
|
+
listed = poppins("Poppins-Regular.ttf", "400");
|
|
50
|
+
await commitDomStyles(context, selection("subtitle"), { "font-family": "Poppins" });
|
|
51
|
+
const faces = html.match(/@font-face[^}]*}/g) ?? [];
|
|
52
|
+
expect(faces).toHaveLength(2);
|
|
53
|
+
expect(faces[0]).toContain('Poppins-SemiBold.ttf"); font-weight: 600; font-style: normal;');
|
|
54
|
+
expect(faces[1]).toContain('Poppins-Regular.ttf"); font-weight: 400; font-style: normal;');
|
|
55
|
+
});
|
|
@@ -65,13 +65,14 @@ describe("dispatchPlainKey — select leftward / rightward", () => {
|
|
|
65
65
|
{ ...bgmElement, id: "early", key: "early", start: 0, track: 0 },
|
|
66
66
|
{ ...bgmElement, id: "at", key: "at", start: 4, track: 1 },
|
|
67
67
|
{ ...bgmElement, id: "late", key: "late", start: 7, track: 2 },
|
|
68
|
+
{ ...bgmElement, id: "gone", key: "gone", start: 0, duration: 2, track: 3 },
|
|
68
69
|
];
|
|
69
70
|
beforeEach(() => usePlayerStore.setState({ elements: clips, currentTime: 4 }));
|
|
70
71
|
|
|
71
|
-
it("[ selects every clip
|
|
72
|
+
it("[ selects every clip that started before the playhead, crossing clips included", () => {
|
|
72
73
|
const event = press("[");
|
|
73
74
|
dispatchPlainKey(event, "[", callbacks());
|
|
74
|
-
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["early"]);
|
|
75
|
+
expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual(["early", "gone"]);
|
|
75
76
|
expect(usePlayerStore.getState().selectedElementId).toBe("early");
|
|
76
77
|
expect(event.defaultPrevented).toBe(true);
|
|
77
78
|
});
|
|
@@ -84,6 +85,7 @@ describe("dispatchPlainKey — select leftward / rightward", () => {
|
|
|
84
85
|
expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual([
|
|
85
86
|
"at",
|
|
86
87
|
"early",
|
|
88
|
+
"gone",
|
|
87
89
|
"late",
|
|
88
90
|
]);
|
|
89
91
|
} finally {
|
|
@@ -98,11 +100,11 @@ describe("dispatchPlainKey — select leftward / rightward", () => {
|
|
|
98
100
|
expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
|
|
99
101
|
});
|
|
100
102
|
|
|
101
|
-
it("] selects every clip
|
|
103
|
+
it("] selects every clip still running at or after the playhead, crossing clips included", () => {
|
|
102
104
|
dispatchPlainKey(press("]"), "]", callbacks());
|
|
103
105
|
const { selectedElementIds, selectedElementId } = usePlayerStore.getState();
|
|
104
|
-
expect([...selectedElementIds].sort()).toEqual(["at", "late"]);
|
|
105
|
-
expect(selectedElementId).toBe("
|
|
106
|
+
expect([...selectedElementIds].sort()).toEqual(["at", "early", "late"]);
|
|
107
|
+
expect(selectedElementId).toBe("early");
|
|
106
108
|
});
|
|
107
109
|
|
|
108
110
|
it("selects nothing when no clip is on that side", () => {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
deduplicateIds,
|
|
12
12
|
insertAsSibling,
|
|
13
13
|
} from "../utils/clipboardPayload";
|
|
14
|
+
import { carryLook, renamedIds } from "../utils/clipboardLook";
|
|
14
15
|
import { collectHtmlIds } from "../utils/studioHelpers";
|
|
15
16
|
import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
|
|
16
17
|
import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
@@ -165,15 +166,16 @@ export function pasteElementHtml(
|
|
|
165
166
|
return insertAsSibling(content, deduped, payload.originSelector, payload.originSelectorIndex);
|
|
166
167
|
}
|
|
167
168
|
|
|
168
|
-
/** Shared insertion path for paste and duplicate, anchored at the playhead or
|
|
169
|
-
* the
|
|
170
|
-
*
|
|
171
|
-
*
|
|
169
|
+
/** Shared insertion path for paste and duplicate, anchored at the playhead or the selection's end. Returns the
|
|
170
|
+
* final ids so the caller can select what it placed, and the furthest end any clip lands at so it can grow the
|
|
171
|
+
* root duration to cover it. `fromThisFile`: the clips came from `content`, so a renamed copy takes its
|
|
172
|
+
* original's look. */
|
|
172
173
|
export function pasteTimelineClips(
|
|
173
174
|
content: string,
|
|
174
175
|
clips: readonly TimelineClipboardClip[],
|
|
175
176
|
anchorTime: number,
|
|
176
177
|
liveElements: readonly TimelineElement[],
|
|
178
|
+
fromThisFile = false,
|
|
177
179
|
): { content: string; ids: string[]; requiredEnd: number } {
|
|
178
180
|
const groupMinStart = Math.min(...clips.map((c) => c.start));
|
|
179
181
|
let existingIds = collectHtmlIds(content);
|
|
@@ -209,6 +211,11 @@ export function pasteTimelineClips(
|
|
|
209
211
|
.replace(/data-track-index="[^"]*"/, `data-track-index="${newTrack}"`);
|
|
210
212
|
const withPatched = patchedRootTag + deduped.slice(rootTagEnd + 1);
|
|
211
213
|
result = insertTimelineAssetIntoSource(result, withPatched);
|
|
214
|
+
if (fromThisFile) {
|
|
215
|
+
const authored = Number(rootTag.match(/data-start="([^"]*)"/)?.[1]);
|
|
216
|
+
const authoredStart = Number.isFinite(authored) ? authored : clip.start;
|
|
217
|
+
result = carryLook(result, renamedIds(reminted, deduped), newStart - authoredStart);
|
|
218
|
+
}
|
|
212
219
|
|
|
213
220
|
const id = patchedRootTag.match(ID_ATTR_RE)?.[1];
|
|
214
221
|
if (id) ids.push(id);
|
|
@@ -294,7 +301,12 @@ export function useClipboard({
|
|
|
294
301
|
const sourceFile = targets.elements[0]?.sourceFile || activeCompPath || "index.html";
|
|
295
302
|
return readClips(targets).then((clips) => {
|
|
296
303
|
showToast(clips.length > 1 ? `Copied ${clips.length} clips` : "Copied clip", "info");
|
|
297
|
-
return {
|
|
304
|
+
return {
|
|
305
|
+
kind: "timeline-clip",
|
|
306
|
+
clips,
|
|
307
|
+
sourceFile,
|
|
308
|
+
projectId: projectIdRef.current ?? undefined,
|
|
309
|
+
};
|
|
298
310
|
});
|
|
299
311
|
}, [activeCompPath, findSelectedClips, readClips, showToast]);
|
|
300
312
|
|
|
@@ -364,7 +376,13 @@ export function useClipboard({
|
|
|
364
376
|
const paste = (originalContent: string) => {
|
|
365
377
|
if (payload.kind !== "timeline-clip") return pasteElementHtml(originalContent, payload);
|
|
366
378
|
const { currentTime, elements } = usePlayerStore.getState();
|
|
367
|
-
const pasted = pasteTimelineClips(
|
|
379
|
+
const pasted = pasteTimelineClips(
|
|
380
|
+
originalContent,
|
|
381
|
+
payload.clips,
|
|
382
|
+
currentTime,
|
|
383
|
+
elements,
|
|
384
|
+
payload.sourceFile === targetPath && payload.projectId === pid,
|
|
385
|
+
);
|
|
368
386
|
pastedIds = pasted.ids;
|
|
369
387
|
// A clip pasted past the current composition end would exist in the
|
|
370
388
|
// file but never appear on the timeline or in playback/export (the
|
|
@@ -433,7 +451,7 @@ export function useClipboard({
|
|
|
433
451
|
.getState()
|
|
434
452
|
.elements.filter((el) => pathOf(el) === targetPath)
|
|
435
453
|
.map((el) => ({ ...el, start: toAuthoredStart(el, el.start) }));
|
|
436
|
-
const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
|
|
454
|
+
const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements, true);
|
|
437
455
|
ids = pasted.ids;
|
|
438
456
|
return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
|
|
439
457
|
};
|
|
@@ -5,10 +5,8 @@ import { dragEditOutcome, preflightGsapRotationIntercept } from "./gsapRuntimeBr
|
|
|
5
5
|
import { preflightGsapResizeIntercept } from "./gsapResizePreflight";
|
|
6
6
|
import { GSAP_EDIT_BLOCK_COPY, type GsapEditOutcome } from "./gsapEditOutcome";
|
|
7
7
|
import { fetchParsedAnimations, parseCacheKey } from "./keyframeCacheAstLoad";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
selectElementAnimationsOrRetry,
|
|
11
|
-
} from "./useGsapAnimationFetchFallback";
|
|
8
|
+
import { getAnimationsForElement } from "./gsapElementMatch";
|
|
9
|
+
import { gsapSourceFileForSelection } from "./useGsapAnimationFetchFallback";
|
|
12
10
|
|
|
13
11
|
interface CommitPreflight {
|
|
14
12
|
offset: GsapEditOutcome;
|
|
@@ -23,13 +21,7 @@ function runCommitPreflights(
|
|
|
23
21
|
group: boolean,
|
|
24
22
|
): CommitPreflight {
|
|
25
23
|
const target = { id: selection.id ?? null, selector: selection.selector ?? null };
|
|
26
|
-
const
|
|
27
|
-
{ animations: fileAnimations },
|
|
28
|
-
target,
|
|
29
|
-
selection.element,
|
|
30
|
-
);
|
|
31
|
-
// A file the server parsed with no tweens at all is a definitive answer here.
|
|
32
|
-
const animations = matched.kind === "resolved" ? matched.animations : [];
|
|
24
|
+
const animations = getAnimationsForElement(fileAnimations, target, selection.element);
|
|
33
25
|
return {
|
|
34
26
|
offset: dragEditOutcome(selection, animations, iframe, [], group),
|
|
35
27
|
size: preflightGsapResizeIntercept(selection, animations, iframe),
|
|
@@ -29,9 +29,13 @@ function renderHarness(
|
|
|
29
29
|
cleanup: () => void;
|
|
30
30
|
timeline: TimelineSpies;
|
|
31
31
|
} {
|
|
32
|
+
// Reads back what was last published, as the timeline store does.
|
|
33
|
+
let publishedSet: ReadonlySet<string> = new Set();
|
|
32
34
|
const timeline: TimelineSpies = {
|
|
33
35
|
setSelectedTimelineElementId: vi.fn(),
|
|
34
|
-
setTimelineSelectionSet: vi.fn()
|
|
36
|
+
setTimelineSelectionSet: vi.fn((ids: Set<string>) => {
|
|
37
|
+
publishedSet = ids;
|
|
38
|
+
}),
|
|
35
39
|
};
|
|
36
40
|
const host = document.createElement("div");
|
|
37
41
|
document.body.append(host);
|
|
@@ -47,7 +51,7 @@ function renderHarness(
|
|
|
47
51
|
captionEditMode: false,
|
|
48
52
|
previewIframeRef: { current: null },
|
|
49
53
|
timelineElements: options.timelineElements ?? [],
|
|
50
|
-
getTimelineSelectionSet: () =>
|
|
54
|
+
getTimelineSelectionSet: () => publishedSet,
|
|
51
55
|
setSelectedTimelineElementId: timeline.setSelectedTimelineElementId,
|
|
52
56
|
setTimelineSelectionSet: timeline.setTimelineSelectionSet,
|
|
53
57
|
setRightCollapsed: vi.fn(),
|
|
@@ -107,6 +111,17 @@ function timelineElement(domId: string): TimelineElement {
|
|
|
107
111
|
} as TimelineElement;
|
|
108
112
|
}
|
|
109
113
|
|
|
114
|
+
function renderCardAndChip() {
|
|
115
|
+
const card = makeSelection("Card", Object.assign(document.createElement("div"), { id: "card" }));
|
|
116
|
+
const chip = makeSelection("Chip", Object.assign(document.createElement("div"), { id: "chip" }));
|
|
117
|
+
document.body.append(card.element, chip.element);
|
|
118
|
+
const harness = renderHarness(
|
|
119
|
+
{ activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
|
|
120
|
+
{ timelineElements: [timelineElement("card"), timelineElement("chip")] },
|
|
121
|
+
);
|
|
122
|
+
return { harness, card, chip };
|
|
123
|
+
}
|
|
124
|
+
|
|
110
125
|
/**
|
|
111
126
|
* A marquee builds the group correctly and then used to lose it: it announced only
|
|
112
127
|
* the primary to the timeline, the timeline is the source of truth for what is
|
|
@@ -116,24 +131,9 @@ function timelineElement(domId: string): TimelineElement {
|
|
|
116
131
|
*/
|
|
117
132
|
describe("useDomSelection marquee", () => {
|
|
118
133
|
it("announces every marquee'd element to the timeline, anchored on the primary", () => {
|
|
119
|
-
const
|
|
120
|
-
first.id = "card";
|
|
121
|
-
const second = document.createElement("div");
|
|
122
|
-
second.id = "chip";
|
|
123
|
-
document.body.append(first, second);
|
|
124
|
-
const harness = renderHarness(
|
|
125
|
-
{ activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
|
|
126
|
-
{ timelineElements: [timelineElement("card"), timelineElement("chip")] },
|
|
127
|
-
);
|
|
134
|
+
const { harness, card, chip } = renderCardAndChip();
|
|
128
135
|
|
|
129
|
-
act(() =>
|
|
130
|
-
harness
|
|
131
|
-
.current()
|
|
132
|
-
.applyMarqueeSelection(
|
|
133
|
-
[makeSelection("Card", first), makeSelection("Chip", second)],
|
|
134
|
-
false,
|
|
135
|
-
),
|
|
136
|
-
);
|
|
136
|
+
act(() => harness.current().applyMarqueeSelection([card, chip], false));
|
|
137
137
|
|
|
138
138
|
expect(harness.current().domEditGroupSelections).toHaveLength(2);
|
|
139
139
|
expect(harness.timeline.setTimelineSelectionSet).toHaveBeenCalledWith(
|
|
@@ -181,20 +181,10 @@ describe("useDomSelection marquee", () => {
|
|
|
181
181
|
*/
|
|
182
182
|
describe("useDomSelection additive", () => {
|
|
183
183
|
it("announces both members when a second element joins the selection", () => {
|
|
184
|
-
const
|
|
185
|
-
first.id = "card";
|
|
186
|
-
const second = document.createElement("div");
|
|
187
|
-
second.id = "chip";
|
|
188
|
-
document.body.append(first, second);
|
|
189
|
-
const harness = renderHarness(
|
|
190
|
-
{ activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
|
|
191
|
-
{ timelineElements: [timelineElement("card"), timelineElement("chip")] },
|
|
192
|
-
);
|
|
184
|
+
const { harness, card, chip } = renderCardAndChip();
|
|
193
185
|
|
|
194
|
-
act(() => harness.current().applyDomSelection(
|
|
195
|
-
act(() =>
|
|
196
|
-
harness.current().applyDomSelection(makeSelection("Chip", second), { additive: true }),
|
|
197
|
-
);
|
|
186
|
+
act(() => harness.current().applyDomSelection(card));
|
|
187
|
+
act(() => harness.current().applyDomSelection(chip, { additive: true }));
|
|
198
188
|
|
|
199
189
|
expect(harness.current().domEditGroupSelections).toHaveLength(2);
|
|
200
190
|
expect(harness.timeline.setTimelineSelectionSet).toHaveBeenLastCalledWith(
|
|
@@ -205,6 +195,40 @@ describe("useDomSelection additive", () => {
|
|
|
205
195
|
});
|
|
206
196
|
harness.cleanup();
|
|
207
197
|
});
|
|
198
|
+
|
|
199
|
+
// The timeline syncs its set back onto the canvas, so a set left at two re-adds the member.
|
|
200
|
+
it.each([
|
|
201
|
+
["shift+click removes a member", "toggle"],
|
|
202
|
+
["a marquee catches one member of a live group", "marquee"],
|
|
203
|
+
] as const)("publishes the smaller set when %s", (_, shrink) => {
|
|
204
|
+
const { harness, card, chip } = renderCardAndChip();
|
|
205
|
+
|
|
206
|
+
act(() => harness.current().applyMarqueeSelection([card, chip], false));
|
|
207
|
+
act(() =>
|
|
208
|
+
shrink === "toggle"
|
|
209
|
+
? harness.current().applyDomSelection(chip, { additive: true })
|
|
210
|
+
: harness.current().applyMarqueeSelection([card], false),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(harness.current().domEditGroupSelections).toHaveLength(1);
|
|
214
|
+
expect(harness.timeline.setTimelineSelectionSet).toHaveBeenLastCalledWith(new Set(["card"]));
|
|
215
|
+
expect(harness.timeline.setSelectedTimelineElementId).toHaveBeenLastCalledWith("card", {
|
|
216
|
+
preserveSet: true,
|
|
217
|
+
});
|
|
218
|
+
harness.cleanup();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("keeps the live set when a single member is re-announced", () => {
|
|
222
|
+
const { harness, card, chip } = renderCardAndChip();
|
|
223
|
+
|
|
224
|
+
act(() => harness.current().applyMarqueeSelection([card, chip], false));
|
|
225
|
+
act(() => harness.current().applyDomSelection(chip));
|
|
226
|
+
|
|
227
|
+
expect(harness.timeline.setTimelineSelectionSet).toHaveBeenLastCalledWith(
|
|
228
|
+
new Set(["card", "chip"]),
|
|
229
|
+
);
|
|
230
|
+
harness.cleanup();
|
|
231
|
+
});
|
|
208
232
|
});
|
|
209
233
|
|
|
210
234
|
describe("useDomSelection", () => {
|
|
@@ -83,7 +83,7 @@ export function useDomSelection({
|
|
|
83
83
|
// ── Callbacks ──
|
|
84
84
|
|
|
85
85
|
const announceTimelineSelection = useCallback(
|
|
86
|
-
(group: DomEditSelection[], primary: DomEditSelection | null) =>
|
|
86
|
+
(group: DomEditSelection[], primary: DomEditSelection | null, replaceSet?: boolean) =>
|
|
87
87
|
announceSelectionToTimeline(
|
|
88
88
|
{
|
|
89
89
|
timelineElements,
|
|
@@ -93,6 +93,7 @@ export function useDomSelection({
|
|
|
93
93
|
},
|
|
94
94
|
group,
|
|
95
95
|
primary,
|
|
96
|
+
replaceSet,
|
|
96
97
|
),
|
|
97
98
|
[
|
|
98
99
|
getTimelineSelectionSet,
|
|
@@ -179,7 +180,7 @@ export function useDomSelection({
|
|
|
179
180
|
setRightPanelTab("design");
|
|
180
181
|
}
|
|
181
182
|
}
|
|
182
|
-
announceTimelineSelection(nextGroup, nextSelection);
|
|
183
|
+
announceTimelineSelection(nextGroup, nextSelection, isAdditiveSelection);
|
|
183
184
|
return;
|
|
184
185
|
}
|
|
185
186
|
|
|
@@ -510,7 +511,7 @@ export function useDomSelection({
|
|
|
510
511
|
domEditGroupSelectionsRef.current = nextGroup;
|
|
511
512
|
setDomEditSelection(nextSelection);
|
|
512
513
|
setDomEditGroupSelections(nextGroup);
|
|
513
|
-
announceTimelineSelection(nextGroup, nextSelection);
|
|
514
|
+
announceTimelineSelection(nextGroup, nextSelection, true);
|
|
514
515
|
},
|
|
515
516
|
[applyDomSelection, announceTimelineSelection],
|
|
516
517
|
);
|