@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
package/dist/ui.d.ts
CHANGED
|
@@ -181,6 +181,8 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof Popover$1.Ro
|
|
|
181
181
|
side?: "top" | "bottom" | "left" | "right";
|
|
182
182
|
align?: "start" | "center" | "end";
|
|
183
183
|
sideOffset?: number;
|
|
184
|
+
/** Points the popup at its trigger. Off by default; the default gap from the trigger grows by the arrow's height. */
|
|
185
|
+
arrow?: boolean;
|
|
184
186
|
/** Portal target. Pass the shadow root when the trigger lives in one. */
|
|
185
187
|
container?: PortalContainer;
|
|
186
188
|
/** Names the popup for assistive tech. */
|
|
@@ -193,7 +195,7 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof Popover$1.Ro
|
|
|
193
195
|
className?: string;
|
|
194
196
|
"data-preview-state"?: PopupPreviewState;
|
|
195
197
|
}
|
|
196
|
-
declare function Popover({ trigger, children, side, align, sideOffset, container, className, initialFocus, "aria-label": ariaLabel, "data-preview-state": previewState, ...root }: PopoverProps): react_jsx_runtime.JSX.Element;
|
|
198
|
+
declare function Popover({ trigger, children, side, align, arrow, sideOffset, container, className, initialFocus, "aria-label": ariaLabel, "data-preview-state": previewState, ...root }: PopoverProps): react_jsx_runtime.JSX.Element;
|
|
197
199
|
|
|
198
200
|
/**
|
|
199
201
|
* Shared field look (also used by NumberField and Select). Each interactive state is written
|
package/dist/ui.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.97",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/core": "0.8.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.97",
|
|
64
|
+
"@hyperframes/parsers": "0.8.97",
|
|
65
|
+
"@hyperframes/player": "0.8.97",
|
|
66
|
+
"@hyperframes/shader-transitions": "0.8.97",
|
|
67
|
+
"@hyperframes/sdk": "0.8.97",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.97"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.97"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -23,7 +23,7 @@ interface ToolRow {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** One button for the active timeline tool; its menu switches tools or selects clips around the playhead. */
|
|
26
|
-
export function TimelineToolPicker() {
|
|
26
|
+
export function TimelineToolPicker({ showSelectAroundPlayhead = true }) {
|
|
27
27
|
const activeTool = usePlayerStore((s) => s.activeTool);
|
|
28
28
|
const store = () => usePlayerStore.getState();
|
|
29
29
|
const rows: ToolRow[] = [
|
|
@@ -41,21 +41,25 @@ export function TimelineToolPicker() {
|
|
|
41
41
|
active: activeTool === "razor",
|
|
42
42
|
run: () => store().setActiveTool("razor"),
|
|
43
43
|
},
|
|
44
|
-
{
|
|
45
|
-
label: "Select leftward",
|
|
46
|
-
shortcut: "[",
|
|
47
|
-
icon: <ArrowLineLeft size={16} aria-hidden="true" />,
|
|
48
|
-
active: false,
|
|
49
|
-
run: () => store().selectLeftward(),
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
label: "Select rightward",
|
|
53
|
-
shortcut: "]",
|
|
54
|
-
icon: <ArrowLineRight size={16} aria-hidden="true" />,
|
|
55
|
-
active: false,
|
|
56
|
-
run: () => store().selectRightward(),
|
|
57
|
-
},
|
|
58
44
|
];
|
|
45
|
+
if (showSelectAroundPlayhead) {
|
|
46
|
+
rows.push(
|
|
47
|
+
{
|
|
48
|
+
label: "Select leftward",
|
|
49
|
+
shortcut: "[",
|
|
50
|
+
icon: <ArrowLineLeft size={16} aria-hidden="true" />,
|
|
51
|
+
active: false,
|
|
52
|
+
run: () => store().selectLeftward(),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
label: "Select rightward",
|
|
56
|
+
shortcut: "]",
|
|
57
|
+
icon: <ArrowLineRight size={16} aria-hidden="true" />,
|
|
58
|
+
active: false,
|
|
59
|
+
run: () => store().selectRightward(),
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
}
|
|
59
63
|
const current = rows.find((row) => row.active) ?? rows[0];
|
|
60
64
|
return (
|
|
61
65
|
<Menu
|
|
@@ -8,6 +8,7 @@ import { usePlayerStore } from "../player/store/playerStore";
|
|
|
8
8
|
import { makeSelection } from "../hooks/domSelectionTestHarness";
|
|
9
9
|
import { useAudioMetersVisible } from "../utils/audioMeterVisibility";
|
|
10
10
|
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
11
|
+
import { dispatchPlainKey, type HotkeyCallbacks } from "../hooks/appHotkeysDispatch";
|
|
11
12
|
import { AudioMeterStrip } from "./nle/AudioMeterStrip";
|
|
12
13
|
import { TimelineToolbar } from "./TimelineToolbar";
|
|
13
14
|
|
|
@@ -295,6 +296,67 @@ describe("TimelineToolbar history", () => {
|
|
|
295
296
|
});
|
|
296
297
|
});
|
|
297
298
|
|
|
299
|
+
describe("TimelineToolbar tool menu", () => {
|
|
300
|
+
async function openMenuRows(props: Partial<React.ComponentProps<typeof TimelineToolbar>>) {
|
|
301
|
+
const { host, root } = renderToolbar(undefined, props);
|
|
302
|
+
const trigger = host.querySelector<HTMLButtonElement>('button[aria-label^="Timeline tool:"]')!;
|
|
303
|
+
await act(async () => {
|
|
304
|
+
trigger.click();
|
|
305
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
306
|
+
});
|
|
307
|
+
const rows = [...document.querySelectorAll('[role="menuitem"] > span:first-child')].map(
|
|
308
|
+
(label) => label.textContent,
|
|
309
|
+
);
|
|
310
|
+
act(() => root.unmount());
|
|
311
|
+
return rows;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
it("lists Select, Split, Select leftward and Select rightward by default", async () => {
|
|
315
|
+
expect(await openMenuRows({})).toEqual([
|
|
316
|
+
"Select",
|
|
317
|
+
"Split",
|
|
318
|
+
"Select leftward",
|
|
319
|
+
"Select rightward",
|
|
320
|
+
]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("lists only Select and Split when the host hides select around the playhead", async () => {
|
|
324
|
+
expect(await openMenuRows({ showSelectAroundPlayhead: false })).toEqual(["Select", "Split"]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it.each([true, false])("[ and ] select around the playhead with the rows shown: %s", (shown) => {
|
|
328
|
+
usePlayerStore.setState({
|
|
329
|
+
currentTime: 4,
|
|
330
|
+
elements: [0, 4, 7].map((start, track) => ({
|
|
331
|
+
id: `c${start}`,
|
|
332
|
+
key: `c${start}`,
|
|
333
|
+
tag: "div",
|
|
334
|
+
start,
|
|
335
|
+
duration: 2,
|
|
336
|
+
track,
|
|
337
|
+
})),
|
|
338
|
+
});
|
|
339
|
+
const { root } = renderToolbar(undefined, { showSelectAroundPlayhead: shown });
|
|
340
|
+
const press = (key: string) =>
|
|
341
|
+
act(() =>
|
|
342
|
+
dispatchPlainKey(new KeyboardEvent("keydown", { key }), key, {} as HotkeyCallbacks),
|
|
343
|
+
);
|
|
344
|
+
try {
|
|
345
|
+
press("[");
|
|
346
|
+
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["c0"]);
|
|
347
|
+
press("]");
|
|
348
|
+
expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual(["c4", "c7"]);
|
|
349
|
+
} finally {
|
|
350
|
+
act(() => root.unmount());
|
|
351
|
+
usePlayerStore.setState({
|
|
352
|
+
elements: [],
|
|
353
|
+
selectedElementId: null,
|
|
354
|
+
selectedElementIds: new Set(),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
298
360
|
describe("TimelineToolbar add beat", () => {
|
|
299
361
|
it("shows Add beat by default, as Studio does", () => {
|
|
300
362
|
const { host, root } = renderToolbar();
|
|
@@ -44,6 +44,7 @@ export interface TimelineToolbarProps {
|
|
|
44
44
|
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
|
|
45
45
|
history?: TimelineHistoryButtonsProps;
|
|
46
46
|
showHistory?: boolean;
|
|
47
|
+
showSelectAroundPlayhead?: boolean;
|
|
47
48
|
showAddBeat?: boolean;
|
|
48
49
|
/** Hides Add keyframe and auto-record, and turns off auto-record and the K shortcut with them. */
|
|
49
50
|
showKeyframes?: boolean;
|
|
@@ -143,6 +144,7 @@ export function TimelineToolbar({
|
|
|
143
144
|
onSplitElement,
|
|
144
145
|
history,
|
|
145
146
|
showHistory = true,
|
|
147
|
+
showSelectAroundPlayhead = true,
|
|
146
148
|
showAddBeat = true,
|
|
147
149
|
showKeyframes = true,
|
|
148
150
|
}: TimelineToolbarProps) {
|
|
@@ -216,7 +218,7 @@ export function TimelineToolbar({
|
|
|
216
218
|
<div className="flex items-center justify-between px-2 py-0.5">
|
|
217
219
|
<div className="flex items-center gap-0.5">
|
|
218
220
|
{showHistory && <TimelineHistoryButtons {...history} />}
|
|
219
|
-
<TimelineToolPicker />
|
|
221
|
+
<TimelineToolPicker showSelectAroundPlayhead={showSelectAroundPlayhead} />
|
|
220
222
|
{/* Divider: tool-mode | editing-actions */}
|
|
221
223
|
<div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
|
|
222
224
|
<Tooltip label={timelineSnapEnabled ? "Snapping on (N)" : "Snapping off (N)"}>
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
resolveDomEditCapabilities,
|
|
18
18
|
resolveDomEditSelection,
|
|
19
19
|
} from "./domEditing";
|
|
20
|
-
import { normalizeTimelineCompositionSource } from "./domEditingDom";
|
|
20
|
+
import { buildElementLabel, normalizeTimelineCompositionSource } from "./domEditingDom";
|
|
21
21
|
|
|
22
22
|
function createDocument(markup: string): Document {
|
|
23
23
|
const window = new Window();
|
|
@@ -925,6 +925,14 @@ describe("resolveDomEditSelection", () => {
|
|
|
925
925
|
);
|
|
926
926
|
});
|
|
927
927
|
|
|
928
|
+
it("names a repeated section by its authored id, not its runtime instance id", () => {
|
|
929
|
+
const document = createDocument(`
|
|
930
|
+
<div data-composition-id="benefit-fresh__hf2" data-hf-original-composition-id="benefit-fresh"></div>
|
|
931
|
+
`);
|
|
932
|
+
|
|
933
|
+
expect(buildElementLabel(document.querySelector("div") as HTMLElement)).toBe("Benefit Fresh");
|
|
934
|
+
});
|
|
935
|
+
|
|
928
936
|
it("finds a composition host authored with a ./ path", () => {
|
|
929
937
|
const document = createDocument(`
|
|
930
938
|
<div data-composition-id="scene" data-composition-file="./compositions/scene.html"></div>
|
|
@@ -312,7 +312,8 @@ function getPreferredClassSelector(el: HTMLElement): string | undefined {
|
|
|
312
312
|
|
|
313
313
|
// fallow-ignore-next-line complexity
|
|
314
314
|
export function buildElementLabel(el: HTMLElement): string {
|
|
315
|
-
const compositionId =
|
|
315
|
+
const compositionId =
|
|
316
|
+
el.getAttribute("data-hf-original-composition-id") ?? el.getAttribute("data-composition-id");
|
|
316
317
|
if (compositionId && compositionId !== "main") {
|
|
317
318
|
return humanizeIdentifier(compositionId);
|
|
318
319
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { expect, it } from "vitest";
|
|
3
|
+
import { ensureImportedFontFace, injectPreviewImportedFont } from "../../utils/studioFontHelpers";
|
|
4
|
+
import { importedFontFaceCss, type ImportedFontAsset } from "./fontAssets";
|
|
5
|
+
|
|
6
|
+
const face = (file: string, extra: Partial<ImportedFontAsset> = {}): ImportedFontAsset => ({
|
|
7
|
+
family: "Poppins",
|
|
8
|
+
path: `assets/fonts/Poppins/${file}`,
|
|
9
|
+
url: `/preview/assets/fonts/Poppins/${file}`,
|
|
10
|
+
...extra,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("a face with no weight or style is written as before", () => {
|
|
14
|
+
expect(importedFontFaceCss(face("Poppins-Bold.ttf"))).toBe(
|
|
15
|
+
'@font-face { font-family: "Poppins"; src: url("/preview/assets/fonts/Poppins/Poppins-Bold.ttf"); font-display: swap; }',
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("a face says which weight and style it draws, a variable file its weight range", () => {
|
|
20
|
+
expect(
|
|
21
|
+
importedFontFaceCss(face("Poppins-BoldItalic.ttf", { weight: "700", style: "italic" })),
|
|
22
|
+
).toBe(
|
|
23
|
+
'@font-face { font-family: "Poppins"; src: url("/preview/assets/fonts/Poppins/Poppins-BoldItalic.ttf"); font-weight: 700; font-style: italic; font-display: swap; }',
|
|
24
|
+
);
|
|
25
|
+
expect(
|
|
26
|
+
importedFontFaceCss(face("Roboto-Variable.ttf", { weight: "100 900", style: "normal" })),
|
|
27
|
+
).toContain("font-weight: 100 900; font-style: normal;");
|
|
28
|
+
expect(importedFontFaceCss(face("x.ttf", { weight: "700; color: red" }))).not.toContain(
|
|
29
|
+
"font-weight",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("two weights of one family are both kept in the file and both drawn in the preview", () => {
|
|
34
|
+
const bold = face("Poppins-Bold.ttf", { weight: "700", style: "normal" });
|
|
35
|
+
const regular = face("Poppins-Regular.ttf", { weight: "400", style: "normal" });
|
|
36
|
+
const html = "<html><head></head><body></body></html>";
|
|
37
|
+
const saved = ensureImportedFontFace(
|
|
38
|
+
ensureImportedFontFace(html, bold, "index.html"),
|
|
39
|
+
regular,
|
|
40
|
+
"index.html",
|
|
41
|
+
);
|
|
42
|
+
expect(saved).toContain("font-weight: 700;");
|
|
43
|
+
expect(saved).toContain("font-weight: 400;");
|
|
44
|
+
expect(ensureImportedFontFace(saved, bold, "index.html")).toBe(saved);
|
|
45
|
+
injectPreviewImportedFont(document, bold);
|
|
46
|
+
injectPreviewImportedFont(document, regular);
|
|
47
|
+
injectPreviewImportedFont(document, bold);
|
|
48
|
+
expect(document.head.querySelectorAll("style")).toHaveLength(2);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("a weight the browser would drop is left out, and a family cannot close the style block", () => {
|
|
52
|
+
for (const weight of ["0", "5000", "bold", "700;}"])
|
|
53
|
+
expect(importedFontFaceCss(face("x.ttf", { weight }))).not.toContain("font-weight");
|
|
54
|
+
expect(importedFontFaceCss(face("x.ttf", { weight: "1000" }))).toContain("font-weight: 1000;");
|
|
55
|
+
expect(importedFontFaceCss({ ...face("x.ttf"), family: "</style><b>x" })).not.toContain(
|
|
56
|
+
"</style>",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("a file name with a replacement pattern is saved as written, not expanded into the page", () => {
|
|
61
|
+
const html = "<html><head></head><body>rest</body></html>";
|
|
62
|
+
const odd = { family: "Cash", path: "assets/Cash$'Font.ttf", url: "/Cash$'Font.ttf" };
|
|
63
|
+
const saved = ensureImportedFontFace(html, odd, "index.html");
|
|
64
|
+
expect(saved).toContain("Cash$'Font.ttf");
|
|
65
|
+
expect(saved.match(/<body>/g)).toHaveLength(1);
|
|
66
|
+
const again = ensureImportedFontFace(saved, { ...odd, path: "assets/Cash$&.ttf" }, "index.html");
|
|
67
|
+
expect(again).toContain("Cash$&.ttf");
|
|
68
|
+
expect(again.match(/<body>/g)).toHaveLength(1);
|
|
69
|
+
});
|
|
@@ -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
|
}
|
|
@@ -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
|
};
|