@hyperframes/studio 0.8.51 → 0.8.53-desktop.0
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-Cn_PpHqP.js → hyperframes-player-PTEnAHhg.js} +1 -1
- package/dist/assets/{index-CPS3Cvj8.js → index--myXp8ui.js} +1 -1
- package/dist/assets/{index-Clrb11lj.js → index-BjslOS9g.js} +1 -1
- package/dist/assets/index-BnXZuIJD.css +1 -0
- package/dist/assets/index-CNyviK_l.js +390 -0
- package/dist/index.css +29 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +808 -12
- package/dist/index.html +2 -2
- package/dist/index.js +8109 -7583
- package/dist/index.js.map +1 -1
- package/package.json +9 -7
- package/src/App.tsx +45 -50
- package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
- package/src/captions/components/CaptionOverlay.tsx +8 -4
- package/src/components/EditorShell.selectionSync.test.tsx +9 -4
- package/src/components/EditorShell.tsx +56 -56
- package/src/components/RightPanelTabs.test.tsx +151 -0
- package/src/components/RightPanelTabs.tsx +59 -0
- package/src/components/StudioHeader.dom.test.tsx +181 -0
- package/src/components/StudioHeader.test.ts +0 -12
- package/src/components/StudioHeader.tsx +117 -160
- package/src/components/StudioLeftPanels.tsx +202 -0
- package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -194
- package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
- package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
- package/src/components/TimelineHistoryButtons.tsx +54 -0
- package/src/components/TimelineToolbar.test.tsx +8 -0
- package/src/components/TimelineToolbar.tsx +3 -7
- package/src/components/dock/Dock.test.tsx +100 -18
- package/src/components/dock/Dock.tsx +32 -5
- package/src/components/dock/dock.css +5 -0
- package/src/components/dock/dockAccessibility.test.ts +166 -0
- package/src/components/dock/dockAccessibility.ts +98 -0
- package/src/components/dock/dockLayout.test.ts +134 -0
- package/src/components/dock/dockLayout.ts +53 -6
- package/src/components/dock/dockLayoutStore.test.ts +28 -0
- package/src/components/dock/dockLayoutStore.ts +21 -4
- package/src/components/dock/dockTestHarness.ts +11 -0
- package/src/components/editor/LayersPanel.test.ts +97 -1
- package/src/components/editor/LayersPanel.tsx +4 -2
- package/src/components/editor/PreviewGuides.tsx +1 -1
- package/src/components/editor/SnapToolbar.test.tsx +0 -3
- package/src/components/editor/TopologyLens.test.tsx +28 -0
- package/src/components/editor/TopologyLens.tsx +3 -1
- package/src/components/editor/useColorGradingController.test.ts +38 -0
- package/src/components/editor/useColorGradingController.ts +9 -1
- package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
- package/src/components/editor/useColorGradingPreviews.ts +11 -1
- package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
- package/src/components/editor/usePreviewCompositionRect.ts +21 -0
- package/src/components/nle/NLEContext.tsx +0 -43
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewPane.tsx +1 -1
- package/src/components/nle/TimelinePane.tsx +53 -67
- package/src/components/panels/SlideshowPanel.tsx +1 -1
- package/src/components/panels/VariablesPanel.tsx +1 -1
- package/src/components/pointerTargetSize.test.tsx +8 -0
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/renders/RenderQueuePanel.tsx +1 -1
- package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
- package/src/components/sidebar/CompositionsPanel.tsx +15 -0
- package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
- package/src/components/sidebar/SidebarLintButton.tsx +50 -0
- package/src/components/timelineToolbarStyles.ts +5 -0
- package/src/components/ui/Button.test.tsx +222 -30
- package/src/components/ui/Button.tsx +88 -125
- package/src/components/ui/IconButton.tsx +38 -0
- package/src/components/ui/Input.tsx +115 -0
- package/src/components/ui/Menu.test.tsx +374 -0
- package/src/components/ui/Menu.tsx +213 -0
- package/src/components/ui/NumberField.tsx +122 -0
- package/src/components/ui/Popover.tsx +74 -0
- package/src/components/ui/Select.tsx +100 -0
- package/src/components/ui/Slider.tsx +153 -0
- package/src/components/ui/Tabs.test.tsx +98 -0
- package/src/components/ui/Tabs.tsx +71 -0
- package/src/components/ui/Toggle.tsx +63 -0
- package/src/components/ui/Tooltip.test.tsx +110 -0
- package/src/components/ui/Tooltip.tsx +38 -112
- package/src/components/ui/cn.test.ts +30 -0
- package/src/components/ui/cn.ts +25 -0
- package/src/components/ui/index.ts +29 -2
- package/src/components/ui/mountHost.testHelpers.ts +24 -0
- package/src/components/ui/valueControls.test.tsx +524 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -57
- package/src/hooks/appHotkeysDispatch.ts +3 -4
- package/src/hooks/timelineEditPermission.ts +31 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
- package/src/hooks/useAppHotkeys.test.ts +0 -1
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
- package/src/hooks/useAppHotkeys.ts +20 -83
- package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
- package/src/hooks/useCaptionDetection.ts +3 -1
- package/src/hooks/useDomEditPreviewSync.ts +4 -6
- package/src/hooks/useDomEditSession.ts +0 -7
- package/src/hooks/useDomEditWiring.ts +4 -9
- package/src/hooks/useEditHistoryActions.test.tsx +90 -0
- package/src/hooks/useEditHistoryActions.ts +106 -0
- package/src/hooks/useFileManager.ts +21 -123
- package/src/hooks/usePanelLayout.test.ts +108 -296
- package/src/hooks/usePanelLayout.ts +50 -255
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/useProjectFileWriter.ts +150 -0
- package/src/hooks/useRightPanelIntents.test.ts +181 -0
- package/src/hooks/useRightPanelIntents.ts +63 -0
- package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
- package/src/hooks/useServerConnection.ts +43 -10
- package/src/hooks/useSlideshowTabState.test.ts +9 -31
- package/src/hooks/useSlideshowTabState.ts +6 -19
- package/src/hooks/useStudioContextValue.test.ts +6 -18
- package/src/hooks/useStudioContextValue.ts +5 -26
- package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
- package/src/hooks/useTimelineEditing.test.tsx +322 -3
- package/src/hooks/useTimelineEditing.ts +103 -16
- package/src/hooks/useTimelineEditingTypes.ts +3 -0
- package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
- package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
- package/src/hostMount.test.tsx +222 -0
- package/src/index.ts +87 -0
- package/src/player/components/AutomationSelectionMenu.tsx +1 -1
- package/src/player/components/Timeline.tsx +1 -1
- package/src/player/components/TimelineCanvas.tsx +4 -4
- package/src/player/components/TimelineClip.tsx +0 -1
- package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +30 -30
- package/src/player/lib/playbackShortcuts.ts +2 -0
- package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
- package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
- package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
- package/src/player/store/previewIframeStore.test.tsx +29 -0
- package/src/player/store/previewIframeStore.ts +25 -0
- package/src/renderQueueExports.test.tsx +58 -0
- package/src/styles/classCandidates.test.ts +105 -0
- package/src/styles/classCandidates.ts +281 -0
- package/src/styles/hex-baseline.json +84 -0
- package/src/styles/hexRatchet.test.ts +201 -0
- package/src/styles/styleSources.test.ts +20 -0
- package/src/styles/styleSources.ts +47 -0
- package/src/styles/theme.css +57 -0
- package/src/styles/theme.test.ts +2 -15
- package/src/styles/tokenGate.test.ts +109 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +11 -8
- package/src/utils/studioUiPreferences.test.ts +10 -16
- package/src/utils/studioUiPreferences.ts +0 -16
- package/src/utils/typingTarget.ts +4 -0
- package/dist/assets/index-C05SNrvY.js +0 -360
- package/dist/assets/index-DUR9qzQM.css +0 -1
- package/src/components/PanelTabButton.tsx +0 -31
- package/src/components/StudioLeftSidebar.tsx +0 -207
- package/src/components/nle/TimelineResizeDivider.tsx +0 -101
- package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
- package/src/components/sidebar/LeftSidebar.tsx +0 -348
- package/src/hooks/useInspectorSplitResize.ts +0 -51
- package/src/utils/fitPanels.test.ts +0 -164
- package/src/utils/fitPanels.ts +0 -151
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/** Value controls: one drag is one commit and one telemetry event; classes resolve; hotkey roles match. */
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import React, { act } from "react";
|
|
7
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
8
|
+
import { compile } from "tailwindcss";
|
|
9
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
const trackStudioEvent = vi.fn();
|
|
12
|
+
vi.mock("../../utils/studioTelemetry", () => ({
|
|
13
|
+
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { Input } from "./Input";
|
|
17
|
+
import { NumberField } from "./NumberField";
|
|
18
|
+
import { Select } from "./Select";
|
|
19
|
+
import { Slider } from "./Slider";
|
|
20
|
+
import { Toggle } from "./Toggle";
|
|
21
|
+
import { isTypingTarget } from "../../utils/typingTarget";
|
|
22
|
+
import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
|
|
23
|
+
import { __resetDesignInputThrottle, trackDesignInput } from "../../utils/designInputTracking";
|
|
24
|
+
|
|
25
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
26
|
+
|
|
27
|
+
let mounted: { root: Root; host: HTMLElement } | null = null;
|
|
28
|
+
|
|
29
|
+
function render(element: React.ReactElement): HTMLElement {
|
|
30
|
+
const host = document.createElement("div");
|
|
31
|
+
document.body.append(host);
|
|
32
|
+
const root = createRoot(host);
|
|
33
|
+
mounted = { root, host };
|
|
34
|
+
act(() => root.render(element));
|
|
35
|
+
return host;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function unmount() {
|
|
39
|
+
if (!mounted) return;
|
|
40
|
+
const { root, host } = mounted;
|
|
41
|
+
mounted = null;
|
|
42
|
+
act(() => root.unmount());
|
|
43
|
+
host.remove();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
trackStudioEvent.mockReset();
|
|
48
|
+
__resetDesignInputThrottle();
|
|
49
|
+
});
|
|
50
|
+
afterEach(unmount);
|
|
51
|
+
|
|
52
|
+
/** Base UI moves focus a task later than React renders; happy-dom is no faster. */
|
|
53
|
+
const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
|
54
|
+
|
|
55
|
+
function fire(el: Element, type: string, init: MouseEventInit & { key?: string } = {}) {
|
|
56
|
+
const event =
|
|
57
|
+
init.key === undefined
|
|
58
|
+
? new MouseEvent(type, { bubbles: true, ...init })
|
|
59
|
+
: new KeyboardEvent(type, { bubbles: true, key: init.key });
|
|
60
|
+
act(() => void el.dispatchEvent(event));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Types into a React-controlled input the way the browser does. */
|
|
64
|
+
function type(input: HTMLInputElement, text: string) {
|
|
65
|
+
act(() => {
|
|
66
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.bind(
|
|
67
|
+
input,
|
|
68
|
+
);
|
|
69
|
+
setter(text);
|
|
70
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One design-input telemetry call, through the real coalescing sink. */
|
|
75
|
+
const track = (control: string) => () =>
|
|
76
|
+
trackDesignInput({ ui: "flat", section: "style", control, name: "Opacity" });
|
|
77
|
+
|
|
78
|
+
const designInputCalls = () =>
|
|
79
|
+
trackStudioEvent.mock.calls.filter(([event]) => event === "design_input");
|
|
80
|
+
|
|
81
|
+
// -- the compiled stylesheet, built once for the whole file --
|
|
82
|
+
|
|
83
|
+
const STYLES_DIR = path.resolve(__dirname, "../../styles");
|
|
84
|
+
const require = createRequire(import.meta.url);
|
|
85
|
+
const TAILWIND_DIR = path.dirname(require.resolve("tailwindcss/package.json"));
|
|
86
|
+
|
|
87
|
+
async function loadStylesheet(id: string, base: string) {
|
|
88
|
+
const file = id === "tailwindcss" ? path.join(TAILWIND_DIR, "index.css") : path.resolve(base, id);
|
|
89
|
+
return { path: file, base: path.dirname(file), content: readFileSync(file, "utf8") };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** How Tailwind escapes a candidate into a selector: `\` before each symbol. */
|
|
93
|
+
function asSelector(candidate: string): string {
|
|
94
|
+
return candidate.replace(/[^a-zA-Z0-9-]/g, (char) => `\\${char}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let compileStudioCss: (candidates: string[]) => string;
|
|
98
|
+
|
|
99
|
+
beforeAll(async () => {
|
|
100
|
+
const compiled = await compile(readFileSync(path.join(STYLES_DIR, "studio.css"), "utf8"), {
|
|
101
|
+
base: STYLES_DIR,
|
|
102
|
+
loadStylesheet,
|
|
103
|
+
});
|
|
104
|
+
compileStudioCss = (candidates) => compiled.build(candidates);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
function unresolved(candidates: string[]): string[] {
|
|
108
|
+
const css = compileStudioCss(candidates);
|
|
109
|
+
return candidates.filter((candidate) => !css.includes(asSelector(candidate)));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** All five, rendered together, so one sweep covers the set. */
|
|
113
|
+
function allFive(previewState?: "hover" | "active" | "focus") {
|
|
114
|
+
const noop = () => {};
|
|
115
|
+
return (
|
|
116
|
+
<>
|
|
117
|
+
<Input value="hello" onCommit={noop} aria-label="Name" data-preview-state={previewState} />
|
|
118
|
+
<NumberField
|
|
119
|
+
label="Size"
|
|
120
|
+
value={24}
|
|
121
|
+
unit="px"
|
|
122
|
+
onCommit={noop}
|
|
123
|
+
data-preview-state={previewState}
|
|
124
|
+
/>
|
|
125
|
+
<Select
|
|
126
|
+
label="Weight"
|
|
127
|
+
value="400"
|
|
128
|
+
options={[
|
|
129
|
+
{ label: "Regular", value: "400" },
|
|
130
|
+
{ label: "Semibold", value: "600" },
|
|
131
|
+
]}
|
|
132
|
+
onCommit={noop}
|
|
133
|
+
data-preview-state={previewState}
|
|
134
|
+
/>
|
|
135
|
+
<Slider
|
|
136
|
+
label="Opacity"
|
|
137
|
+
value={10}
|
|
138
|
+
min={0}
|
|
139
|
+
max={100}
|
|
140
|
+
onCommit={noop}
|
|
141
|
+
data-preview-state={previewState}
|
|
142
|
+
/>
|
|
143
|
+
<Toggle label="Visible" checked onCommit={noop} data-preview-state={previewState} />
|
|
144
|
+
</>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
describe("value control classes", () => {
|
|
149
|
+
it("emits only classes Studio's stylesheet defines", () => {
|
|
150
|
+
const host = render(allFive());
|
|
151
|
+
const emitted = new Set<string>();
|
|
152
|
+
for (const el of host.querySelectorAll("*")) {
|
|
153
|
+
for (const cls of el.classList) emitted.add(cls);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
expect(emitted.size).toBeGreaterThan(20);
|
|
157
|
+
expect(unresolved([...emitted])).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("fails an undefined class, so the check above is not vacuous", () => {
|
|
161
|
+
expect(unresolved(["bg-not-a-token", "text-step-999"])).toEqual([
|
|
162
|
+
"bg-not-a-token",
|
|
163
|
+
"text-step-999",
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("keeps every interactive look reachable without a pointer", () => {
|
|
168
|
+
// Same contract as Button: the `data-preview-state` half of each recipe
|
|
169
|
+
// repeats the real state's half exactly, so a gallery shot cannot drift
|
|
170
|
+
// away from what a pointer actually produces.
|
|
171
|
+
const realPrefix: Record<string, string[]> = {
|
|
172
|
+
hover: ["hover:"],
|
|
173
|
+
active: ["active:"],
|
|
174
|
+
focus: ["focus-visible:", "focus-within:", "has-[:focus-visible]:"],
|
|
175
|
+
};
|
|
176
|
+
const host = render(allFive());
|
|
177
|
+
const classes = [...host.querySelectorAll("*")].flatMap((el) => [...el.classList]);
|
|
178
|
+
|
|
179
|
+
for (const [state, prefixes] of Object.entries(realPrefix)) {
|
|
180
|
+
const previewPrefix = `data-[preview-state=${state}]:`;
|
|
181
|
+
const preview = classes
|
|
182
|
+
.filter((cls) => cls.startsWith(previewPrefix))
|
|
183
|
+
.map((cls) => cls.slice(previewPrefix.length));
|
|
184
|
+
const real = classes
|
|
185
|
+
.filter((cls) => prefixes.some((prefix) => cls.startsWith(prefix)))
|
|
186
|
+
.map((cls) => cls.slice(cls.lastIndexOf(":") + 1));
|
|
187
|
+
|
|
188
|
+
expect([...new Set(preview)].sort(), state).toEqual([...new Set(real)].sort());
|
|
189
|
+
expect(preview.length, state).toBeGreaterThan(0);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("boxes every value control, so an input reads as an input", () => {
|
|
194
|
+
// R10. The three text-shaped controls carry a real border and a background
|
|
195
|
+
// that differs from the panel behind them.
|
|
196
|
+
const host = render(allFive());
|
|
197
|
+
const boxes = [...host.querySelectorAll(".border-border-input")];
|
|
198
|
+
|
|
199
|
+
expect(boxes.length).toBe(4); // Input, NumberField, Select trigger, Toggle
|
|
200
|
+
for (const box of boxes) {
|
|
201
|
+
expect([...box.classList]).toContain("bg-input");
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("Slider", () => {
|
|
207
|
+
/** Gives the control a width so Base UI's pointer maths has a rect to use. */
|
|
208
|
+
function stubRect(el: Element, width = 100) {
|
|
209
|
+
el.getBoundingClientRect = () =>
|
|
210
|
+
({ x: 0, y: 0, top: 0, left: 0, right: width, bottom: 10, width, height: 10 }) as DOMRect;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
it("commits once and tracks once for a drag across five moves", () => {
|
|
214
|
+
const commits: number[] = [];
|
|
215
|
+
const previews: number[] = [];
|
|
216
|
+
// Counted at the wrapper as well as at the sink. The sink's 600 ms window
|
|
217
|
+
// would collapse a per-move `track()` into one event on its own, so
|
|
218
|
+
// asserting only the event count would pass even if the call moved to
|
|
219
|
+
// `onValueChange`. The raw count is what pins KTD11.
|
|
220
|
+
let trackCalls = 0;
|
|
221
|
+
const trackOnce = track("slider");
|
|
222
|
+
const host = render(
|
|
223
|
+
<Slider
|
|
224
|
+
label="Opacity"
|
|
225
|
+
value={10}
|
|
226
|
+
min={0}
|
|
227
|
+
max={100}
|
|
228
|
+
onPreview={(n) => previews.push(n)}
|
|
229
|
+
onCommit={(n) => commits.push(n)}
|
|
230
|
+
onTrack={() => {
|
|
231
|
+
trackCalls += 1;
|
|
232
|
+
trackOnce();
|
|
233
|
+
}}
|
|
234
|
+
/>,
|
|
235
|
+
);
|
|
236
|
+
const control = host.querySelector("[data-slider-control]")!;
|
|
237
|
+
stubRect(control);
|
|
238
|
+
|
|
239
|
+
fire(control, "pointerdown", { clientX: 10, button: 0 });
|
|
240
|
+
// 10 to 40 over five moves. The release commits the last value a move
|
|
241
|
+
// delivered, not the pointerup coordinate, which is why the last move and
|
|
242
|
+
// the release sit at the same x.
|
|
243
|
+
for (const x of [15, 20, 25, 30, 40]) {
|
|
244
|
+
// `buttons: 1` is not decoration: Base UI reads it to tell a real drag
|
|
245
|
+
// from a move whose pointerup another element swallowed, and treats
|
|
246
|
+
// `buttons: 0` as the end of the gesture.
|
|
247
|
+
act(
|
|
248
|
+
() =>
|
|
249
|
+
void document.dispatchEvent(new MouseEvent("pointermove", { clientX: x, buttons: 1 })),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
act(
|
|
253
|
+
() => void document.dispatchEvent(new MouseEvent("pointerup", { clientX: 40, buttons: 0 })),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(previews.length).toBeGreaterThan(1);
|
|
257
|
+
expect(commits).toEqual([40]);
|
|
258
|
+
expect(trackCalls).toBe(1);
|
|
259
|
+
expect(designInputCalls()).toHaveLength(1);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("puts the value back when a drag is aborted with the right button", () => {
|
|
263
|
+
// KTD8. The pointer is still down: the abort has to stop Base UI applying
|
|
264
|
+
// any further move, not merely reset the number once.
|
|
265
|
+
const commits: number[] = [];
|
|
266
|
+
const host = render(
|
|
267
|
+
<Slider
|
|
268
|
+
label="Opacity"
|
|
269
|
+
value={10}
|
|
270
|
+
min={0}
|
|
271
|
+
max={100}
|
|
272
|
+
onCommit={(n) => commits.push(n)}
|
|
273
|
+
onTrack={track("slider")}
|
|
274
|
+
/>,
|
|
275
|
+
);
|
|
276
|
+
const control = host.querySelector("[data-slider-control]")!;
|
|
277
|
+
stubRect(control);
|
|
278
|
+
|
|
279
|
+
fire(control, "pointerdown", { clientX: 10, button: 0 });
|
|
280
|
+
act(
|
|
281
|
+
() => void document.dispatchEvent(new MouseEvent("pointermove", { clientX: 60, buttons: 1 })),
|
|
282
|
+
);
|
|
283
|
+
fire(control, "contextmenu", { clientX: 60, button: 2 });
|
|
284
|
+
act(
|
|
285
|
+
() => void document.dispatchEvent(new MouseEvent("pointermove", { clientX: 90, buttons: 1 })),
|
|
286
|
+
);
|
|
287
|
+
act(
|
|
288
|
+
() => void document.dispatchEvent(new MouseEvent("pointerup", { clientX: 90, buttons: 0 })),
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const input = host.querySelector<HTMLInputElement>('input[type="range"]')!;
|
|
292
|
+
expect(input.value).toBe("10");
|
|
293
|
+
expect(commits.at(-1) ?? 10).toBe(10);
|
|
294
|
+
expect(designInputCalls()).toHaveLength(0);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("steps by one on ArrowRight and coalesces three fast presses into one event", () => {
|
|
298
|
+
const commits: number[] = [];
|
|
299
|
+
const host = render(
|
|
300
|
+
<Slider
|
|
301
|
+
label="Opacity"
|
|
302
|
+
value={10}
|
|
303
|
+
min={0}
|
|
304
|
+
max={100}
|
|
305
|
+
onCommit={(n) => commits.push(n)}
|
|
306
|
+
onTrack={track("slider")}
|
|
307
|
+
/>,
|
|
308
|
+
);
|
|
309
|
+
const input = host.querySelector<HTMLInputElement>('input[type="range"]')!;
|
|
310
|
+
|
|
311
|
+
fire(input, "keydown", { key: "ArrowRight" });
|
|
312
|
+
fire(input, "keydown", { key: "ArrowRight" });
|
|
313
|
+
fire(input, "keydown", { key: "ArrowRight" });
|
|
314
|
+
|
|
315
|
+
expect(commits).toEqual([11, 12, 13]);
|
|
316
|
+
// Three commits, one event: the sink's 600 ms window is what turns a burst
|
|
317
|
+
// of keyboard steps into one "the user worked this control".
|
|
318
|
+
expect(designInputCalls()).toHaveLength(1);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe("NumberField", () => {
|
|
323
|
+
it("commits a typed number on Enter and keeps the unit", () => {
|
|
324
|
+
const commits: number[] = [];
|
|
325
|
+
const host = render(
|
|
326
|
+
<NumberField
|
|
327
|
+
label="Size"
|
|
328
|
+
value={24}
|
|
329
|
+
unit="px"
|
|
330
|
+
onCommit={(n) => commits.push(n)}
|
|
331
|
+
onTrack={track("metric")}
|
|
332
|
+
/>,
|
|
333
|
+
);
|
|
334
|
+
const input = host.querySelector("input")!;
|
|
335
|
+
|
|
336
|
+
type(input, "48");
|
|
337
|
+
expect(commits).toEqual([]); // a keystroke is not a commit
|
|
338
|
+
|
|
339
|
+
fire(input, "keydown", { key: "Enter" });
|
|
340
|
+
|
|
341
|
+
expect(commits).toEqual([48]);
|
|
342
|
+
expect(designInputCalls()).toHaveLength(1);
|
|
343
|
+
expect(host.textContent).toContain("px");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("shows the error state for text it cannot parse, and commits nothing", () => {
|
|
347
|
+
// Not "abc": Base UI filters a non-numeral character out of the input
|
|
348
|
+
// before the text ever reaches this component, so that case is unreachable
|
|
349
|
+
// rather than untested. A lone minus sign is the reachable one. It is a
|
|
350
|
+
// legal character, it survives into the field, and it is not a number.
|
|
351
|
+
const commits: number[] = [];
|
|
352
|
+
const host = render(
|
|
353
|
+
<NumberField label="Size" value={24} unit="px" onCommit={(n) => commits.push(n)} />,
|
|
354
|
+
);
|
|
355
|
+
const input = host.querySelector("input")!;
|
|
356
|
+
|
|
357
|
+
type(input, "abc");
|
|
358
|
+
expect(input.value).toBe("24"); // rejected at the keystroke, nothing to commit
|
|
359
|
+
|
|
360
|
+
type(input, "-");
|
|
361
|
+
fire(input, "keydown", { key: "Enter" });
|
|
362
|
+
|
|
363
|
+
expect(commits).toEqual([]);
|
|
364
|
+
expect(input.getAttribute("aria-invalid")).toBe("true");
|
|
365
|
+
expect(host.textContent).toContain("px");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("treats an emptied field as an error rather than as zero", () => {
|
|
369
|
+
const commits: number[] = [];
|
|
370
|
+
const host = render(
|
|
371
|
+
<NumberField label="Size" value={24} unit="px" onCommit={(n) => commits.push(n)} />,
|
|
372
|
+
);
|
|
373
|
+
const input = host.querySelector("input")!;
|
|
374
|
+
|
|
375
|
+
type(input, "");
|
|
376
|
+
fire(input, "keydown", { key: "Enter" });
|
|
377
|
+
|
|
378
|
+
expect(commits).toEqual([]);
|
|
379
|
+
expect(input.getAttribute("aria-invalid")).toBe("true");
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe("Input", () => {
|
|
384
|
+
it("commits a typed value on Enter, once, and not per keystroke", () => {
|
|
385
|
+
const commits: string[] = [];
|
|
386
|
+
const host = render(
|
|
387
|
+
<Input
|
|
388
|
+
value="hello"
|
|
389
|
+
aria-label="Name"
|
|
390
|
+
onCommit={(v) => commits.push(v)}
|
|
391
|
+
onTrack={track("text")}
|
|
392
|
+
/>,
|
|
393
|
+
);
|
|
394
|
+
const input = host.querySelector("input")!;
|
|
395
|
+
|
|
396
|
+
type(input, "world");
|
|
397
|
+
expect(commits).toEqual([]);
|
|
398
|
+
|
|
399
|
+
fire(input, "keydown", { key: "Enter" });
|
|
400
|
+
|
|
401
|
+
expect(commits).toEqual(["world"]);
|
|
402
|
+
expect(designInputCalls()).toHaveLength(1);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("abandons the draft on Escape", () => {
|
|
406
|
+
const commits: string[] = [];
|
|
407
|
+
const host = render(
|
|
408
|
+
<Input value="hello" aria-label="Name" onCommit={(v) => commits.push(v)} />,
|
|
409
|
+
);
|
|
410
|
+
const input = host.querySelector("input")!;
|
|
411
|
+
|
|
412
|
+
type(input, "world");
|
|
413
|
+
fire(input, "keydown", { key: "Escape" });
|
|
414
|
+
|
|
415
|
+
expect(input.value).toBe("hello");
|
|
416
|
+
expect(commits).toEqual([]);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe("Select", () => {
|
|
421
|
+
it("opens with Space, moves the highlight, and commits on Enter", async () => {
|
|
422
|
+
const commits: string[] = [];
|
|
423
|
+
const host = render(
|
|
424
|
+
<Select
|
|
425
|
+
label="Weight"
|
|
426
|
+
value="400"
|
|
427
|
+
options={[
|
|
428
|
+
{ label: "Regular", value: "400" },
|
|
429
|
+
{ label: "Semibold", value: "600" },
|
|
430
|
+
]}
|
|
431
|
+
onCommit={(v) => commits.push(v)}
|
|
432
|
+
onTrack={track("select")}
|
|
433
|
+
/>,
|
|
434
|
+
);
|
|
435
|
+
const trigger = host.querySelector('[role="combobox"]')!;
|
|
436
|
+
|
|
437
|
+
// The trigger is a real `<button>`, so Space reaches it as keydown, keyup
|
|
438
|
+
// and then a click the browser synthesises. happy-dom does not synthesise
|
|
439
|
+
// that click, so the test dispatches the sequence a browser produces.
|
|
440
|
+
fire(trigger, "keydown", { key: " " });
|
|
441
|
+
fire(trigger, "keyup", { key: " " });
|
|
442
|
+
act(() => (trigger as HTMLElement).click());
|
|
443
|
+
await settle();
|
|
444
|
+
const options = [...document.querySelectorAll('[role="option"]')];
|
|
445
|
+
expect(options).toHaveLength(2);
|
|
446
|
+
|
|
447
|
+
fire(document.activeElement ?? document.body, "keydown", { key: "ArrowDown" });
|
|
448
|
+
await settle();
|
|
449
|
+
expect(document.querySelector("[data-highlighted]")?.textContent).toBe("Semibold");
|
|
450
|
+
|
|
451
|
+
fire(document.activeElement ?? document.body, "keydown", { key: "Enter" });
|
|
452
|
+
await settle();
|
|
453
|
+
|
|
454
|
+
expect(commits).toEqual(["600"]);
|
|
455
|
+
expect(designInputCalls()).toHaveLength(1);
|
|
456
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
describe("Toggle", () => {
|
|
461
|
+
it("flips on click and on Space, and reports aria-checked", () => {
|
|
462
|
+
const flips: boolean[] = [];
|
|
463
|
+
const host = render(
|
|
464
|
+
<Toggle
|
|
465
|
+
label="Visible"
|
|
466
|
+
checked={false}
|
|
467
|
+
onCommit={(v) => flips.push(v)}
|
|
468
|
+
onTrack={track("toggle")}
|
|
469
|
+
/>,
|
|
470
|
+
);
|
|
471
|
+
const control = host.querySelector('[role="switch"]') as HTMLElement;
|
|
472
|
+
|
|
473
|
+
expect(control.getAttribute("aria-checked")).toBe("false");
|
|
474
|
+
|
|
475
|
+
act(() => control.click());
|
|
476
|
+
fire(control, "keydown", { key: " " });
|
|
477
|
+
fire(control, "keyup", { key: " " });
|
|
478
|
+
|
|
479
|
+
expect(flips).toEqual([true, true]);
|
|
480
|
+
expect(designInputCalls()).toHaveLength(1); // coalesced, same control
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
describe("hotkey classification (KTD13)", () => {
|
|
485
|
+
it("classifies each control the way the native input it replaces was", async () => {
|
|
486
|
+
// Native reference on the left, primitive on the right. Both selector lists
|
|
487
|
+
// have to agree on each pair, or a global shortcut starts behaving
|
|
488
|
+
// differently after a consumer migrates.
|
|
489
|
+
const host = render(
|
|
490
|
+
<>
|
|
491
|
+
<input type="text" data-testid="native-text" />
|
|
492
|
+
<input type="range" data-testid="native-range" />
|
|
493
|
+
<select data-testid="native-select">
|
|
494
|
+
<option>a</option>
|
|
495
|
+
</select>
|
|
496
|
+
<input type="checkbox" data-testid="native-checkbox" />
|
|
497
|
+
{allFive()}
|
|
498
|
+
</>,
|
|
499
|
+
);
|
|
500
|
+
await settle();
|
|
501
|
+
|
|
502
|
+
const native = (id: string) => host.querySelector(`[data-testid="${id}"]`)!;
|
|
503
|
+
const pairs: Array<[string, Element]> = [
|
|
504
|
+
["native-text", host.querySelectorAll("input[type='text']")[1]!],
|
|
505
|
+
["native-range", host.querySelector("input[type='range']")!],
|
|
506
|
+
["native-select", host.querySelector('[role="combobox"]')!],
|
|
507
|
+
["native-checkbox", host.querySelector('[role="switch"]')!],
|
|
508
|
+
];
|
|
509
|
+
|
|
510
|
+
for (const [nativeId, primitive] of pairs) {
|
|
511
|
+
expect(isTypingTarget(primitive), `${nativeId}: typing target`).toBe(
|
|
512
|
+
isTypingTarget(native(nativeId)),
|
|
513
|
+
);
|
|
514
|
+
expect(shouldIgnorePlaybackShortcutTarget(primitive), `${nativeId}: playback`).toBe(
|
|
515
|
+
shouldIgnorePlaybackShortcutTarget(native(nativeId)),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// The slider thumb in particular: arrow keys must step the slider rather
|
|
520
|
+
// than nudge the canvas selection, which only holds while its input counts
|
|
521
|
+
// as a typing target.
|
|
522
|
+
expect(isTypingTarget(host.querySelector("input[type='range']"))).toBe(true);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
@@ -13,69 +13,15 @@ export function usePanelLayoutContext(): PanelLayoutValue {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function PanelLayoutProvider({
|
|
16
|
-
value: {
|
|
17
|
-
leftWidth,
|
|
18
|
-
rightWidth,
|
|
19
|
-
adjustPanelWidth,
|
|
20
|
-
leftCollapsed,
|
|
21
|
-
rightCollapsed,
|
|
22
|
-
setRightCollapsed,
|
|
23
|
-
effectiveLeftCollapsed,
|
|
24
|
-
effectiveRightCollapsed,
|
|
25
|
-
rightPanelTab,
|
|
26
|
-
setRightPanelTab,
|
|
27
|
-
rightInspectorPanes,
|
|
28
|
-
toggleRightInspectorPane,
|
|
29
|
-
setExclusiveRightInspectorPane,
|
|
30
|
-
toggleLeftSidebar,
|
|
31
|
-
handlePanelResizeStart,
|
|
32
|
-
handlePanelResizeMove,
|
|
33
|
-
handlePanelResizeEnd,
|
|
34
|
-
},
|
|
16
|
+
value: { rightCollapsed, setRightCollapsed, rightPanelTab, setRightPanelTab },
|
|
35
17
|
children,
|
|
36
18
|
}: {
|
|
37
19
|
value: PanelLayoutValue;
|
|
38
20
|
children: ReactNode;
|
|
39
21
|
}) {
|
|
40
22
|
const stable = useMemo<PanelLayoutValue>(
|
|
41
|
-
() => ({
|
|
42
|
-
|
|
43
|
-
rightWidth,
|
|
44
|
-
adjustPanelWidth,
|
|
45
|
-
leftCollapsed,
|
|
46
|
-
rightCollapsed,
|
|
47
|
-
setRightCollapsed,
|
|
48
|
-
effectiveLeftCollapsed,
|
|
49
|
-
effectiveRightCollapsed,
|
|
50
|
-
rightPanelTab,
|
|
51
|
-
setRightPanelTab,
|
|
52
|
-
rightInspectorPanes,
|
|
53
|
-
toggleRightInspectorPane,
|
|
54
|
-
setExclusiveRightInspectorPane,
|
|
55
|
-
toggleLeftSidebar,
|
|
56
|
-
handlePanelResizeStart,
|
|
57
|
-
handlePanelResizeMove,
|
|
58
|
-
handlePanelResizeEnd,
|
|
59
|
-
}),
|
|
60
|
-
[
|
|
61
|
-
leftWidth,
|
|
62
|
-
rightWidth,
|
|
63
|
-
adjustPanelWidth,
|
|
64
|
-
leftCollapsed,
|
|
65
|
-
rightCollapsed,
|
|
66
|
-
setRightCollapsed,
|
|
67
|
-
effectiveLeftCollapsed,
|
|
68
|
-
effectiveRightCollapsed,
|
|
69
|
-
rightPanelTab,
|
|
70
|
-
setRightPanelTab,
|
|
71
|
-
rightInspectorPanes,
|
|
72
|
-
toggleRightInspectorPane,
|
|
73
|
-
setExclusiveRightInspectorPane,
|
|
74
|
-
toggleLeftSidebar,
|
|
75
|
-
handlePanelResizeStart,
|
|
76
|
-
handlePanelResizeMove,
|
|
77
|
-
handlePanelResizeEnd,
|
|
78
|
-
],
|
|
23
|
+
() => ({ rightCollapsed, setRightCollapsed, rightPanelTab, setRightPanelTab }),
|
|
24
|
+
[rightCollapsed, setRightCollapsed, rightPanelTab, setRightPanelTab],
|
|
79
25
|
);
|
|
80
26
|
return <PanelLayoutContext value={stable}>{children}</PanelLayoutContext>;
|
|
81
27
|
}
|
|
@@ -2,7 +2,7 @@ import { automationOwnsKey } from "./useAutomationSelectionKeyboard";
|
|
|
2
2
|
import { usePlayerStore } from "../player";
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
|
-
import
|
|
5
|
+
import { useDockLayoutStore } from "../components/dock/dockLayoutStore";
|
|
6
6
|
import { isTypingTarget } from "../utils/typingTarget";
|
|
7
7
|
import { isEditableTarget } from "../utils/timelineDiscovery";
|
|
8
8
|
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
|
@@ -52,7 +52,6 @@ export interface HotkeyCallbacks {
|
|
|
52
52
|
onToggleRecording?: () => void;
|
|
53
53
|
onGroupSelection?: () => void;
|
|
54
54
|
onUngroupSelection?: () => void;
|
|
55
|
-
leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
|
|
56
55
|
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
57
56
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
58
57
|
}
|
|
@@ -84,13 +83,13 @@ export function dispatchModifierKey(
|
|
|
84
83
|
if (event.key === "1") {
|
|
85
84
|
event.preventDefault();
|
|
86
85
|
trackStudioEvent("keyboard_shortcut", { action: "tab_compositions" });
|
|
87
|
-
|
|
86
|
+
useDockLayoutStore.getState().activatePanel("compositions");
|
|
88
87
|
return true;
|
|
89
88
|
}
|
|
90
89
|
if (event.key === "2") {
|
|
91
90
|
event.preventDefault();
|
|
92
91
|
trackStudioEvent("keyboard_shortcut", { action: "tab_assets" });
|
|
93
|
-
|
|
92
|
+
useDockLayoutStore.getState().activatePanel("assets");
|
|
94
93
|
return true;
|
|
95
94
|
}
|
|
96
95
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
|
+
|
|
4
|
+
/** A host's verdict on one element: editable, or blocked with a reason to show. */
|
|
5
|
+
export type TimelineEditPermission = true | { blocked: true; reason: string };
|
|
6
|
+
|
|
7
|
+
export type CanEditTimelineElement = (element: TimelineElement) => TimelineEditPermission;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Refuses a write when any target element is blocked, toasting the host's
|
|
11
|
+
* reason. Absent `canEdit` never refuses, so Studio itself is unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export function useTimelineEditGate(
|
|
14
|
+
canEdit: CanEditTimelineElement | undefined,
|
|
15
|
+
showToast: (message: string, tone?: "error" | "info") => void,
|
|
16
|
+
) {
|
|
17
|
+
return useCallback(
|
|
18
|
+
(targets: readonly TimelineElement[]): boolean => {
|
|
19
|
+
if (!canEdit) return true;
|
|
20
|
+
for (const element of targets) {
|
|
21
|
+
const verdict = canEdit(element);
|
|
22
|
+
if (verdict !== true) {
|
|
23
|
+
showToast(verdict.reason, "error");
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
},
|
|
29
|
+
[canEdit, showToast],
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -4,7 +4,6 @@ import React, { act, useRef } from "react";
|
|
|
4
4
|
import type { Root } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
7
|
-
import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
|
|
8
7
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
9
8
|
import { useAppHotkeys } from "./useAppHotkeys";
|
|
10
9
|
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
@@ -46,7 +45,6 @@ function Harness() {
|
|
|
46
45
|
showToast: vi.fn(),
|
|
47
46
|
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
|
48
47
|
waitForPendingDomEditSaves: vi.fn(async () => undefined),
|
|
49
|
-
leftSidebarRef: useRef<LeftSidebarHandle | null>(null),
|
|
50
48
|
handleCopy: vi.fn(() => false),
|
|
51
49
|
handlePaste: vi.fn(() => false),
|
|
52
50
|
handleCut: vi.fn(() => false),
|