@hyperframes/studio 0.8.105 → 0.8.106
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-tnfCiVdR.js → hyperframes-player-dl6K6ABp.js} +1 -1
- package/dist/assets/{index-DEDhPmPD.js → index-CMiYVGv5.js} +202 -202
- package/dist/assets/{index-zpe332hb.js → index-DKT5NF1Y.js} +1 -1
- package/dist/assets/index-lrT-gfHa.css +1 -0
- package/dist/assets/{index-Ca6LZlVb.js → index-ooaZZhKf.js} +1 -1
- package/dist/{chunk-QADHJFDF.js → chunk-ICTZJPQQ.js} +2 -1
- package/dist/chunk-ICTZJPQQ.js.map +1 -0
- package/dist/index.d.ts +15 -4
- package/dist/index.html +11 -2
- package/dist/index.js +6601 -6022
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +9 -9
- package/src/captions/keyboard.test.ts +6 -0
- package/src/captions/keyboard.ts +2 -1
- package/src/components/StudioHeader.dom.test.tsx +31 -9
- package/src/components/StudioHeader.tsx +4 -1
- package/src/components/ThemeToggle.tsx +99 -0
- package/src/components/editor/DomEditOverlay.tsx +37 -5
- package/src/components/editor/SourceEditor.tsx +2 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +127 -115
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +275 -0
- package/src/components/editor/manualEditsDom.ts +33 -0
- package/src/components/editor/marqueeCommit.test.tsx +69 -1
- package/src/components/editor/marqueeCommit.ts +6 -1
- package/src/components/editor/overlayFrameLoop.test.ts +27 -0
- package/src/components/editor/overlayFrameLoop.ts +21 -0
- package/src/components/editor/propertyPanelFont.test.tsx +80 -3
- package/src/components/editor/propertyPanelFont.tsx +69 -46
- package/src/components/editor/propertyPanelHelpers.ts +1 -2
- package/src/components/editor/studioCodeTheme.test.ts +20 -0
- package/src/components/editor/studioCodeTheme.ts +99 -0
- package/src/components/editor/useDomEditNudge.test.tsx +31 -0
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useInlineTextEditing.tsx +1 -1
- package/src/components/editor/useMotionPathData.test.tsx +64 -0
- package/src/components/editor/useMotionPathData.ts +5 -1
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/previewPromotionSelection.test.tsx +180 -0
- package/src/hooks/useAppHotkeys.test.ts +32 -0
- package/src/hooks/useAppHotkeys.ts +1 -2
- package/src/hooks/useClipboard.source.test.ts +99 -19
- package/src/hooks/useClipboard.ts +145 -124
- package/src/hooks/useDomEditSession.test.tsx +76 -72
- package/src/hooks/useDomEditSession.ts +7 -43
- package/src/hooks/useGsapTweenCache.ts +1 -2
- package/src/hooks/useInlineTextEdit.ts +16 -2
- package/src/hooks/useLivePreviewIframe.ts +18 -0
- package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
- package/src/hooks/usePreviewPersistence.ts +2 -5
- package/src/hooks/useRenderClipContent.ts +3 -3
- package/src/index.ts +2 -0
- package/src/livePreviewIframeExport.test.tsx +33 -0
- package/src/main.tsx +4 -1
- package/src/player/components/AudioWaveform.test.tsx +36 -15
- package/src/player/components/AudioWaveform.tsx +24 -7
- package/src/player/components/TimelineClip.test.tsx +38 -0
- package/src/player/components/TimelineClip.tsx +14 -6
- package/src/player/components/TimelineClipFades.test.tsx +376 -74
- package/src/player/components/TimelineClipFades.tsx +368 -137
- package/src/player/components/useTimelineKeyboardActor.ts +1 -1
- package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
- package/src/player/hooks/useShadowPreviewReload.ts +72 -5
- package/src/player/lib/automationStoreSync.test.ts +18 -1
- package/src/player/lib/automationStoreSync.ts +29 -17
- package/src/player/sceneSwap.ts +18 -0
- package/src/styles/components.css +26 -0
- package/src/styles/studio.css +6 -0
- package/src/styles/theme.css +10 -0
- package/src/utils/clipboardPayload.ts +10 -1
- package/src/utils/studioPendingEdits.ts +21 -0
- package/src/utils/studioTheme.test.tsx +141 -0
- package/src/utils/studioTheme.ts +34 -0
- package/src/utils/studioUiPreferences.ts +3 -0
- package/src/utils/typingTarget.test.ts +8 -1
- package/src/utils/typingTarget.ts +5 -0
- package/dist/assets/index-Cre9X4RS.css +0 -1
- package/dist/chunk-QADHJFDF.js.map +0 -1
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { makeSelection } from "../../hooks/domSelectionTestHarness";
|
|
7
|
+
import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
|
|
8
|
+
import {
|
|
9
|
+
makePreview,
|
|
10
|
+
mountPlayerWithPreview,
|
|
11
|
+
paintShadow,
|
|
12
|
+
resetPlayerStore,
|
|
13
|
+
type TimelinePlayerApi,
|
|
14
|
+
} from "../../player/hooks/timelinePlayerTestHarness";
|
|
15
|
+
import type { DomEditSelection } from "./domEditing";
|
|
16
|
+
import "./domEditOverlayTestMocks";
|
|
17
|
+
import { DomEditOverlay } from "./DomEditOverlay";
|
|
18
|
+
import { PreviewReadOnlyProvider } from "./previewReadOnlyContext";
|
|
19
|
+
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "./manualEditsTypes";
|
|
20
|
+
|
|
21
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
22
|
+
|
|
23
|
+
const layout = vi.hoisted(() => ({
|
|
24
|
+
group: [] as unknown[],
|
|
25
|
+
rect: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
|
|
26
|
+
}));
|
|
27
|
+
vi.mock("./useDomEditOverlayRects", () => ({
|
|
28
|
+
useDomEditOverlayRects: () => {
|
|
29
|
+
const { rect, group } = layout;
|
|
30
|
+
const groupOverlayItemsRef = { current: group };
|
|
31
|
+
const noop = () => undefined;
|
|
32
|
+
return {
|
|
33
|
+
overlayRect: rect,
|
|
34
|
+
overlayRectRef: { current: rect },
|
|
35
|
+
groupOverlayItems: group,
|
|
36
|
+
groupOverlayItemsRef,
|
|
37
|
+
hoverRect: null,
|
|
38
|
+
childRects: [],
|
|
39
|
+
setOverlayRect: noop,
|
|
40
|
+
setGroupOverlayItems: noop,
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
vi.mock("../../utils/gsapSoftReload", async (importOriginal) => ({
|
|
45
|
+
...(await importOriginal<typeof import("../../utils/gsapSoftReload")>()),
|
|
46
|
+
ensureMotionPathPluginLoaded: vi.fn(),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const BOX = '[data-dom-edit-selection-box="true"]';
|
|
50
|
+
const RECT = layout.rect;
|
|
51
|
+
const TWO_LAYERS = '<h1 id="title">Title</h1><p id="sub">Sub</p>';
|
|
52
|
+
|
|
53
|
+
/** What the server serves: the file as the last commit left it. */
|
|
54
|
+
let file = { title: "", sub: "" };
|
|
55
|
+
const served = (query: string) =>
|
|
56
|
+
makePreview(
|
|
57
|
+
`<h1 id="title" style="${file.title}">Title</h1><p id="sub" style="${file.sub}">Sub</p>`,
|
|
58
|
+
query,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
let overlayRoot: Root;
|
|
62
|
+
let player: ReturnType<typeof mountPlayerWithPreview>;
|
|
63
|
+
|
|
64
|
+
const marked = (doc: Document) => doc.querySelectorAll(`[${STUDIO_MANUAL_EDIT_GESTURE_ATTR}]`);
|
|
65
|
+
const byId = (iframe: HTMLIFrameElement | null, id: string) =>
|
|
66
|
+
iframe!.contentDocument!.getElementById(id) as HTMLElement;
|
|
67
|
+
const api = (): TimelinePlayerApi => player.getApi();
|
|
68
|
+
|
|
69
|
+
function pointer(target: Element, type: string, x: number, y: number) {
|
|
70
|
+
const init = { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: x, clientY: y };
|
|
71
|
+
act(() => void target.dispatchEvent(new PointerEvent(type, init)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The real player with a two-layer preview, and the real overlay editing it. */
|
|
75
|
+
function mountEditor(group: boolean) {
|
|
76
|
+
file = { title: "", sub: "" };
|
|
77
|
+
const live = makePreview(TWO_LAYERS);
|
|
78
|
+
player = mountPlayerWithPreview(live);
|
|
79
|
+
const selections = ["title", "sub"].map((id) => makeSelection(id, byId(live, id)));
|
|
80
|
+
layout.group = group
|
|
81
|
+
? selections.map((s) => ({ key: s.id, selection: s, element: s.element, rect: RECT }))
|
|
82
|
+
: [];
|
|
83
|
+
const write = (sel: DomEditSelection, next: { x: number; y: number }) => {
|
|
84
|
+
file = { ...file, [sel.id!]: `translate: ${next.x}px ${next.y}px` };
|
|
85
|
+
return Promise.resolve();
|
|
86
|
+
};
|
|
87
|
+
const onPathOffsetCommit = vi.fn(write);
|
|
88
|
+
const onGroupPathOffsetCommit = vi.fn(
|
|
89
|
+
async (updates: { selection: DomEditSelection; next: { x: number; y: number } }[]) => {
|
|
90
|
+
for (const { selection, next } of updates) await write(selection, next);
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
const host = document.body.appendChild(document.createElement("div"));
|
|
94
|
+
overlayRoot = createRoot(host);
|
|
95
|
+
const render = (readOnly: boolean) =>
|
|
96
|
+
act(() =>
|
|
97
|
+
overlayRoot.render(
|
|
98
|
+
<PreviewReadOnlyProvider readOnly={readOnly}>
|
|
99
|
+
<DomEditOverlay
|
|
100
|
+
iframeRef={api().iframeRef}
|
|
101
|
+
activeCompositionPath={null}
|
|
102
|
+
selection={group ? null : selections[0]!}
|
|
103
|
+
groupSelections={group ? selections : []}
|
|
104
|
+
hoverSelection={null}
|
|
105
|
+
onCanvasMouseDown={() => undefined}
|
|
106
|
+
onCanvasPointerMove={() => Promise.resolve(null)}
|
|
107
|
+
onCanvasPointerLeave={() => undefined}
|
|
108
|
+
onSelectionChange={() => undefined}
|
|
109
|
+
onBlockedMove={() => undefined}
|
|
110
|
+
onPathOffsetCommit={onPathOffsetCommit}
|
|
111
|
+
onGroupPathOffsetCommit={onGroupPathOffsetCommit}
|
|
112
|
+
onBoxSizeCommit={() => undefined}
|
|
113
|
+
onRotationCommit={() => undefined}
|
|
114
|
+
/>
|
|
115
|
+
</PreviewReadOnlyProvider>,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
render(false);
|
|
119
|
+
const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLElement;
|
|
120
|
+
return { live, overlay, box: overlay.querySelector(BOX)!, onPathOffsetCommit, render };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A reload whose shadow paints while the gesture is still live. */
|
|
124
|
+
async function reloadMidGesture() {
|
|
125
|
+
act(() => api().refreshPlayer());
|
|
126
|
+
const shadow = served("?_t=1");
|
|
127
|
+
const gen = await paintShadow(api, shadow);
|
|
128
|
+
return { shadow, gen };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function settle() {
|
|
132
|
+
await act(async () => {
|
|
133
|
+
for (let tick = 0; tick < 10; tick++) await Promise.resolve();
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
beforeEach(() => {
|
|
138
|
+
HTMLElement.prototype.setPointerCapture = () => undefined;
|
|
139
|
+
HTMLElement.prototype.releasePointerCapture = () => undefined;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
afterEach(() => {
|
|
143
|
+
act(() => overlayRoot?.unmount());
|
|
144
|
+
act(() => player?.root.unmount());
|
|
145
|
+
layout.group = [];
|
|
146
|
+
document.body.innerHTML = "";
|
|
147
|
+
resetPlayerStore();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("a reload during a drag", () => {
|
|
151
|
+
it.each([
|
|
152
|
+
["one layer", false],
|
|
153
|
+
["a group", true],
|
|
154
|
+
])(
|
|
155
|
+
"that paints before the drop of %s waits, then shows the drop from a fresh load",
|
|
156
|
+
async (_, group) => {
|
|
157
|
+
const { live, overlay, box } = mountEditor(group);
|
|
158
|
+
pointer(box, "pointerdown", 150, 150);
|
|
159
|
+
pointer(overlay, "pointermove", 170, 160);
|
|
160
|
+
pointer(overlay, "pointermove", 190, 170);
|
|
161
|
+
const held = await reloadMidGesture();
|
|
162
|
+
expect(api().iframeRef.current, "promoted under the pointer").toBe(live);
|
|
163
|
+
|
|
164
|
+
pointer(overlay, "pointerup", 190, 170);
|
|
165
|
+
await settle();
|
|
166
|
+
expect(byId(live, "title").style.getPropertyValue("translate")).toBe("40px 20px");
|
|
167
|
+
expect(file.title).toBe("translate: 40px 20px");
|
|
168
|
+
if (group) expect(file.sub).toBe("translate: 40px 20px");
|
|
169
|
+
expect(marked(live.contentDocument!)).toHaveLength(0);
|
|
170
|
+
expect(api().iframeRef.current, "the shadow loaded before the drop").toBe(live);
|
|
171
|
+
|
|
172
|
+
const fresh = served("?_t=2");
|
|
173
|
+
expect(await paintShadow(api, fresh)).toBeGreaterThan(held.gen);
|
|
174
|
+
expect(api().iframeRef.current).toBe(fresh);
|
|
175
|
+
expect(byId(fresh, "title").style.getPropertyValue("translate")).toBe("40px 20px");
|
|
176
|
+
if (group) expect(byId(fresh, "sub").style.getPropertyValue("translate")).toBe("40px 20px");
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
it("that paints after the drop shows the drop from a fresh load, not the file before it", async () => {
|
|
181
|
+
const { live, overlay, box } = mountEditor(false);
|
|
182
|
+
pointer(box, "pointerdown", 150, 150);
|
|
183
|
+
pointer(overlay, "pointermove", 190, 170);
|
|
184
|
+
act(() => api().refreshPlayer());
|
|
185
|
+
const beforeDrop = served("?_t=1");
|
|
186
|
+
pointer(overlay, "pointerup", 190, 170);
|
|
187
|
+
await settle();
|
|
188
|
+
expect(file.title).toBe("translate: 40px 20px");
|
|
189
|
+
|
|
190
|
+
const requested = await paintShadow(api, beforeDrop);
|
|
191
|
+
expect(api().iframeRef.current, "the file before the drop").toBe(live);
|
|
192
|
+
const fresh = served("?_t=2");
|
|
193
|
+
expect(await paintShadow(api, fresh)).toBeGreaterThan(requested);
|
|
194
|
+
expect(byId(fresh, "title").style.getPropertyValue("translate")).toBe("40px 20px");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
type Editor = ReturnType<typeof mountEditor>;
|
|
199
|
+
|
|
200
|
+
describe("every way a drag ends without a drop clears its mark and promotes the held reload", () => {
|
|
201
|
+
it.each([
|
|
202
|
+
["pointercancel", (e: Editor) => pointer(e.overlay, "pointercancel", 0, 0)],
|
|
203
|
+
["lostpointercapture", (e: Editor) => pointer(e.box, "lostpointercapture", 0, 0)],
|
|
204
|
+
["window blur", () => act(() => void window.dispatchEvent(new Event("blur")))],
|
|
205
|
+
["a switch to read-only", (e: Editor) => e.render(true)],
|
|
206
|
+
["overlay unmount", () => act(() => overlayRoot.unmount())],
|
|
207
|
+
])("%s", async (_, end) => {
|
|
208
|
+
const editor = mountEditor(false);
|
|
209
|
+
pointer(editor.box, "pointerdown", 150, 150);
|
|
210
|
+
pointer(editor.overlay, "pointermove", 190, 170);
|
|
211
|
+
expect(marked(editor.live.contentDocument!)).toHaveLength(1);
|
|
212
|
+
const held = await reloadMidGesture();
|
|
213
|
+
expect(api().iframeRef.current).toBe(editor.live);
|
|
214
|
+
|
|
215
|
+
end(editor);
|
|
216
|
+
await settle();
|
|
217
|
+
expect(marked(editor.live.contentDocument!)).toHaveLength(0);
|
|
218
|
+
expect(byId(editor.live, "title").style.getPropertyValue("translate")).not.toBe("40px 20px");
|
|
219
|
+
expect(editor.onPathOffsetCommit).not.toHaveBeenCalled();
|
|
220
|
+
expect(api().iframeRef.current, "nothing was saved, so the held copy is current").toBe(
|
|
221
|
+
held.shadow,
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("a press that throws before the drag is armed leaves no mark behind", async () => {
|
|
226
|
+
const editor = mountEditor(false);
|
|
227
|
+
HTMLElement.prototype.setPointerCapture = () => {
|
|
228
|
+
throw new Error("capture refused");
|
|
229
|
+
};
|
|
230
|
+
const swallow = (event: ErrorEvent) => event.preventDefault();
|
|
231
|
+
window.addEventListener("error", swallow);
|
|
232
|
+
try {
|
|
233
|
+
pointer(editor.box, "pointerdown", 150, 150);
|
|
234
|
+
} catch {
|
|
235
|
+
// React may rethrow the handler's error; either way the mark must be gone.
|
|
236
|
+
} finally {
|
|
237
|
+
window.removeEventListener("error", swallow);
|
|
238
|
+
}
|
|
239
|
+
expect(marked(editor.live.contentDocument!)).toHaveLength(0);
|
|
240
|
+
const { shadow } = await reloadMidGesture();
|
|
241
|
+
expect(api().iframeRef.current).toBe(shadow);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("an inline text edit", () => {
|
|
246
|
+
it("holds a reload while open, and the reload after it shows the saved text", async () => {
|
|
247
|
+
const live = makePreview(TWO_LAYERS);
|
|
248
|
+
player = mountPlayerWithPreview(live);
|
|
249
|
+
let land: () => void = () => {};
|
|
250
|
+
const onCommit = vi.fn(() => new Promise<void>((resolve) => (land = resolve)));
|
|
251
|
+
let controls: ReturnType<typeof useInlineTextEdit> | null = null;
|
|
252
|
+
function Editor() {
|
|
253
|
+
controls = useInlineTextEdit({ onCommit });
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
overlayRoot = createRoot(document.body.appendChild(document.createElement("div")));
|
|
257
|
+
act(() => overlayRoot.render(<Editor />));
|
|
258
|
+
act(() => void controls!.start(byId(live, "title")));
|
|
259
|
+
const held = await reloadMidGesture();
|
|
260
|
+
expect(api().iframeRef.current).toBe(live);
|
|
261
|
+
|
|
262
|
+
act(() => controls!.commit());
|
|
263
|
+
await settle();
|
|
264
|
+
expect(onCommit).toHaveBeenCalledTimes(1);
|
|
265
|
+
expect(marked(live.contentDocument!), "closed, though the save is still out").toHaveLength(0);
|
|
266
|
+
expect(api().iframeRef.current, "the held copy predates the text").toBe(live);
|
|
267
|
+
expect(api().previewSlots.find((slot) => slot.role === "shadow")?.gen).toBe(held.gen);
|
|
268
|
+
|
|
269
|
+
await act(async () => land());
|
|
270
|
+
await settle();
|
|
271
|
+
const afterSave = served("?_t=2");
|
|
272
|
+
expect(await paintShadow(api, afterSave)).toBeGreaterThan(held.gen);
|
|
273
|
+
expect(api().iframeRef.current).toBe(afterSave);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -46,9 +46,42 @@ export function beginStudioManualEditGesture(element: HTMLElement): string {
|
|
|
46
46
|
return token;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
const GESTURE_ENDED = "hf-manual-edit-gesture-ended";
|
|
50
|
+
|
|
49
51
|
export function endStudioManualEditGesture(element: HTMLElement, token?: string): void {
|
|
50
52
|
if (token && element.getAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR) !== token) return;
|
|
53
|
+
if (!element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
|
|
51
54
|
element.removeAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
|
|
55
|
+
const doc = element.ownerDocument;
|
|
56
|
+
doc.dispatchEvent(new (doc.defaultView?.Event ?? Event)(GESTURE_ENDED));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isStudioManualEditGestureLiveIn(doc: Document): boolean {
|
|
60
|
+
return doc.querySelector(`[${STUDIO_MANUAL_EDIT_GESTURE_ATTR}]`) !== null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Runs `run` once the last gesture in `doc` ends; the returned function stops waiting. */
|
|
64
|
+
export function afterStudioManualEditGestures(doc: Document, run: () => void): () => void {
|
|
65
|
+
const onEnded = () => {
|
|
66
|
+
if (isStudioManualEditGestureLiveIn(doc)) return;
|
|
67
|
+
stop();
|
|
68
|
+
run();
|
|
69
|
+
};
|
|
70
|
+
const stop = () => doc.removeEventListener(GESTURE_ENDED, onEnded);
|
|
71
|
+
doc.addEventListener(GESTURE_ENDED, onEnded);
|
|
72
|
+
return stop;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const gestureSaves = new WeakMap<Document, number>();
|
|
76
|
+
|
|
77
|
+
export function studioManualEditSavesIn(doc: Document): number {
|
|
78
|
+
return gestureSaves.get(doc) ?? 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A gesture's save in `element`'s document: a reload requested before it shows the file before it. */
|
|
82
|
+
export function noteStudioManualEditSave(element: HTMLElement): void {
|
|
83
|
+
const doc = element.ownerDocument;
|
|
84
|
+
gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
|
|
52
85
|
}
|
|
53
86
|
|
|
54
87
|
function isStudioManualEditGestureActive(element: HTMLElement): boolean {
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { act, useRef } from "react";
|
|
3
3
|
import type { Root } from "react-dom/client";
|
|
4
|
-
import { afterEach, beforeEach, expect, it } from "vitest";
|
|
4
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
5
5
|
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
|
6
6
|
import { useMarqueeGestures } from "./marqueeCommit";
|
|
7
7
|
|
|
8
|
+
// Layout stands in for a real preview: every element is visible and sits at the band's start.
|
|
9
|
+
vi.mock("./domEditingElement", async (importOriginal) => ({
|
|
10
|
+
...(await importOriginal<typeof import("./domEditingElement")>()),
|
|
11
|
+
isElementComputedVisible: () => true,
|
|
12
|
+
}));
|
|
13
|
+
vi.mock("./domEditOverlayGeometry", async (importOriginal) => ({
|
|
14
|
+
...(await importOriginal<typeof import("./domEditOverlayGeometry")>()),
|
|
15
|
+
toVisibleOverlayRect: () => ({ left: 10, top: 10, width: 40, height: 40 }),
|
|
16
|
+
}));
|
|
17
|
+
|
|
8
18
|
installReactActEnvironment();
|
|
9
19
|
HTMLElement.prototype.setPointerCapture ??= () => {};
|
|
10
20
|
HTMLElement.prototype.releasePointerCapture ??= () => {};
|
|
@@ -82,3 +92,61 @@ it("an escape with no band in flight still reaches the host", () => {
|
|
|
82
92
|
expect(event.defaultPrevented).toBe(false);
|
|
83
93
|
expect(hostEscapes).toHaveLength(1);
|
|
84
94
|
});
|
|
95
|
+
|
|
96
|
+
it("a reload promoted mid-band selects from the preview on screen, not the retired one", async () => {
|
|
97
|
+
const preview = () => {
|
|
98
|
+
const iframe = document.createElement("iframe");
|
|
99
|
+
const doc = document.implementation.createHTMLDocument("preview");
|
|
100
|
+
doc.body.innerHTML = '<div data-composition-id="main"><h1 id="title">Title</h1></div>';
|
|
101
|
+
Object.defineProperty(iframe, "contentDocument", { value: doc });
|
|
102
|
+
return iframe;
|
|
103
|
+
};
|
|
104
|
+
const [retired, live] = [preview(), preview()];
|
|
105
|
+
const picked: HTMLElement[][] = [];
|
|
106
|
+
const iframeRef = { current: retired as HTMLIFrameElement | null };
|
|
107
|
+
function Band() {
|
|
108
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
109
|
+
const marquee = useMarqueeGestures({
|
|
110
|
+
iframeRef,
|
|
111
|
+
overlayRef,
|
|
112
|
+
activeCompositionPathRef: useRef<string | null>("index.html"),
|
|
113
|
+
onMarqueeSelectRef: useRef(() => undefined),
|
|
114
|
+
resolveHits: async (elements: HTMLElement[]) => {
|
|
115
|
+
picked.push(elements);
|
|
116
|
+
return [];
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
ref={overlayRef}
|
|
122
|
+
data-band-overlay
|
|
123
|
+
onPointerDown={marquee.begin}
|
|
124
|
+
onPointerMove={marquee.onPointerMove}
|
|
125
|
+
onPointerUp={marquee.onPointerUp}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const band = mountReactHarness(<Band />);
|
|
130
|
+
const fire = (type: string, x: number, y: number) =>
|
|
131
|
+
act(() => {
|
|
132
|
+
document.querySelector("[data-band-overlay]")!.dispatchEvent(
|
|
133
|
+
new PointerEvent(type, {
|
|
134
|
+
bubbles: true,
|
|
135
|
+
button: 0,
|
|
136
|
+
pointerId: 1,
|
|
137
|
+
clientX: x,
|
|
138
|
+
clientY: y,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
fire("pointerdown", 0, 0);
|
|
144
|
+
fire("pointermove", 120, 90);
|
|
145
|
+
iframeRef.current = live;
|
|
146
|
+
await act(async () => fire("pointerup", 120, 90));
|
|
147
|
+
expect(picked.at(-1)?.length).toBeGreaterThan(0);
|
|
148
|
+
for (const element of picked.at(-1)!) expect(element.ownerDocument).toBe(live.contentDocument);
|
|
149
|
+
} finally {
|
|
150
|
+
act(() => band.unmount());
|
|
151
|
+
}
|
|
152
|
+
});
|
|
@@ -159,7 +159,12 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
|
|
|
159
159
|
const overlay = deps.overlayRef.current;
|
|
160
160
|
if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
|
|
161
161
|
const acp = deps.activeCompositionPathRef.current ?? "index.html";
|
|
162
|
-
const
|
|
162
|
+
const cached = candidatesRef.current;
|
|
163
|
+
const measuredOnScreen = cached?.every(
|
|
164
|
+
(hit) => hit.element.ownerDocument === iframe.contentDocument,
|
|
165
|
+
);
|
|
166
|
+
const candidates =
|
|
167
|
+
cached && measuredOnScreen ? cached : collectMarqueeCandidates(iframe, overlay, acp);
|
|
163
168
|
const elements = hitsWithin(rect, candidates).map((hit) => hit.element);
|
|
164
169
|
const resolveHits = deps.resolveHits;
|
|
165
170
|
const picks = resolveHits
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import {
|
|
5
5
|
IDLE_POLL_MS,
|
|
6
|
+
IDLE_SLICE_MS,
|
|
6
7
|
requestOverlayFrames,
|
|
7
8
|
resetOverlayFrameLoopForTests,
|
|
9
|
+
runWhenInputIdle,
|
|
8
10
|
subscribeOverlayFrame,
|
|
9
11
|
} from "./overlayFrameLoop";
|
|
10
12
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
@@ -71,6 +73,31 @@ describe("overlay frame loop", () => {
|
|
|
71
73
|
expect(runs).toBeGreaterThan(0);
|
|
72
74
|
});
|
|
73
75
|
|
|
76
|
+
it("runs idle work in capped slices, only while no input arrives", () => {
|
|
77
|
+
const budgets: number[] = [];
|
|
78
|
+
requestOverlayFrames();
|
|
79
|
+
runWhenInputIdle((timeLeft) => {
|
|
80
|
+
budgets.push(timeLeft());
|
|
81
|
+
return budgets.length === 3;
|
|
82
|
+
});
|
|
83
|
+
vi.advanceTimersByTime(300);
|
|
84
|
+
expect(budgets).toEqual([]);
|
|
85
|
+
vi.advanceTimersByTime(200);
|
|
86
|
+
expect(budgets).toHaveLength(3);
|
|
87
|
+
expect(Math.max(...budgets)).toBeLessThanOrEqual(IDLE_SLICE_MS);
|
|
88
|
+
|
|
89
|
+
// Input during a slice holds the next one until the loop has been idle again.
|
|
90
|
+
const slices: number[] = [];
|
|
91
|
+
runWhenInputIdle(() => {
|
|
92
|
+
slices.push(performance.now());
|
|
93
|
+
if (slices.length === 1) requestOverlayFrames();
|
|
94
|
+
return slices.length === 2;
|
|
95
|
+
});
|
|
96
|
+
vi.advanceTimersByTime(1000);
|
|
97
|
+
expect(slices).toHaveLength(2);
|
|
98
|
+
expect(slices[1] - slices[0]).toBeGreaterThanOrEqual(400);
|
|
99
|
+
});
|
|
100
|
+
|
|
74
101
|
it("runs every subscriber on one frame, not one frame each", () => {
|
|
75
102
|
const calls: string[] = [];
|
|
76
103
|
subscribeOverlayFrame(() => calls.push("a"));
|
|
@@ -143,6 +143,27 @@ function schedule(): void {
|
|
|
143
143
|
}, IDLE_POLL_MS);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/** Longest idle slice, so input that arrives during one waits at most this long for its frame. */
|
|
147
|
+
export const IDLE_SLICE_MS = 3;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Runs `step` in idle slices once no input has woken the loop for AWAKE_MS, so it never shares a gesture's
|
|
151
|
+
* frames. `step` works while `timeLeft()` is positive and returns true when it has finished.
|
|
152
|
+
*/
|
|
153
|
+
export function runWhenInputIdle(step: (timeLeft: () => number) => boolean): void {
|
|
154
|
+
const wait = awakeUntil - performance.now();
|
|
155
|
+
if (wait > 0) {
|
|
156
|
+
setTimeout(() => runWhenInputIdle(step), wait);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const idle = window.requestIdleCallback ?? ((callback: () => void) => setTimeout(callback, 0));
|
|
160
|
+
idle(() => {
|
|
161
|
+
const end = performance.now() + IDLE_SLICE_MS;
|
|
162
|
+
if (performance.now() < awakeUntil || !step(() => end - performance.now()))
|
|
163
|
+
runWhenInputIdle(step);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
146
167
|
/** Something moved, or might have. Run frames at full rate for a moment. */
|
|
147
168
|
export function requestOverlayFrames(): void {
|
|
148
169
|
awakeUntil = performance.now() + AWAKE_MS;
|
|
@@ -8,7 +8,11 @@ import { sortFontOptions } from "./propertyPanelHelpers";
|
|
|
8
8
|
|
|
9
9
|
vi.mock("./propertyPanelHelpers", async (importOriginal) => {
|
|
10
10
|
const actual = await importOriginal<typeof import("./propertyPanelHelpers")>();
|
|
11
|
-
return {
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
sortFontOptions: vi.fn(actual.sortFontOptions),
|
|
14
|
+
uniqueFontFamilies: vi.fn(actual.uniqueFontFamilies),
|
|
15
|
+
};
|
|
12
16
|
});
|
|
13
17
|
|
|
14
18
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -56,13 +60,14 @@ describe("FontFamilyField font list", () => {
|
|
|
56
60
|
root.render(<FontFamilyField flat value={value} importedFonts={[]} onCommit={vi.fn()} />);
|
|
57
61
|
try {
|
|
58
62
|
await act(async () => render("Arial"));
|
|
59
|
-
// The
|
|
63
|
+
// The value changes while the dropdown is closed, as during a drag.
|
|
60
64
|
await act(async () => render("Georgia"));
|
|
61
65
|
expect(sortFontOptions).not.toHaveBeenCalled();
|
|
62
66
|
|
|
63
67
|
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
|
|
64
68
|
await act(async () => trigger?.click());
|
|
65
|
-
|
|
69
|
+
await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
|
|
70
|
+
expect(sortFontOptions).toHaveBeenCalled();
|
|
66
71
|
expect(host.textContent).toContain("Roboto Slab");
|
|
67
72
|
expect(document.head.querySelector('link[href*="fonts.googleapis.com"]')).toBeNull();
|
|
68
73
|
} finally {
|
|
@@ -71,3 +76,75 @@ describe("FontFamilyField font list", () => {
|
|
|
71
76
|
}
|
|
72
77
|
});
|
|
73
78
|
});
|
|
79
|
+
|
|
80
|
+
describe("FontFamilyField session font lists", () => {
|
|
81
|
+
const fresh = async () => {
|
|
82
|
+
vi.resetModules();
|
|
83
|
+
const { FontFamilyField } = await import("./propertyPanelFont");
|
|
84
|
+
const { uniqueFontFamilies } = await import("./propertyPanelHelpers");
|
|
85
|
+
const loop = await import("./overlayFrameLoop");
|
|
86
|
+
vi.mocked(uniqueFontFamilies).mockClear();
|
|
87
|
+
const host = document.createElement("div");
|
|
88
|
+
document.body.append(host);
|
|
89
|
+
const root = createRoot(host);
|
|
90
|
+
const render = (value: string) =>
|
|
91
|
+
root.render(<FontFamilyField flat value={value} importedFonts={[]} onCommit={vi.fn()} />);
|
|
92
|
+
const toggle = () =>
|
|
93
|
+
host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]')?.click();
|
|
94
|
+
const done = () => {
|
|
95
|
+
act(() => root.unmount());
|
|
96
|
+
host.remove();
|
|
97
|
+
loop.resetOverlayFrameLoopForTests();
|
|
98
|
+
};
|
|
99
|
+
return { uniqueFontFamilies, loop, host, render, toggle, done };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
it("does no list work in a drag's frames when the lists land mid-drag", async () => {
|
|
103
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance", "Date"] });
|
|
104
|
+
const raf = window.requestAnimationFrame;
|
|
105
|
+
window.requestAnimationFrame = (() => 0) as typeof window.requestAnimationFrame;
|
|
106
|
+
const lists = vi.mocked(fetch).getMockImplementation()!;
|
|
107
|
+
vi.mocked(fetch).mockImplementation(async (url) => {
|
|
108
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
109
|
+
return lists(url);
|
|
110
|
+
});
|
|
111
|
+
const t = await fresh();
|
|
112
|
+
const stopLoop = t.loop.subscribeOverlayFrame(() => undefined);
|
|
113
|
+
try {
|
|
114
|
+
await act(async () => t.render("Georgia"));
|
|
115
|
+
for (let elapsed = 0; elapsed < 600; elapsed += 16) {
|
|
116
|
+
window.dispatchEvent(new Event("pointermove"));
|
|
117
|
+
await act(async () => vi.advanceTimersByTimeAsync(16));
|
|
118
|
+
}
|
|
119
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
120
|
+
expect(t.uniqueFontFamilies).not.toHaveBeenCalled();
|
|
121
|
+
|
|
122
|
+
await act(async () => vi.advanceTimersByTimeAsync(500));
|
|
123
|
+
expect(t.uniqueFontFamilies).toHaveBeenCalledTimes(1);
|
|
124
|
+
await act(async () => t.toggle());
|
|
125
|
+
expect(t.host.textContent).toContain("Roboto Slab");
|
|
126
|
+
} finally {
|
|
127
|
+
stopLoop();
|
|
128
|
+
t.done();
|
|
129
|
+
window.requestAnimationFrame = raf;
|
|
130
|
+
vi.useRealTimers();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("retries a failed list fetch when the picker opens", async () => {
|
|
135
|
+
vi.mocked(fetch).mockRejectedValueOnce(new Error("offline"));
|
|
136
|
+
const t = await fresh();
|
|
137
|
+
try {
|
|
138
|
+
await act(async () => t.render("Georgia"));
|
|
139
|
+
await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
|
|
140
|
+
expect(t.uniqueFontFamilies).not.toHaveBeenCalled();
|
|
141
|
+
|
|
142
|
+
await act(async () => t.toggle());
|
|
143
|
+
await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
|
|
144
|
+
expect(fetch).toHaveBeenCalledTimes(4);
|
|
145
|
+
expect(t.host.textContent).toContain("Roboto Slab");
|
|
146
|
+
} finally {
|
|
147
|
+
t.done();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|