@hyperframes/studio 0.8.97 → 0.8.99
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-Biu5_W0l.js → hyperframes-player-Rml34FRW.js} +1 -1
- package/dist/assets/{index-BdV5T-QI.js → index-CVDArg3m.js} +188 -188
- package/dist/assets/{index-B5Z-1RW9.js → index-C_fi-jTe.js} +1 -1
- package/dist/assets/{index-CWuozokW.js → index-D9YzDuBx.js} +1 -1
- package/dist/index.d.ts +16 -4
- package/dist/index.html +1 -1
- package/dist/index.js +758 -607
- package/dist/index.js.map +1 -1
- package/package.json +10 -9
- package/src/components/EditorShell.tsx +7 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
- package/src/components/editor/DomEditCropHandles.tsx +30 -62
- package/src/components/editor/DomEditOverlay.test.ts +2 -2
- package/src/components/editor/DomEditOverlay.tsx +0 -27
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
- package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
- package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
- package/src/components/editor/cropResize.test.ts +129 -0
- package/src/components/editor/cropResize.ts +143 -0
- package/src/components/editor/domEditOverlayCrop.ts +65 -15
- package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
- package/src/components/editor/domEditOverlayGestures.ts +2 -5
- package/src/components/editor/elementPositionOffset.ts +5 -4
- package/src/components/editor/manualEdits.test.ts +22 -8
- package/src/components/editor/manualEditsDom.ts +27 -11
- package/src/components/editor/manualEditsParsing.ts +0 -4
- package/src/components/editor/manualEditsSnapshot.ts +3 -0
- package/src/components/editor/manualOffsetDrag.test.ts +14 -0
- package/src/components/editor/propertyPanelFont.test.tsx +48 -5
- package/src/components/editor/propertyPanelFont.tsx +2 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
- package/src/components/nle/TimelinePane.tsx +4 -0
- package/src/hooks/domSelectionTestHarness.ts +8 -0
- package/src/hooks/draggedGsapPosition.ts +4 -2
- package/src/hooks/gsapDragCommit.ts +6 -6
- package/src/hooks/gsapEditOutcome.ts +6 -0
- package/src/hooks/gsapResizeIntercept.ts +5 -5
- package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
- package/src/hooks/gsapRuntimeBridge.ts +2 -1
- package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +34 -21
- package/src/hooks/timelineTimingSync.ts +40 -4
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
- package/src/hooks/useCommitPreflightCapabilities.ts +3 -11
- package/src/hooks/useCropOverlay.ts +8 -2
- package/src/hooks/useDomEditCommits.test.tsx +6 -1
- package/src/hooks/useDomEditCommits.ts +1 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommit.ts +1 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +112 -0
- package/src/hooks/useDomGeometryCommits.ts +5 -0
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
- package/src/hooks/useGsapAwareEditing.test.tsx +101 -5
- package/src/hooks/useGsapAwareEditing.ts +37 -23
- package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
- package/src/hooks/useGsapTweenCache.ts +0 -24
- package/src/hooks/useTimelineEditing.test.tsx +190 -135
- package/src/hooks/useTimelineEditing.ts +13 -13
- package/src/hooks/useTimelineGroupEditing.ts +13 -9
- package/src/index.ts +4 -1
- package/src/player/components/ClipContextMenu.test.tsx +182 -0
- package/src/player/components/ClipContextMenu.tsx +52 -10
- package/src/player/components/TimelineOverlays.test.ts +30 -7
- package/src/player/components/TimelineOverlays.tsx +7 -1
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTypes.ts +10 -0
- package/src/player/components/menuKeyboardNav.ts +12 -5
- package/src/player/components/useTimelineProviderState.tsx +2 -0
- package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
- package/src/utils/rounding.ts +5 -0
- package/src/utils/sdkEditTransaction.ts +10 -3
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, StrictMode, useRef, useState } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
6
|
+
import { ClipContextMenu } from "./ClipContextMenu";
|
|
7
|
+
import type { TimelineClipMenuItem } from "./TimelineTypes";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const element = {
|
|
16
|
+
id: "title",
|
|
17
|
+
tag: "div",
|
|
18
|
+
start: 0,
|
|
19
|
+
duration: 2,
|
|
20
|
+
track: 0,
|
|
21
|
+
} as unknown as TimelineElement;
|
|
22
|
+
|
|
23
|
+
function renderMenu(hostItems: readonly TimelineClipMenuItem[], onClose = vi.fn()) {
|
|
24
|
+
const host = document.createElement("div");
|
|
25
|
+
document.body.appendChild(host);
|
|
26
|
+
const root = createRoot(host);
|
|
27
|
+
act(() =>
|
|
28
|
+
root.render(
|
|
29
|
+
<ClipContextMenu
|
|
30
|
+
x={10}
|
|
31
|
+
y={10}
|
|
32
|
+
element={element}
|
|
33
|
+
currentTime={1}
|
|
34
|
+
onClose={onClose}
|
|
35
|
+
onSplit={vi.fn()}
|
|
36
|
+
onDelete={vi.fn()}
|
|
37
|
+
onCopy={() => true}
|
|
38
|
+
hostItems={hostItems}
|
|
39
|
+
/>,
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
const items = () =>
|
|
43
|
+
Array.from(document.body.querySelectorAll<HTMLButtonElement>("[role=menuitem]"));
|
|
44
|
+
return { items, unmount: () => act(() => root.unmount()) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("ClipContextMenu host items", () => {
|
|
48
|
+
it("lists the host's items first, with their icon and shortcut, and closes on a pick", () => {
|
|
49
|
+
const onSelect = vi.fn();
|
|
50
|
+
const onClose = vi.fn();
|
|
51
|
+
const { items, unmount } = renderMenu(
|
|
52
|
+
[{ id: "ask", label: "Ask", icon: <svg data-testid="ask-icon" />, shortcut: "A", onSelect }],
|
|
53
|
+
onClose,
|
|
54
|
+
);
|
|
55
|
+
expect(items().map((item) => item.textContent)).toEqual(["AskA", "Copy⌘C", "Delete⌫"]);
|
|
56
|
+
expect(items()[0]!.querySelector("[data-testid=ask-icon]")).not.toBeNull();
|
|
57
|
+
act(() => items()[0]!.click());
|
|
58
|
+
expect(onSelect).toHaveBeenCalledOnce();
|
|
59
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
60
|
+
unmount();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("counts host rows and their divider when keeping the menu inside the window", () => {
|
|
64
|
+
const innerHeight = Object.getOwnPropertyDescriptor(window, "innerHeight");
|
|
65
|
+
Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 });
|
|
66
|
+
try {
|
|
67
|
+
const host = document.createElement("div");
|
|
68
|
+
document.body.appendChild(host);
|
|
69
|
+
const root = createRoot(host);
|
|
70
|
+
const ask = { label: "Ask", onSelect: vi.fn() };
|
|
71
|
+
act(() =>
|
|
72
|
+
root.render(
|
|
73
|
+
<ClipContextMenu
|
|
74
|
+
x={10}
|
|
75
|
+
y={550}
|
|
76
|
+
element={element}
|
|
77
|
+
currentTime={1}
|
|
78
|
+
onClose={vi.fn()}
|
|
79
|
+
onSplit={vi.fn()}
|
|
80
|
+
onDelete={vi.fn()}
|
|
81
|
+
onCopy={() => true}
|
|
82
|
+
hostItems={[
|
|
83
|
+
{ id: "a", ...ask },
|
|
84
|
+
{ id: "b", ...ask },
|
|
85
|
+
]}
|
|
86
|
+
/>,
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
// Four 30 px rows, two 9 px dividers and 8 px of padding end 96 px below the window.
|
|
90
|
+
expect(document.body.querySelector<HTMLElement>("[role=menu]")!.style.top).toBe("446px");
|
|
91
|
+
act(() => root.unmount());
|
|
92
|
+
} finally {
|
|
93
|
+
if (innerHeight) Object.defineProperty(window, "innerHeight", innerHeight);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps a disabled host item inert", () => {
|
|
98
|
+
const onSelect = vi.fn();
|
|
99
|
+
const { items, unmount } = renderMenu([{ id: "ask", label: "Ask", disabled: true, onSelect }]);
|
|
100
|
+
expect(items()[0]!.disabled).toBe(true);
|
|
101
|
+
act(() => items()[0]!.click());
|
|
102
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
103
|
+
unmount();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("shows only Studio's items when the host adds none", () => {
|
|
107
|
+
const { items, unmount } = renderMenu([]);
|
|
108
|
+
expect(items().map((item) => item.textContent)).toEqual(["Copy⌘C", "Delete⌫"]);
|
|
109
|
+
expect(document.body.querySelectorAll(".border-t")).toHaveLength(1);
|
|
110
|
+
unmount();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** A clip button that opens the real menu, whose Ask moves the focus the way `focus` says. */
|
|
115
|
+
function FocusHost({ focus }: { focus: "existing" | "mounted" }) {
|
|
116
|
+
const [open, setOpen] = useState(false);
|
|
117
|
+
const [asked, setAsked] = useState(false);
|
|
118
|
+
const existing = useRef<HTMLInputElement>(null);
|
|
119
|
+
const ask = () => (focus === "existing" ? existing.current!.focus() : setAsked(true));
|
|
120
|
+
return (
|
|
121
|
+
<>
|
|
122
|
+
<button type="button" data-testid="clip" onClick={() => setOpen(true)} />
|
|
123
|
+
<input ref={existing} data-testid="existing" />
|
|
124
|
+
{asked && <input data-testid="mounted" autoFocus />}
|
|
125
|
+
{open && (
|
|
126
|
+
<ClipContextMenu
|
|
127
|
+
x={10}
|
|
128
|
+
y={10}
|
|
129
|
+
element={element}
|
|
130
|
+
currentTime={1}
|
|
131
|
+
onClose={() => setOpen(false)}
|
|
132
|
+
onSplit={vi.fn()}
|
|
133
|
+
onDelete={vi.fn()}
|
|
134
|
+
hostItems={[{ id: "ask", label: "Ask", onSelect: ask }]}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
137
|
+
</>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function openFrom(focus: "existing" | "mounted") {
|
|
142
|
+
const host = document.createElement("div");
|
|
143
|
+
document.body.appendChild(host);
|
|
144
|
+
const root = createRoot(host);
|
|
145
|
+
// StrictMode runs the menu's effect cleanup once while it is still mounted, as Studio's dev build does.
|
|
146
|
+
act(() =>
|
|
147
|
+
root.render(
|
|
148
|
+
<StrictMode>
|
|
149
|
+
<FocusHost focus={focus} />
|
|
150
|
+
</StrictMode>,
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
const clip = document.body.querySelector<HTMLButtonElement>("[data-testid=clip]")!;
|
|
154
|
+
clip.focus();
|
|
155
|
+
act(() => clip.click());
|
|
156
|
+
const menuItem = () => document.body.querySelector<HTMLButtonElement>("[role=menuitem]");
|
|
157
|
+
expect(document.activeElement).toBe(menuItem());
|
|
158
|
+
return { menuItem, unmount: () => act(() => root.unmount()) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const focusedId = () => (document.activeElement as HTMLElement | null)?.dataset.testid;
|
|
162
|
+
|
|
163
|
+
describe("ClipContextMenu focus on close", () => {
|
|
164
|
+
it.each(["existing", "mounted"] as const)(
|
|
165
|
+
"leaves the focus where a host item put it (%s field)",
|
|
166
|
+
(focus) => {
|
|
167
|
+
const { menuItem, unmount } = openFrom(focus);
|
|
168
|
+
act(() => menuItem()!.click());
|
|
169
|
+
expect(menuItem()).toBeNull();
|
|
170
|
+
expect(focusedId()).toBe(focus);
|
|
171
|
+
unmount();
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
it("gives the focus back to the clip when the menu closes without moving it", () => {
|
|
176
|
+
const { menuItem, unmount } = openFrom("existing");
|
|
177
|
+
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
|
|
178
|
+
expect(menuItem()).toBeNull();
|
|
179
|
+
expect(focusedId()).toBe("clip");
|
|
180
|
+
unmount();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -4,6 +4,7 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
|
4
4
|
import { canSplitElement } from "../../utils/timelineElementSplit";
|
|
5
5
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
6
6
|
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
7
|
+
import type { TimelineClipMenuItem } from "./TimelineTypes";
|
|
7
8
|
|
|
8
9
|
interface ClipContextMenuProps {
|
|
9
10
|
x: number;
|
|
@@ -17,6 +18,51 @@ interface ClipContextMenuProps {
|
|
|
17
18
|
onPaste?: () => Promise<void>;
|
|
18
19
|
onDuplicate?: () => Promise<boolean>;
|
|
19
20
|
canPaste?: boolean;
|
|
21
|
+
hostItems?: readonly TimelineClipMenuItem[] | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
|
|
25
|
+
const itemClass = (enabled: boolean) =>
|
|
26
|
+
`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
|
|
27
|
+
enabled
|
|
28
|
+
? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
29
|
+
: " text-neutral-600 cursor-not-allowed"
|
|
30
|
+
}`;
|
|
31
|
+
|
|
32
|
+
/** The host's items, above Studio's. A pick closes the menu; focus the item moves stays where it went. */
|
|
33
|
+
function HostItems({
|
|
34
|
+
items,
|
|
35
|
+
onClose,
|
|
36
|
+
}: {
|
|
37
|
+
items: readonly TimelineClipMenuItem[];
|
|
38
|
+
onClose: () => void;
|
|
39
|
+
}) {
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
{items.map((item) => (
|
|
43
|
+
<button
|
|
44
|
+
key={item.id}
|
|
45
|
+
type="button"
|
|
46
|
+
role="menuitem"
|
|
47
|
+
className={itemClass(!item.disabled)}
|
|
48
|
+
disabled={item.disabled}
|
|
49
|
+
onClick={() => {
|
|
50
|
+
onClose();
|
|
51
|
+
item.onSelect();
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<span className="flex items-center gap-2">
|
|
55
|
+
{item.icon}
|
|
56
|
+
{item.label}
|
|
57
|
+
</span>
|
|
58
|
+
{item.shortcut && (
|
|
59
|
+
<span className="text-neutral-500 text-[10px] ml-3">{item.shortcut}</span>
|
|
60
|
+
)}
|
|
61
|
+
</button>
|
|
62
|
+
))}
|
|
63
|
+
<div className="my-1 border-t border-neutral-700/60" />
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
20
66
|
}
|
|
21
67
|
|
|
22
68
|
// A menu with many independently gated items (Split/Delete/Copy/Paste/Duplicate).
|
|
@@ -33,6 +79,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
33
79
|
onPaste,
|
|
34
80
|
onDuplicate,
|
|
35
81
|
canPaste,
|
|
82
|
+
hostItems = [],
|
|
36
83
|
}: ClipContextMenuProps) {
|
|
37
84
|
const menuRef = useContextMenuDismiss(onClose);
|
|
38
85
|
useMenuKeyboardNav(menuRef);
|
|
@@ -57,22 +104,16 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
57
104
|
: "Split (move playhead inside clip)";
|
|
58
105
|
|
|
59
106
|
const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length;
|
|
60
|
-
const
|
|
61
|
-
const
|
|
107
|
+
const hostRows = hostItems.length;
|
|
108
|
+
const rowCount = hostRows + (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
|
|
109
|
+
const dividerCount =
|
|
110
|
+
(hostRows > 0 ? 1 : 0) + (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
|
|
62
111
|
const menuWidth = 200;
|
|
63
112
|
const menuHeight = rowCount * 30 + dividerCount * 9 + 8;
|
|
64
113
|
const overflowY = y + menuHeight - window.innerHeight;
|
|
65
114
|
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
66
115
|
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
|
|
67
116
|
|
|
68
|
-
// Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
|
|
69
|
-
const itemClass = (enabled: boolean) =>
|
|
70
|
-
`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
|
|
71
|
-
enabled
|
|
72
|
-
? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
73
|
-
: " text-neutral-600 cursor-not-allowed"
|
|
74
|
-
}`;
|
|
75
|
-
|
|
76
117
|
return createPortal(
|
|
77
118
|
<div
|
|
78
119
|
ref={menuRef}
|
|
@@ -81,6 +122,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
81
122
|
className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
82
123
|
style={{ left: adjustedX, top: adjustedY }}
|
|
83
124
|
>
|
|
125
|
+
{hostRows > 0 && <HostItems items={hostItems} onClose={onClose} />}
|
|
84
126
|
{splitLabel && (
|
|
85
127
|
<>
|
|
86
128
|
<button
|
|
@@ -71,11 +71,12 @@ describe("resolveTimelineContextElement", () => {
|
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
function
|
|
74
|
+
function renderOverlay(options: {
|
|
75
75
|
capturedElement: TimelineElement;
|
|
76
76
|
currentElement: TimelineElement;
|
|
77
77
|
setKfContextMenu?: ReturnType<typeof vi.fn>;
|
|
78
78
|
onDeleteAllKeyframes?: ReturnType<typeof vi.fn>;
|
|
79
|
+
overlays?: Partial<TimelineOverlaysState>;
|
|
79
80
|
}) {
|
|
80
81
|
const container = document.createElement("div");
|
|
81
82
|
document.body.appendChild(container);
|
|
@@ -117,6 +118,7 @@ function renderKeyframeOverlay(options: {
|
|
|
117
118
|
onCloseTrackGap: vi.fn(),
|
|
118
119
|
onCloseAllTrackGaps: vi.fn(),
|
|
119
120
|
onHoverGapAction: vi.fn(),
|
|
121
|
+
...options.overlays,
|
|
120
122
|
} satisfies TimelineOverlaysState;
|
|
121
123
|
const contextValue = {
|
|
122
124
|
state: {
|
|
@@ -151,22 +153,24 @@ function renderKeyframeOverlay(options: {
|
|
|
151
153
|
);
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
const render = () =>
|
|
157
|
+
root.render(
|
|
158
|
+
createElement(TestTimelineContext, { value: contextValue }, createElement(TimelineOverlays)),
|
|
159
|
+
);
|
|
154
160
|
act(() => {
|
|
155
161
|
usePlayerStore.setState({
|
|
156
162
|
selectedElementId: options.capturedElement.key ?? options.capturedElement.id,
|
|
157
163
|
timelineSessionEpoch: 2,
|
|
158
164
|
});
|
|
159
|
-
|
|
160
|
-
createElement(TestTimelineContext, { value: contextValue }, createElement(TimelineOverlays)),
|
|
161
|
-
);
|
|
165
|
+
render();
|
|
162
166
|
});
|
|
163
|
-
return { setKfContextMenu, onDeleteAllKeyframes };
|
|
167
|
+
return { setKfContextMenu, onDeleteAllKeyframes, rerender: () => act(render) };
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
describe("TimelineOverlays context lifecycle", () => {
|
|
167
171
|
it("dismisses a keyframe menu when its selected target becomes stale", () => {
|
|
168
172
|
const setKfContextMenu = vi.fn();
|
|
169
|
-
|
|
173
|
+
renderOverlay({
|
|
170
174
|
capturedElement: captured,
|
|
171
175
|
currentElement: captured,
|
|
172
176
|
setKfContextMenu,
|
|
@@ -180,7 +184,7 @@ describe("TimelineOverlays context lifecycle", () => {
|
|
|
180
184
|
it("dispatches a menu action with the current model element", () => {
|
|
181
185
|
const current = { ...captured, start: 4, track: 7 };
|
|
182
186
|
const onDeleteAllKeyframes = vi.fn();
|
|
183
|
-
|
|
187
|
+
renderOverlay({
|
|
184
188
|
capturedElement: captured,
|
|
185
189
|
currentElement: current,
|
|
186
190
|
onDeleteAllKeyframes,
|
|
@@ -193,4 +197,23 @@ describe("TimelineOverlays context lifecycle", () => {
|
|
|
193
197
|
|
|
194
198
|
expect(onDeleteAllKeyframes).toHaveBeenCalledExactlyOnceWith(current, "child-position");
|
|
195
199
|
});
|
|
200
|
+
|
|
201
|
+
it("builds the host's clip menu items once for the current model element and lists them first", () => {
|
|
202
|
+
const current = { ...captured, start: 4, track: 7 };
|
|
203
|
+
const clipMenuItems = vi.fn(() => [{ id: "ask", label: "Ask", onSelect: vi.fn() }]);
|
|
204
|
+
const { rerender } = renderOverlay({
|
|
205
|
+
capturedElement: captured,
|
|
206
|
+
currentElement: current,
|
|
207
|
+
overlays: {
|
|
208
|
+
kfContextMenu: null,
|
|
209
|
+
clipContextMenu: { x: 10, y: 10, sessionEpoch: 2, element: captured },
|
|
210
|
+
clipMenuItems,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
rerender();
|
|
215
|
+
|
|
216
|
+
expect(clipMenuItems).toHaveBeenCalledExactlyOnceWith(current);
|
|
217
|
+
expect(document.body.querySelector('[role="menuitem"]')?.textContent).toBe("Ask");
|
|
218
|
+
});
|
|
196
219
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { EditPopover } from "./EditModal";
|
|
4
4
|
import { KeyframeDiamondContextMenu } from "./KeyframeDiamondContextMenu";
|
|
@@ -152,6 +152,11 @@ export function TimelineClipMenuOverlay() {
|
|
|
152
152
|
useEffect(() => {
|
|
153
153
|
if (clipContextMenu && !element) setClipContextMenu(null);
|
|
154
154
|
}, [element, clipContextMenu, setClipContextMenu]);
|
|
155
|
+
const { clipMenuItems } = overlay;
|
|
156
|
+
const hostItems = useMemo(
|
|
157
|
+
() => (element ? clipMenuItems?.(element) : undefined),
|
|
158
|
+
[clipMenuItems, element],
|
|
159
|
+
);
|
|
155
160
|
if (!clipContextMenu || !element) return null;
|
|
156
161
|
const readCurrentElement = () =>
|
|
157
162
|
resolveTimelineContextElement({
|
|
@@ -183,6 +188,7 @@ export function TimelineClipMenuOverlay() {
|
|
|
183
188
|
onPaste={overlay.onPasteClip}
|
|
184
189
|
onDuplicate={overlay.onDuplicateClip}
|
|
185
190
|
canPaste={overlay.canPasteClip?.() ?? false}
|
|
191
|
+
hostItems={hostItems}
|
|
186
192
|
/>
|
|
187
193
|
);
|
|
188
194
|
}
|
|
@@ -111,6 +111,7 @@ export interface TimelineOverlaysState {
|
|
|
111
111
|
onPasteClip?: () => Promise<void>;
|
|
112
112
|
onDuplicateClip?: () => Promise<boolean>;
|
|
113
113
|
canPasteClip?: () => boolean;
|
|
114
|
+
clipMenuItems?: TimelineProps["clipMenuItems"];
|
|
114
115
|
gapContextMenu: TrackGapContextMenuState | null;
|
|
115
116
|
onDismissGapContextMenu: () => void;
|
|
116
117
|
onCloseTrackGap: () => void;
|
|
@@ -12,6 +12,15 @@ export interface TimelineClipRenderContext {
|
|
|
12
12
|
rich: boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface TimelineClipMenuItem {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
icon?: ReactNode;
|
|
19
|
+
shortcut?: string;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
onSelect: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
export interface TimelineProps
|
|
16
25
|
extends TimelineDropCallbacks, TimelineEditOverrides, TimelineStackingSyncProps {
|
|
17
26
|
/** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
|
|
@@ -33,6 +42,7 @@ export interface TimelineProps
|
|
|
33
42
|
onPasteClip?: () => Promise<void>;
|
|
34
43
|
onDuplicateClip?: () => Promise<boolean>;
|
|
35
44
|
canPasteClip?: () => boolean;
|
|
45
|
+
clipMenuItems?: (element: TimelineElement) => readonly TimelineClipMenuItem[];
|
|
36
46
|
theme?: Partial<TimelineTheme>;
|
|
37
47
|
showAudioEffects?: boolean;
|
|
38
48
|
showKeyframes?: boolean;
|
|
@@ -2,10 +2,10 @@ import { useEffect, type RefObject } from "react";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* APG menu keyboard basics for the timeline context menus: focuses the first
|
|
5
|
-
* menu item on open, moves focus with ArrowUp/ArrowDown/Home/End, and
|
|
6
|
-
* focus to the
|
|
7
|
-
* `role="menu"` on the container and `role="menuitem"`
|
|
8
|
-
* (dismiss/Escape handling stays in useContextMenuDismiss).
|
|
5
|
+
* menu item on open, moves focus with ArrowUp/ArrowDown/Home/End, and gives
|
|
6
|
+
* focus back to the element that held it at open, on unmount, unless an item moved
|
|
7
|
+
* it elsewhere. Pair with `role="menu"` on the container and `role="menuitem"`
|
|
8
|
+
* on the buttons (dismiss/Escape handling stays in useContextMenuDismiss).
|
|
9
9
|
*/
|
|
10
10
|
export function useMenuKeyboardNav(menuRef: RefObject<HTMLDivElement | null>): void {
|
|
11
11
|
useEffect(() => {
|
|
@@ -39,7 +39,14 @@ export function useMenuKeyboardNav(menuRef: RefObject<HTMLDivElement | null>): v
|
|
|
39
39
|
|
|
40
40
|
return () => {
|
|
41
41
|
menu.removeEventListener("keydown", onKeyDown);
|
|
42
|
-
|
|
42
|
+
const active = document.activeElement;
|
|
43
|
+
const focusLeftWithMenu =
|
|
44
|
+
active === null || active === document.body || menu.contains(active);
|
|
45
|
+
if (
|
|
46
|
+
focusLeftWithMenu &&
|
|
47
|
+
previouslyFocused instanceof HTMLElement &&
|
|
48
|
+
document.contains(previouslyFocused)
|
|
49
|
+
) {
|
|
43
50
|
previouslyFocused.focus();
|
|
44
51
|
}
|
|
45
52
|
};
|
|
@@ -71,6 +71,7 @@ export function useTimelineProviderState({
|
|
|
71
71
|
onPasteClip,
|
|
72
72
|
onDuplicateClip,
|
|
73
73
|
canPasteClip,
|
|
74
|
+
clipMenuItems,
|
|
74
75
|
theme: themeOverrides,
|
|
75
76
|
showAudioEffects = true,
|
|
76
77
|
showKeyframes = true,
|
|
@@ -379,6 +380,7 @@ export function useTimelineProviderState({
|
|
|
379
380
|
onPasteClip,
|
|
380
381
|
onDuplicateClip,
|
|
381
382
|
canPasteClip,
|
|
383
|
+
clipMenuItems,
|
|
382
384
|
onSelectElement,
|
|
383
385
|
selectedElementId,
|
|
384
386
|
setRangeSelectionRef,
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
elementScroll,
|
|
7
|
+
observeElementOffset,
|
|
8
|
+
observeElementRect,
|
|
9
|
+
Virtualizer,
|
|
10
|
+
} from "@tanstack/react-virtual";
|
|
11
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
6
12
|
import { createTimelineRowGeometry, RULER_H, TRACKS_TOP_PAD } from "./timelineLayout";
|
|
7
13
|
import { extractTimelineVirtualRowRange, useTimelineVirtualRows } from "./useTimelineVirtualRows";
|
|
8
14
|
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
|
@@ -203,4 +209,27 @@ describe("useTimelineVirtualRows", () => {
|
|
|
203
209
|
expect(rows.some((row) => row.index === 500)).toBe(false);
|
|
204
210
|
act(() => root.unmount());
|
|
205
211
|
});
|
|
212
|
+
|
|
213
|
+
// A scroll-end timer left running fires into the torn-down test window ("window is not defined").
|
|
214
|
+
it("gets no virtualizer change after unmount from a scroll just before it", async () => {
|
|
215
|
+
const scroll = createScrollElement(0);
|
|
216
|
+
const onChange = vi.fn();
|
|
217
|
+
const virtualizer = new Virtualizer<HTMLDivElement, Element>({
|
|
218
|
+
count: 100,
|
|
219
|
+
getScrollElement: () => scroll,
|
|
220
|
+
estimateSize: () => 48,
|
|
221
|
+
scrollToFn: elementScroll,
|
|
222
|
+
observeElementRect,
|
|
223
|
+
observeElementOffset,
|
|
224
|
+
onChange,
|
|
225
|
+
});
|
|
226
|
+
const unmount = virtualizer._didMount();
|
|
227
|
+
virtualizer._willUpdate();
|
|
228
|
+
scroll.dispatchEvent(new Event("scroll"));
|
|
229
|
+
unmount();
|
|
230
|
+
onChange.mockClear();
|
|
231
|
+
|
|
232
|
+
await new Promise((settle) => setTimeout(settle, 200));
|
|
233
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
234
|
+
});
|
|
206
235
|
});
|
package/src/utils/rounding.ts
CHANGED
|
@@ -3,6 +3,11 @@ export function roundTo3(val: number): number {
|
|
|
3
3
|
return Math.round(val * 1000) / 1000;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
/** Whole CSS px: GSAP parses a -50% translate against the integer offsetWidth/Height. */
|
|
7
|
+
export function roundToLayoutPx(val: number): number {
|
|
8
|
+
return Math.round(val);
|
|
9
|
+
}
|
|
10
|
+
|
|
6
11
|
/** Round to 2 decimal places (centisecond precision for timeline values). */
|
|
7
12
|
export function roundToCenti(val: number): number {
|
|
8
13
|
return Math.round(val * 100) / 100;
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
|
|
12
12
|
export type CutoverResult =
|
|
13
13
|
| { status: "declined"; reason: string }
|
|
14
|
-
| { status: "committed"; version: string }
|
|
14
|
+
| { status: "committed"; version: string; before: string; after: string }
|
|
15
15
|
| { status: "failed"; error: Error };
|
|
16
16
|
|
|
17
17
|
export interface SdkSessionPublication {
|
|
@@ -143,7 +143,9 @@ export function failedCutover(
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
/** Only an explicit decline may enter the legacy mutation backend. */
|
|
146
|
-
export function cutoverCommittedOrThrow(
|
|
146
|
+
export function cutoverCommittedOrThrow(
|
|
147
|
+
result: CutoverResult,
|
|
148
|
+
): result is Extract<CutoverResult, { status: "committed" }> {
|
|
147
149
|
if (result.status === "failed") throw result.error;
|
|
148
150
|
return result.status === "committed";
|
|
149
151
|
}
|
|
@@ -300,7 +302,12 @@ async function commitCandidateEdit(
|
|
|
300
302
|
});
|
|
301
303
|
}
|
|
302
304
|
if (refreshTarget) refreshCommittedEdit(edit.after, deps, options);
|
|
303
|
-
return {
|
|
305
|
+
return {
|
|
306
|
+
status: "committed",
|
|
307
|
+
version: hashContent(edit.after),
|
|
308
|
+
before: originalContent,
|
|
309
|
+
after: edit.after,
|
|
310
|
+
};
|
|
304
311
|
}
|
|
305
312
|
|
|
306
313
|
export async function persistSdkCandidateMutation(
|