@hyperframes/studio 0.8.147 → 0.8.148
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-_eMxRXF_.js → hyperframes-player-BBBg-uvu.js} +1 -1
- package/dist/assets/{index-bXzd7tvF.js → index-BJIpZeuk.js} +115 -115
- package/dist/assets/{index-CEq9KmY2.js → index-BXJLeCqB.js} +1 -1
- package/dist/assets/{index-6okhHdnH.js → index-DARhiE_i.js} +1 -1
- package/dist/assets/{index-CVd4O6eH.css → index-DkQKsFxx.css} +1 -1
- package/dist/{chunk-PHGBG7OG.js → chunk-4UHZLLQL.js} +1 -1
- package/dist/{chunk-PVFBY7KR.js → chunk-FF5DM7BQ.js} +4 -1
- package/dist/index.html +2 -2
- package/dist/index.js +642 -657
- 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/ui/Popover.tsx +8 -1
- package/src/hooks/timelineEditingHelpers.test.ts +20 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -1
- package/src/player/components/ShortcutsPanel.test.tsx +73 -20
- package/src/player/components/ShortcutsPanel.tsx +41 -62
- package/src/utils/authoredSource.test.ts +10 -0
package/dist/ui.d.ts
CHANGED
|
@@ -182,12 +182,14 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof Popover$1.Ro
|
|
|
182
182
|
side?: "top" | "bottom" | "left" | "right";
|
|
183
183
|
align?: "start" | "center" | "end";
|
|
184
184
|
sideOffset?: number;
|
|
185
|
+
collisionAvoidance?: ComponentPropsWithoutRef<typeof Popover$1.Positioner>["collisionAvoidance"];
|
|
185
186
|
/** Points the popup at its trigger. Off by default; the default gap from the trigger grows by the arrow's height. */
|
|
186
187
|
arrow?: boolean;
|
|
187
188
|
/** Portal target. Pass the shadow root when the trigger lives in one; `null` keeps it inline. */
|
|
188
189
|
container?: PortalContainer;
|
|
189
190
|
/** Names the popup for assistive tech. */
|
|
190
191
|
"aria-label"?: string;
|
|
192
|
+
"aria-describedby"?: string;
|
|
191
193
|
/**
|
|
192
194
|
* What to focus on open. Defaults to Base UI's behaviour (the first tabbable
|
|
193
195
|
* element), which is what a rename field wants.
|
|
@@ -196,7 +198,7 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof Popover$1.Ro
|
|
|
196
198
|
className?: string;
|
|
197
199
|
"data-preview-state"?: PopupPreviewState;
|
|
198
200
|
}
|
|
199
|
-
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;
|
|
201
|
+
declare function Popover({ trigger, children, side, align, arrow, sideOffset, collisionAvoidance, container, className, initialFocus, "aria-label": ariaLabel, "aria-describedby": describedBy, "data-preview-state": previewState, ...root }: PopoverProps): react_jsx_runtime.JSX.Element;
|
|
200
202
|
|
|
201
203
|
/**
|
|
202
204
|
* 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.148",
|
|
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/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/sdk": "0.8.
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/parsers": "0.8.148",
|
|
64
|
+
"@hyperframes/core": "0.8.148",
|
|
65
|
+
"@hyperframes/sdk": "0.8.148",
|
|
66
|
+
"@hyperframes/player": "0.8.148",
|
|
67
|
+
"@hyperframes/shader-transitions": "0.8.148",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.148"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"vite": "^6.4.2",
|
|
83
83
|
"vitest": "^4.1.11",
|
|
84
84
|
"zustand": "^5.0.0",
|
|
85
|
-
"@hyperframes/producer": "0.8.
|
|
85
|
+
"@hyperframes/producer": "0.8.148"
|
|
86
86
|
},
|
|
87
87
|
"peerDependencies": {
|
|
88
88
|
"react": "19",
|
|
@@ -35,12 +35,16 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof BasePopover.
|
|
|
35
35
|
side?: "top" | "bottom" | "left" | "right";
|
|
36
36
|
align?: "start" | "center" | "end";
|
|
37
37
|
sideOffset?: number;
|
|
38
|
+
collisionAvoidance?: ComponentPropsWithoutRef<
|
|
39
|
+
typeof BasePopover.Positioner
|
|
40
|
+
>["collisionAvoidance"];
|
|
38
41
|
/** Points the popup at its trigger. Off by default; the default gap from the trigger grows by the arrow's height. */
|
|
39
42
|
arrow?: boolean;
|
|
40
43
|
/** Portal target. Pass the shadow root when the trigger lives in one; `null` keeps it inline. */
|
|
41
44
|
container?: PortalContainer;
|
|
42
45
|
/** Names the popup for assistive tech. */
|
|
43
46
|
"aria-label"?: string;
|
|
47
|
+
"aria-describedby"?: string;
|
|
44
48
|
/**
|
|
45
49
|
* What to focus on open. Defaults to Base UI's behaviour (the first tabbable
|
|
46
50
|
* element), which is what a rename field wants.
|
|
@@ -57,17 +61,19 @@ export function Popover({
|
|
|
57
61
|
align = "center",
|
|
58
62
|
arrow = false,
|
|
59
63
|
sideOffset = arrow ? SIDE_OFFSET + ARROW_PX : SIDE_OFFSET,
|
|
64
|
+
collisionAvoidance,
|
|
60
65
|
container,
|
|
61
66
|
className,
|
|
62
67
|
initialFocus,
|
|
63
68
|
"aria-label": ariaLabel,
|
|
69
|
+
"aria-describedby": describedBy,
|
|
64
70
|
"data-preview-state": previewState,
|
|
65
71
|
...root
|
|
66
72
|
}: PopoverProps) {
|
|
67
73
|
const inline = useInlinePortal(container);
|
|
68
74
|
return (
|
|
69
75
|
<BasePopover.Root {...root}>
|
|
70
|
-
<BasePopover.Trigger render={trigger} />
|
|
76
|
+
<BasePopover.Trigger render={trigger} aria-describedby={describedBy} />
|
|
71
77
|
{inline.host}
|
|
72
78
|
<BasePopover.Portal {...inline.portal}>
|
|
73
79
|
<BasePopover.Positioner
|
|
@@ -75,6 +81,7 @@ export function Popover({
|
|
|
75
81
|
align={align}
|
|
76
82
|
sideOffset={sideOffset}
|
|
77
83
|
collisionPadding={VIEWPORT_MARGIN}
|
|
84
|
+
collisionAvoidance={collisionAvoidance}
|
|
78
85
|
arrowPadding={ARROW_CORNER_CLEARANCE}
|
|
79
86
|
className={POPUP_LAYER}
|
|
80
87
|
>
|
|
@@ -218,6 +218,26 @@ describe("patchIframeDomTiming", () => {
|
|
|
218
218
|
|
|
219
219
|
expect([...cards!].map((card) => card.dataset.start)).toEqual(["1", "9"]);
|
|
220
220
|
});
|
|
221
|
+
|
|
222
|
+
it("makes a runtime-stamped element authored on any timing edit, a lane move included", () => {
|
|
223
|
+
const iframe = makeIframeWith(`
|
|
224
|
+
<div data-composition-id="root">
|
|
225
|
+
<div id="title" data-start="0" data-duration="10" data-hf-autostamped="1"></div>
|
|
226
|
+
<div id="badge" data-start="0" data-duration="10" data-hf-autostamped="1"></div>
|
|
227
|
+
</div>
|
|
228
|
+
`);
|
|
229
|
+
const doc = iframe.contentDocument!;
|
|
230
|
+
const at = (id: string) => el({ id, tag: "div", domId: id, sourceFile: "index.html" });
|
|
231
|
+
|
|
232
|
+
patchIframeDomTiming(iframe, at("title"), [
|
|
233
|
+
["data-start", "4"],
|
|
234
|
+
["data-duration", "2"],
|
|
235
|
+
]);
|
|
236
|
+
patchIframeDomTiming(iframe, at("badge"), [["data-track-index", "3"]]);
|
|
237
|
+
|
|
238
|
+
expect(doc.getElementById("title")?.hasAttribute("data-hf-autostamped")).toBe(false);
|
|
239
|
+
expect(doc.getElementById("badge")?.hasAttribute("data-hf-autostamped")).toBe(false);
|
|
240
|
+
});
|
|
221
241
|
});
|
|
222
242
|
|
|
223
243
|
describe("extendRootDurationIfNeeded", () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
|
|
2
|
+
import { applyAuthoredTiming } from "@hyperframes/core/studio-preview-mark";
|
|
2
3
|
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
|
3
4
|
import { toAuthoredStart } from "../player/store/timelineElement";
|
|
4
5
|
import {
|
|
@@ -180,7 +181,7 @@ export function patchIframeDomTiming(
|
|
|
180
181
|
try {
|
|
181
182
|
const el = findTimelineElementInIframe(iframe, element, activeCompositionPath);
|
|
182
183
|
if (!el) return;
|
|
183
|
-
|
|
184
|
+
applyAuthoredTiming(el, attrs);
|
|
184
185
|
} catch {
|
|
185
186
|
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
|
|
186
187
|
}
|
|
@@ -7,6 +7,7 @@ import { PlayerControls } from "./PlayerControls";
|
|
|
7
7
|
import { DEFAULT_SHORTCUT_SECTIONS, type ShortcutSection } from "./studioShortcuts";
|
|
8
8
|
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
9
9
|
import { usePlayerStore } from "../store/playerStore";
|
|
10
|
+
import { POPUP_LAYER } from "../../components/ui/Menu";
|
|
10
11
|
|
|
11
12
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
13
|
|
|
@@ -52,11 +53,16 @@ function openPanel(trigger: HTMLButtonElement): void {
|
|
|
52
53
|
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/** The open popover a trigger controls; it portals out of the trigger's host. */
|
|
57
|
+
function panelOf(trigger: HTMLButtonElement): HTMLElement {
|
|
58
|
+
return document.getElementById(trigger.getAttribute("aria-controls")!)!;
|
|
59
|
+
}
|
|
60
|
+
|
|
55
61
|
describe("ShortcutsPanel", () => {
|
|
56
62
|
it.each(["page", "button", "panel"] as const)(
|
|
57
63
|
"closes with Escape from the %s-focused position",
|
|
58
64
|
(focusPosition) => {
|
|
59
|
-
const {
|
|
65
|
+
const { trigger } = renderPanel();
|
|
60
66
|
openPanel(trigger);
|
|
61
67
|
|
|
62
68
|
let eventTarget: Document | HTMLElement;
|
|
@@ -70,7 +76,9 @@ describe("ShortcutsPanel", () => {
|
|
|
70
76
|
eventTarget = trigger;
|
|
71
77
|
expect(document.activeElement).toBe(trigger);
|
|
72
78
|
} else {
|
|
73
|
-
const panelInput =
|
|
79
|
+
const panelInput = document.querySelector<HTMLInputElement>(
|
|
80
|
+
'[aria-label="Jump to frame"]',
|
|
81
|
+
)!;
|
|
74
82
|
panelInput.focus();
|
|
75
83
|
eventTarget = panelInput;
|
|
76
84
|
expect(document.activeElement).toBe(panelInput);
|
|
@@ -81,40 +89,47 @@ describe("ShortcutsPanel", () => {
|
|
|
81
89
|
});
|
|
82
90
|
|
|
83
91
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
84
|
-
expect(
|
|
92
|
+
expect(document.querySelector('[aria-label="Jump to frame"]')).toBeNull();
|
|
85
93
|
},
|
|
86
94
|
);
|
|
87
95
|
|
|
88
|
-
it("closes on
|
|
96
|
+
it("closes on an outside press whose pointerdown a canvas gesture prevented", () => {
|
|
89
97
|
const preventDefault = (event: Event) => event.preventDefault();
|
|
90
98
|
document.addEventListener("pointerdown", preventDefault, true);
|
|
91
|
-
|
|
92
|
-
openPanel(trigger);
|
|
99
|
+
// The canvas exists before the panel opens, as in the editor.
|
|
93
100
|
const outside = document.createElement("div");
|
|
94
101
|
document.body.append(outside);
|
|
102
|
+
const { trigger } = renderPanel();
|
|
103
|
+
openPanel(trigger);
|
|
95
104
|
const pointerDown = new PointerEvent("pointerdown", {
|
|
96
105
|
bubbles: true,
|
|
97
106
|
cancelable: true,
|
|
98
107
|
button: 0,
|
|
99
108
|
});
|
|
100
109
|
|
|
101
|
-
|
|
110
|
+
// A prevented pointerdown suppresses mousedown and mouseup; the browser still sends the click.
|
|
111
|
+
act(() => {
|
|
112
|
+
outside.dispatchEvent(pointerDown);
|
|
113
|
+
outside.dispatchEvent(
|
|
114
|
+
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
|
|
115
|
+
);
|
|
116
|
+
});
|
|
102
117
|
|
|
103
118
|
document.removeEventListener("pointerdown", preventDefault, true);
|
|
104
119
|
expect(pointerDown.defaultPrevented).toBe(true);
|
|
105
120
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
106
|
-
expect(
|
|
121
|
+
expect(document.querySelector('[aria-label="Jump to frame"]')).toBeNull();
|
|
107
122
|
});
|
|
108
123
|
|
|
109
124
|
it("does not close on a click inside the panel", () => {
|
|
110
|
-
const {
|
|
125
|
+
const { trigger } = renderPanel();
|
|
111
126
|
openPanel(trigger);
|
|
112
|
-
const panelInput =
|
|
127
|
+
const panelInput = document.querySelector<HTMLInputElement>('[aria-label="Jump to frame"]')!;
|
|
113
128
|
|
|
114
129
|
act(() => pressAndClick(panelInput));
|
|
115
130
|
|
|
116
131
|
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
117
|
-
expect(
|
|
132
|
+
expect(document.querySelector('[aria-label="Jump to frame"]')).not.toBeNull();
|
|
118
133
|
});
|
|
119
134
|
|
|
120
135
|
it("toggles once per trigger click", () => {
|
|
@@ -140,10 +155,10 @@ describe("ShortcutsPanel", () => {
|
|
|
140
155
|
});
|
|
141
156
|
|
|
142
157
|
it("connects the trigger to the dialog with aria-controls", () => {
|
|
143
|
-
const {
|
|
158
|
+
const { trigger } = renderPanel();
|
|
144
159
|
openPanel(trigger);
|
|
145
160
|
const panelId = trigger.getAttribute("aria-controls");
|
|
146
|
-
const panel = panelId ?
|
|
161
|
+
const panel = panelId ? document.querySelector<HTMLElement>(`#${CSS.escape(panelId)}`) : null;
|
|
147
162
|
|
|
148
163
|
expect(panelId).not.toBeNull();
|
|
149
164
|
expect(panel?.getAttribute("role")).toBe("dialog");
|
|
@@ -153,6 +168,21 @@ describe("ShortcutsPanel", () => {
|
|
|
153
168
|
expect(panel?.id).toBe(panelId);
|
|
154
169
|
});
|
|
155
170
|
|
|
171
|
+
it("opens on the popup layer, above its button and outside the boxes that hold it", async () => {
|
|
172
|
+
const { host, trigger } = renderPanel();
|
|
173
|
+
// A button at the window's top edge: the panel must not flip down over the timeline.
|
|
174
|
+
trigger.getBoundingClientRect = () => new DOMRect(900, 0, 28, 28);
|
|
175
|
+
openPanel(trigger);
|
|
176
|
+
await act(async () => {});
|
|
177
|
+
const panel = panelOf(trigger);
|
|
178
|
+
|
|
179
|
+
expect(host.contains(panel)).toBe(false);
|
|
180
|
+
expect(panel.closest(`.${POPUP_LAYER}`)).not.toBeNull();
|
|
181
|
+
expect(panel.dataset.side).toBe("top");
|
|
182
|
+
expect(panel.dataset.align).toBe("end");
|
|
183
|
+
expect(panel.classList).toContain("max-h-[min(280px,var(--available-height))]");
|
|
184
|
+
});
|
|
185
|
+
|
|
156
186
|
it("lists what an embedder passes to PlayerControls instead of Studio's defaults", () => {
|
|
157
187
|
const listed = (sections?: readonly ShortcutSection[]) => {
|
|
158
188
|
const host = document.createElement("div");
|
|
@@ -162,8 +192,11 @@ describe("ShortcutsPanel", () => {
|
|
|
162
192
|
<PlayerControls onTogglePlay={vi.fn()} onSeek={vi.fn()} shortcutSections={sections} />,
|
|
163
193
|
);
|
|
164
194
|
});
|
|
165
|
-
|
|
166
|
-
|
|
195
|
+
const trigger = host.querySelector<HTMLButtonElement>(
|
|
196
|
+
'button[aria-label="Shortcuts and tools"]',
|
|
197
|
+
)!;
|
|
198
|
+
openPanel(trigger);
|
|
199
|
+
return panelOf(trigger).textContent ?? "";
|
|
167
200
|
};
|
|
168
201
|
const withoutSplit = DEFAULT_SHORTCUT_SECTIONS.map((section) => ({
|
|
169
202
|
...section,
|
|
@@ -196,12 +229,32 @@ describe("ShortcutsPanel", () => {
|
|
|
196
229
|
});
|
|
197
230
|
openPanel(host.querySelector<HTMLButtonElement>('button[aria-label="Shortcuts and tools"]')!);
|
|
198
231
|
|
|
199
|
-
expect(
|
|
200
|
-
expect(
|
|
232
|
+
expect(document.body.textContent).toContain("Select");
|
|
233
|
+
expect(document.body.textContent).toContain("Snap");
|
|
201
234
|
expect(errors.mock.calls.flat().join(" ")).not.toMatch(/same key/);
|
|
202
235
|
errors.mockRestore();
|
|
203
236
|
});
|
|
204
237
|
|
|
238
|
+
it("ties the shortcuts button to its tooltip for screen readers", async () => {
|
|
239
|
+
const host = document.createElement("div");
|
|
240
|
+
document.body.append(host);
|
|
241
|
+
act(() => {
|
|
242
|
+
mount(host).render(<ShortcutsButton sections={[{ title: "Host", hints: [] }]} />);
|
|
243
|
+
});
|
|
244
|
+
const button = host.querySelector<HTMLButtonElement>(
|
|
245
|
+
'button[aria-label="Shortcuts and tools"]',
|
|
246
|
+
)!;
|
|
247
|
+
|
|
248
|
+
act(() => button.focus());
|
|
249
|
+
await act(async () => {
|
|
250
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const tip = document.querySelector<HTMLElement>('[role="tooltip"]');
|
|
254
|
+
expect(tip?.textContent).toBe("Shortcuts and tools");
|
|
255
|
+
expect(button.getAttribute("aria-describedby")).toBe(tip?.id);
|
|
256
|
+
});
|
|
257
|
+
|
|
205
258
|
it("renders standalone for a host toolbar, reading the player store", () => {
|
|
206
259
|
const { duration, inPoint, requestSeek: storeSeek } = usePlayerStore.getState();
|
|
207
260
|
const requestSeek = vi.fn();
|
|
@@ -213,10 +266,10 @@ describe("ShortcutsPanel", () => {
|
|
|
213
266
|
mount(host).render(<ShortcutsButton sections={[{ title: "Host", hints: [] }]} />);
|
|
214
267
|
});
|
|
215
268
|
openPanel(host.querySelector<HTMLButtonElement>('button[aria-label="Shortcuts and tools"]')!);
|
|
216
|
-
expect(
|
|
217
|
-
expect(
|
|
269
|
+
expect(document.body.textContent).toContain("Host");
|
|
270
|
+
expect(document.body.textContent).toContain("00:02");
|
|
218
271
|
|
|
219
|
-
const input =
|
|
272
|
+
const input = document.querySelector<HTMLInputElement>('input[aria-label="Jump to frame"]')!;
|
|
220
273
|
act(() => {
|
|
221
274
|
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(
|
|
222
275
|
input,
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { useState, useCallback,
|
|
1
|
+
import { useState, useCallback, useRef, memo } from "react";
|
|
2
2
|
import { formatTime, frameToSeconds } from "../lib/time";
|
|
3
|
-
import { Tooltip } from "../../components/ui";
|
|
3
|
+
import { Popover, Tooltip } from "../../components/ui";
|
|
4
4
|
import { flatActive, flatIdle } from "../../components/timelineToolbarStyles";
|
|
5
|
-
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
6
5
|
import { usePlayerStore } from "../store/playerStore";
|
|
7
6
|
import { DEFAULT_SHORTCUT_SECTIONS, type ShortcutSection } from "./studioShortcuts";
|
|
8
7
|
|
|
@@ -17,6 +16,9 @@ interface ShortcutsPanelProps {
|
|
|
17
16
|
sections?: readonly ShortcutSection[];
|
|
18
17
|
}
|
|
19
18
|
|
|
19
|
+
/** Stays above its button, shrinking to the room there, rather than flipping down over the timeline. */
|
|
20
|
+
const STAY_ABOVE = { side: "none", align: "shift", fallbackAxisSide: "none" } as const;
|
|
21
|
+
|
|
20
22
|
export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
21
23
|
disabled,
|
|
22
24
|
duration,
|
|
@@ -29,23 +31,8 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
|
29
31
|
}: ShortcutsPanelProps) {
|
|
30
32
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
31
33
|
const [jumpFrame, setJumpFrame] = useState("");
|
|
32
|
-
|
|
33
|
-
const closeShortcuts = useCallback(() => setShowShortcuts(false), []);
|
|
34
|
-
const shortcutsPanelRef = useContextMenuDismiss(closeShortcuts);
|
|
34
|
+
// Focus lands on the scrolling body, not the frame input, so keyboard users can scroll and read it.
|
|
35
35
|
const panelBodyRef = useRef<HTMLDivElement>(null);
|
|
36
|
-
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
37
|
-
|
|
38
|
-
// Move focus into the panel on open so keyboard users can scroll and read
|
|
39
|
-
// it; hand focus back to the trigger on close.
|
|
40
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
if (!showShortcuts) return;
|
|
43
|
-
const trigger = triggerRef.current;
|
|
44
|
-
panelBodyRef.current?.focus();
|
|
45
|
-
return () => {
|
|
46
|
-
trigger?.focus();
|
|
47
|
-
};
|
|
48
|
-
}, [showShortcuts]);
|
|
49
36
|
|
|
50
37
|
const commitJumpFrame = useCallback(() => {
|
|
51
38
|
if (disabled) return;
|
|
@@ -72,48 +59,40 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
|
72
59
|
);
|
|
73
60
|
|
|
74
61
|
return (
|
|
75
|
-
<
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
fill="none"
|
|
91
|
-
stroke="currentColor"
|
|
92
|
-
strokeWidth="1.75"
|
|
93
|
-
strokeLinecap="round"
|
|
94
|
-
strokeLinejoin="round"
|
|
95
|
-
aria-hidden="true"
|
|
62
|
+
<Tooltip label="Shortcuts and tools">
|
|
63
|
+
<Popover
|
|
64
|
+
open={showShortcuts}
|
|
65
|
+
onOpenChange={setShowShortcuts}
|
|
66
|
+
side="top"
|
|
67
|
+
align="end"
|
|
68
|
+
collisionAvoidance={STAY_ABOVE}
|
|
69
|
+
aria-label="Keyboard shortcuts and tools"
|
|
70
|
+
initialFocus={panelBodyRef}
|
|
71
|
+
className="flex max-h-[min(280px,var(--available-height))] min-w-[220px] flex-col border-border bg-raised p-0"
|
|
72
|
+
trigger={
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
className={`${showShortcuts ? flatActive : flatIdle} shrink-0`}
|
|
76
|
+
aria-label="Shortcuts and tools"
|
|
96
77
|
>
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}}
|
|
116
|
-
>
|
|
78
|
+
<svg
|
|
79
|
+
width="11"
|
|
80
|
+
height="11"
|
|
81
|
+
viewBox="0 0 24 24"
|
|
82
|
+
fill="none"
|
|
83
|
+
stroke="currentColor"
|
|
84
|
+
strokeWidth="1.75"
|
|
85
|
+
strokeLinecap="round"
|
|
86
|
+
strokeLinejoin="round"
|
|
87
|
+
aria-hidden="true"
|
|
88
|
+
>
|
|
89
|
+
<rect x="2" y="4" width="20" height="16" rx="2" />
|
|
90
|
+
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
|
|
91
|
+
</svg>
|
|
92
|
+
</button>
|
|
93
|
+
}
|
|
94
|
+
>
|
|
95
|
+
<div ref={panelBodyRef} tabIndex={-1} className="min-h-0 overflow-y-auto outline-hidden">
|
|
117
96
|
<div className="px-3 pt-3 pb-2.5">
|
|
118
97
|
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
|
|
119
98
|
Jump to frame
|
|
@@ -247,8 +226,8 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
|
247
226
|
))}
|
|
248
227
|
</div>
|
|
249
228
|
</div>
|
|
250
|
-
|
|
251
|
-
</
|
|
229
|
+
</Popover>
|
|
230
|
+
</Tooltip>
|
|
252
231
|
);
|
|
253
232
|
});
|
|
254
233
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
import {
|
|
4
4
|
PREVIEW_RASTER_ATTR,
|
|
5
|
+
RUNTIME_STAMPED_ATTR,
|
|
5
6
|
STUDIO_PREVIEW_LAZY_ATTR,
|
|
6
7
|
} from "@hyperframes/core/studio-preview-mark";
|
|
7
8
|
import { liveMarkupWithoutPreviewMarks } from "./authoredSource";
|
|
@@ -22,4 +23,13 @@ describe("liveMarkupWithoutPreviewMarks", () => {
|
|
|
22
23
|
live.innerHTML = `<img src="a.png" loading="lazy" ${STUDIO_PREVIEW_LAZY_ATTR}>`;
|
|
23
24
|
expect(liveMarkupWithoutPreviewMarks(live)).toBe('<div><img src="a.png"></div>');
|
|
24
25
|
});
|
|
26
|
+
|
|
27
|
+
it("drops the runtime's stamp so copied markup cannot carry it into a project file", () => {
|
|
28
|
+
const live = document.createElement("div");
|
|
29
|
+
live.innerHTML = `<div id="title" data-start="0" data-duration="10" ${RUNTIME_STAMPED_ATTR}="1"></div>`;
|
|
30
|
+
live.setAttribute(RUNTIME_STAMPED_ATTR, "1");
|
|
31
|
+
expect(liveMarkupWithoutPreviewMarks(live)).toBe(
|
|
32
|
+
'<div><div id="title" data-start="0" data-duration="10"></div></div>',
|
|
33
|
+
);
|
|
34
|
+
});
|
|
25
35
|
});
|