@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.
Files changed (79) hide show
  1. package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-dl6K6ABp.js} +1 -1
  2. package/dist/assets/{index-DEDhPmPD.js → index-CMiYVGv5.js} +202 -202
  3. package/dist/assets/{index-zpe332hb.js → index-DKT5NF1Y.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-Ca6LZlVb.js → index-ooaZZhKf.js} +1 -1
  6. package/dist/{chunk-QADHJFDF.js → chunk-ICTZJPQQ.js} +2 -1
  7. package/dist/chunk-ICTZJPQQ.js.map +1 -0
  8. package/dist/index.d.ts +15 -4
  9. package/dist/index.html +11 -2
  10. package/dist/index.js +6601 -6022
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +9 -9
  15. package/src/captions/keyboard.test.ts +6 -0
  16. package/src/captions/keyboard.ts +2 -1
  17. package/src/components/StudioHeader.dom.test.tsx +31 -9
  18. package/src/components/StudioHeader.tsx +4 -1
  19. package/src/components/ThemeToggle.tsx +99 -0
  20. package/src/components/editor/DomEditOverlay.tsx +37 -5
  21. package/src/components/editor/SourceEditor.tsx +2 -6
  22. package/src/components/editor/domEditOverlayStartGesture.ts +127 -115
  23. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +275 -0
  24. package/src/components/editor/manualEditsDom.ts +33 -0
  25. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  26. package/src/components/editor/marqueeCommit.ts +6 -1
  27. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  28. package/src/components/editor/overlayFrameLoop.ts +21 -0
  29. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  30. package/src/components/editor/propertyPanelFont.tsx +69 -46
  31. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  32. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  33. package/src/components/editor/studioCodeTheme.ts +99 -0
  34. package/src/components/editor/useDomEditNudge.test.tsx +31 -0
  35. package/src/components/editor/useDomEditNudge.ts +2 -2
  36. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  37. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  38. package/src/components/editor/useMotionPathData.ts +5 -1
  39. package/src/hooks/appHotkeysDispatch.ts +2 -1
  40. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  41. package/src/hooks/useAppHotkeys.test.ts +32 -0
  42. package/src/hooks/useAppHotkeys.ts +1 -2
  43. package/src/hooks/useClipboard.source.test.ts +99 -19
  44. package/src/hooks/useClipboard.ts +145 -124
  45. package/src/hooks/useDomEditSession.test.tsx +76 -72
  46. package/src/hooks/useDomEditSession.ts +7 -43
  47. package/src/hooks/useGsapTweenCache.ts +1 -2
  48. package/src/hooks/useInlineTextEdit.ts +16 -2
  49. package/src/hooks/useLivePreviewIframe.ts +18 -0
  50. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  51. package/src/hooks/usePreviewPersistence.ts +2 -5
  52. package/src/hooks/useRenderClipContent.ts +3 -3
  53. package/src/index.ts +2 -0
  54. package/src/livePreviewIframeExport.test.tsx +33 -0
  55. package/src/main.tsx +4 -1
  56. package/src/player/components/AudioWaveform.test.tsx +36 -15
  57. package/src/player/components/AudioWaveform.tsx +24 -7
  58. package/src/player/components/TimelineClip.test.tsx +38 -0
  59. package/src/player/components/TimelineClip.tsx +14 -6
  60. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  61. package/src/player/components/TimelineClipFades.tsx +368 -137
  62. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  63. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  64. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  65. package/src/player/lib/automationStoreSync.test.ts +18 -1
  66. package/src/player/lib/automationStoreSync.ts +29 -17
  67. package/src/player/sceneSwap.ts +18 -0
  68. package/src/styles/components.css +26 -0
  69. package/src/styles/studio.css +6 -0
  70. package/src/styles/theme.css +10 -0
  71. package/src/utils/clipboardPayload.ts +10 -1
  72. package/src/utils/studioPendingEdits.ts +21 -0
  73. package/src/utils/studioTheme.test.tsx +141 -0
  74. package/src/utils/studioTheme.ts +34 -0
  75. package/src/utils/studioUiPreferences.ts +3 -0
  76. package/src/utils/typingTarget.test.ts +8 -1
  77. package/src/utils/typingTarget.ts +5 -0
  78. package/dist/assets/index-Cre9X4RS.css +0 -1
  79. package/dist/chunk-QADHJFDF.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
2
  import {
3
3
  googleFontStylesheetUrl,
4
4
  POPULAR_GOOGLE_FONT_FAMILIES,
@@ -19,6 +19,7 @@ import {
19
19
  type LocalFontData,
20
20
  } from "./propertyPanelHelpers";
21
21
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
22
+ import { runWhenInputIdle } from "./overlayFrameLoop";
22
23
 
23
24
  /* ------------------------------------------------------------------ */
24
25
  /* Font helper functions */
@@ -117,6 +118,62 @@ function loadImportedFontStylesheet(asset: ImportedFontAsset): void {
117
118
  document.head.appendChild(style);
118
119
  }
119
120
 
121
+ type FontLists = {
122
+ loaded: boolean;
123
+ google: readonly string[];
124
+ googleKeys: ReadonlySet<string>;
125
+ installed: string[];
126
+ };
127
+
128
+ let fontLists: FontLists = {
129
+ loaded: false,
130
+ google: POPULAR_GOOGLE_FONT_FAMILIES,
131
+ googleKeys: new Set(POPULAR_GOOGLE_FONT_FAMILIES.map((f) => f.toLowerCase())),
132
+ installed: [],
133
+ };
134
+ let fontListsRequest: Promise<void> | null = null;
135
+ const fontListListeners = new Set<() => void>();
136
+
137
+ async function fetchFontList(url: string): Promise<string[]> {
138
+ const data = (await (await fetch(url)).json()) as { fonts?: unknown };
139
+ if (!Array.isArray(data.fonts)) throw new Error(`${url} returned no font list`);
140
+ return data.fonts as string[];
141
+ }
142
+
143
+ function loadFontLists(): void {
144
+ if (fontLists.loaded) return;
145
+ fontListsRequest ??= Promise.all([
146
+ fetchFontList("/api/fonts"),
147
+ fetchFontList("/api/fonts/google"),
148
+ ])
149
+ .then(([installed, google]) => {
150
+ const names = google.concat(POPULAR_GOOGLE_FONT_FAMILIES);
151
+ const families: string[] = [];
152
+ const googleKeys = new Set<string>();
153
+ let next = 0;
154
+ runWhenInputIdle((timeLeft) => {
155
+ while (next < names.length && timeLeft() > 0) {
156
+ const batch = names.slice(next, next + 200);
157
+ next += batch.length;
158
+ families.push(...uniqueFontFamilies(batch, googleKeys));
159
+ }
160
+ if (next < names.length) return false;
161
+ fontLists = { loaded: true, google: families, googleKeys, installed };
162
+ for (const listener of fontListListeners) listener();
163
+ return true;
164
+ });
165
+ })
166
+ .catch(() => {
167
+ fontListsRequest = null;
168
+ });
169
+ }
170
+
171
+ function subscribeFontLists(listener: () => void): () => void {
172
+ fontListListeners.add(listener);
173
+ loadFontLists();
174
+ return () => fontListListeners.delete(listener);
175
+ }
176
+
120
177
  /* ------------------------------------------------------------------ */
121
178
  /* FontFamilyField */
122
179
  /* ------------------------------------------------------------------ */
@@ -146,9 +203,8 @@ export function FontFamilyField({
146
203
  const [activeIndex, setActiveIndex] = useState(-1);
147
204
  const [localFonts, setLocalFonts] = useState<string[]>([]);
148
205
  const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]);
149
- const [googleFonts, setGoogleFonts] = useState<string[]>(() => [...POPULAR_GOOGLE_FONT_FAMILIES]);
206
+ const lists = useSyncExternalStore(subscribeFontLists, () => fontLists);
150
207
  const [loadingLocalFonts, setLoadingLocalFonts] = useState(false);
151
- const [loadingGoogleFonts, setLoadingGoogleFonts] = useState(false);
152
208
  const [importingFonts, setImportingFonts] = useState(false);
153
209
  const [fontNotice, setFontNotice] = useState<string | null>(null);
154
210
  const canQueryLocalFonts =
@@ -175,46 +231,18 @@ export function FontFamilyField({
175
231
  }, [open]);
176
232
 
177
233
  useEffect(() => {
178
- let cancelled = false;
179
- void fetch("/api/fonts")
180
- .then((r) => (r.ok ? r.json() : null))
181
- .then((data: { fonts?: string[] } | null) => {
182
- if (cancelled || !Array.isArray(data?.fonts)) return;
183
- setLocalFonts((cur) => uniqueFontFamilies([...cur, ...data.fonts!]));
184
- })
185
- .catch(() => undefined);
186
- return () => {
187
- cancelled = true;
188
- };
189
- }, []);
190
-
191
- useEffect(() => {
192
- let cancelled = false;
193
- setLoadingGoogleFonts(true);
194
- void fetch("/api/fonts/google")
195
- .then((r) => (r.ok ? r.json() : null))
196
- .then((data: { fonts?: string[] } | null) => {
197
- if (cancelled || !Array.isArray(data?.fonts)) return;
198
- setGoogleFonts(uniqueFontFamilies([...data.fonts!, ...POPULAR_GOOGLE_FONT_FAMILIES]));
199
- })
200
- .catch(() => undefined)
201
- .finally(() => {
202
- if (!cancelled) setLoadingGoogleFonts(false);
203
- });
204
- return () => {
205
- cancelled = true;
206
- };
207
- }, []);
234
+ if (open) loadFontLists();
235
+ }, [open]);
208
236
 
209
237
  useEffect(() => {
210
- if (googleFonts.some((f) => f.toLowerCase() === currentFamily.toLowerCase())) {
238
+ if (lists.googleKeys.has(currentFamily.toLowerCase())) {
211
239
  loadGoogleFontStylesheet(currentFamily);
212
240
  }
213
241
  const imported = importedFonts.find(
214
242
  (f) => f.family.toLowerCase() === currentFamily.toLowerCase(),
215
243
  );
216
244
  if (imported) loadImportedFontStylesheet(imported);
217
- }, [currentFamily, googleFonts, importedFonts]);
245
+ }, [currentFamily, lists, importedFonts]);
218
246
 
219
247
  const loadBrowserLocalFonts = async () => {
220
248
  if (!canQueryLocalFonts || !window.queryLocalFonts) {
@@ -278,24 +306,19 @@ export function FontFamilyField({
278
306
  const options = useMemo(() => {
279
307
  if (!open) return [];
280
308
  const documentFonts = collectDocumentFontFamilies();
281
- const googleSet = new Set(googleFonts.map((f) => f.toLowerCase()));
282
- const taggedLocal = localFonts.map(
283
- (family): FontOption => ({
284
- family,
285
- source: googleSet.has(family.toLowerCase()) ? "Google" : "Local",
286
- }),
287
- );
288
309
  return sortFontOptions(
289
310
  uniqueFontOptions([
290
311
  { family: currentFamily, source: "Current" },
291
312
  ...documentFonts.map((f): FontOption => ({ family: f, source: "Document" })),
292
313
  ...projectFontAssets,
293
- ...googleFonts.map((f): FontOption => ({ family: f, source: "Google" })),
294
- ...taggedLocal,
314
+ ...lists.google.map((f): FontOption => ({ family: f, source: "Google" })),
315
+ ...[...lists.installed, ...localFonts].map(
316
+ (f): FontOption => ({ family: f, source: "Local" }),
317
+ ),
295
318
  ...DEFAULT_FONT_FAMILIES.map((f): FontOption => ({ family: f, source: "System" })),
296
319
  ]),
297
320
  );
298
- }, [open, currentFamily, googleFonts, localFonts, projectFontAssets]);
321
+ }, [open, currentFamily, lists, localFonts, projectFontAssets]);
299
322
 
300
323
  const filteredOptions = useMemo(() => {
301
324
  const matches = options.filter((o) => fontMatchesQuery(o.family, query));
@@ -392,7 +415,7 @@ export function FontFamilyField({
392
415
  type="text"
393
416
  value={query}
394
417
  disabled={disabled}
395
- placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
418
+ placeholder={lists.loaded ? "Search fonts" : "Loading Google Fonts..."}
396
419
  onChange={(e) => {
397
420
  setQuery(e.target.value);
398
421
  setActiveIndex(-1);
@@ -125,8 +125,7 @@ export function localFontSortScore(font: LocalFontData): number {
125
125
  return 3;
126
126
  }
127
127
 
128
- export function uniqueFontFamilies(values: string[]): string[] {
129
- const seen = new Set<string>();
128
+ export function uniqueFontFamilies(values: string[], seen = new Set<string>()): string[] {
130
129
  return values.reduce<string[]>((result, value) => {
131
130
  const family = value.trim();
132
131
  if (!family) return result;
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8");
5
+ const source = read("./studioCodeTheme.ts");
6
+ const themeCss = read("../../styles/theme.css");
7
+
8
+ describe("the code editor theme", () => {
9
+ it("takes every colour from a token theme.css declares", () => {
10
+ const used = [...source.matchAll(/var\((--color-[\w-]+)\)/g)].map((match) => match[1]!);
11
+ expect(used.length).toBeGreaterThan(20);
12
+ for (const token of new Set(used)) expect(themeCss, token).toContain(`${token}:`);
13
+ });
14
+
15
+ it("paints no literal colour of its own", () => {
16
+ expect(source).not.toMatch(
17
+ /#[\da-f]{3,8}\b|\b(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(|"(white|black|red|orange|yellow|green|blue|purple|pink|gray|grey|silver|lime|cyan|magenta)"/i,
18
+ );
19
+ });
20
+ });
@@ -0,0 +1,99 @@
1
+ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
2
+ import { EditorView } from "@codemirror/view";
3
+ import { tags as t } from "@lezer/highlight";
4
+
5
+ // One Dark's layout on Studio's tokens: every colour is a theme.css variable, so the editor
6
+ // follows the light or dark theme the page paints with no editor reconfiguration.
7
+ const editorTheme = EditorView.theme({
8
+ "&": { height: "100%", color: "var(--color-text-0)", backgroundColor: "var(--color-surface)" },
9
+ ".cm-scroller": { overflow: "auto" },
10
+ ".cm-content": { caretColor: "var(--color-text-0)" },
11
+ ".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--color-text-0)" },
12
+ "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
13
+ { backgroundColor: "var(--color-on)" },
14
+ ".cm-activeLine": { backgroundColor: "var(--color-hover)" },
15
+ ".cm-selectionMatch": {
16
+ backgroundColor: "transparent",
17
+ outline: "1px solid var(--color-text-muted)",
18
+ },
19
+ ".cm-searchMatch": {
20
+ backgroundColor: "transparent",
21
+ outline: "1px solid var(--color-text-muted)",
22
+ },
23
+ ".cm-searchMatch.cm-searchMatch-selected": {
24
+ backgroundColor: "transparent",
25
+ outline: "2px solid var(--color-accent-ink)",
26
+ },
27
+ "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
28
+ backgroundColor: "var(--color-on)",
29
+ },
30
+ ".cm-gutters": {
31
+ backgroundColor: "var(--color-surface)",
32
+ color: "var(--color-text-muted)",
33
+ border: "none",
34
+ },
35
+ ".cm-activeLineGutter": { backgroundColor: "var(--color-hover)", color: "var(--color-text-2)" },
36
+ ".cm-foldPlaceholder": {
37
+ backgroundColor: "transparent",
38
+ border: "none",
39
+ color: "var(--color-text-2)",
40
+ },
41
+ ".cm-panels": { backgroundColor: "var(--color-raised)", color: "var(--color-text-0)" },
42
+ ".cm-panels.cm-panels-top": { borderBottom: "1px solid var(--color-border)" },
43
+ ".cm-panels.cm-panels-bottom": { borderTop: "1px solid var(--color-border)" },
44
+ ".cm-textfield": {
45
+ backgroundColor: "var(--color-input)",
46
+ color: "var(--color-text-0)",
47
+ border: "1px solid var(--color-border-input)",
48
+ },
49
+ ".cm-button": {
50
+ backgroundImage: "none",
51
+ backgroundColor: "var(--color-hover)",
52
+ color: "var(--color-text-0)",
53
+ border: "1px solid var(--color-border)",
54
+ },
55
+ "&.cm-editor .cm-button:active": {
56
+ backgroundImage: "none",
57
+ backgroundColor: "var(--color-press)",
58
+ },
59
+ ".cm-specialChar": { color: "var(--color-danger-ink)" },
60
+ });
61
+
62
+ const highlightStyle = HighlightStyle.define([
63
+ { tag: t.keyword, color: "var(--color-code-keyword)" },
64
+ {
65
+ tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName],
66
+ color: "var(--color-code-name)",
67
+ },
68
+ { tag: [t.function(t.variableName), t.labelName], color: "var(--color-code-function)" },
69
+ { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: "var(--color-code-constant)" },
70
+ { tag: [t.definition(t.name), t.separator], color: "var(--color-text-0)" },
71
+ {
72
+ tag: [
73
+ t.typeName,
74
+ t.className,
75
+ t.number,
76
+ t.changed,
77
+ t.annotation,
78
+ t.modifier,
79
+ t.self,
80
+ t.namespace,
81
+ ],
82
+ color: "var(--color-code-type)",
83
+ },
84
+ {
85
+ tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.special(t.string)],
86
+ color: "var(--color-code-operator)",
87
+ },
88
+ { tag: [t.meta, t.comment], color: "var(--color-code-comment)" },
89
+ { tag: t.strong, fontWeight: "bold" },
90
+ { tag: t.emphasis, fontStyle: "italic" },
91
+ { tag: t.strikethrough, textDecoration: "line-through" },
92
+ { tag: t.link, color: "var(--color-code-comment)", textDecoration: "underline" },
93
+ { tag: t.heading, fontWeight: "bold", color: "var(--color-code-name)" },
94
+ { tag: [t.atom, t.bool, t.special(t.variableName)], color: "var(--color-code-constant)" },
95
+ { tag: [t.processingInstruction, t.string, t.inserted], color: "var(--color-code-string)" },
96
+ { tag: t.invalid, color: "var(--color-danger-ink)" },
97
+ ]);
98
+
99
+ export const studioCodeTheme = [editorTheme, syntaxHighlighting(highlightStyle)];
@@ -124,6 +124,37 @@ describe("useDomEditNudge — selection cleanup keyed on stable identity", () =>
124
124
  element.remove();
125
125
  });
126
126
 
127
+ it("leaves arrows on a focused slider alone", () => {
128
+ const host = document.createElement("div");
129
+ document.body.append(host);
130
+ const root = createRoot(host);
131
+ const element = document.createElement("div");
132
+ element.id = "dot-a";
133
+ const slider = document.createElement("div");
134
+ slider.setAttribute("role", "slider");
135
+ document.body.append(element, slider);
136
+ const commit = vi.fn();
137
+ act(() => {
138
+ root.render(
139
+ React.createElement(Harness, {
140
+ selection: makeSelection("Dot", element),
141
+ onPathOffsetCommit: commit,
142
+ }),
143
+ );
144
+ });
145
+ act(() => {
146
+ slider.dispatchEvent(
147
+ new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
148
+ );
149
+ vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
150
+ });
151
+ expect(commit).not.toHaveBeenCalled();
152
+ act(() => root.unmount());
153
+ host.remove();
154
+ element.remove();
155
+ slider.remove();
156
+ });
157
+
127
158
  it("still flushes the burst when the selection actually changes to a different element", () => {
128
159
  const host = document.createElement("div");
129
160
  document.body.append(host);
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { useCallback, useEffect, useRef, type RefObject } from "react";
9
9
  import { useMountEffect } from "../../hooks/useMountEffect";
10
- import { isTypingTarget } from "../../utils/typingTarget";
10
+ import { ownsPlainKeys } from "../../utils/typingTarget";
11
11
  import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
12
12
  import type { DomEditSelection } from "./domEditing";
13
13
  import {
@@ -132,7 +132,7 @@ function resolveSingleNudgeTarget(
132
132
  function shouldIgnoreNudgeKey(p: UseDomEditNudgeParams, event: KeyboardEvent): boolean {
133
133
  if (!p.allowCanvasMovement || event.defaultPrevented) return true;
134
134
  if (p.gestureRef.current || p.groupGestureRef.current || p.blockedMoveRef.current) return true;
135
- return isTypingTarget(event.target);
135
+ return ownsPlainKeys(event.target);
136
136
  }
137
137
 
138
138
  export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () => void } {
@@ -44,7 +44,7 @@ export function useInlineTextEditing(
44
44
  } {
45
45
  const actions = useDomEditActionsContextOptional();
46
46
  const inlineText = useInlineTextEdit({
47
- onCommit: (commit) => void actions?.handleDomRichTextCommit(commit),
47
+ onCommit: (commit) => actions?.handleDomRichTextCommit(commit),
48
48
  onPause: () => usePlayerStore.getState().setIsPlaying(false),
49
49
  });
50
50
  const lastPressRef = useRef<PressMark | null>(null);
@@ -0,0 +1,64 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, useRef } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
5
+ import { useMotionPathData } from "./useMotionPathData";
6
+ import { resetOverlayFrameLoopForTests } from "./overlayFrameLoop";
7
+ import { usePlayerStore } from "../../player/store/playerStore";
8
+
9
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
10
+
11
+ const originalRaf = window.requestAnimationFrame;
12
+ let frames: Array<() => void> = [];
13
+ const runFrames = (count: number) => {
14
+ for (let i = 0; i < count; i++) {
15
+ const batch = frames;
16
+ frames = [];
17
+ act(() => {
18
+ for (const frame of batch) frame();
19
+ vi.advanceTimersByTime(16);
20
+ });
21
+ }
22
+ };
23
+
24
+ beforeEach(() => {
25
+ vi.useFakeTimers({
26
+ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "performance"],
27
+ });
28
+ window.requestAnimationFrame = ((callback: FrameRequestCallback) =>
29
+ frames.push(() => callback(performance.now()))) as typeof window.requestAnimationFrame;
30
+ usePlayerStore.setState({ previewBooted: true, motionPathArmed: false });
31
+ });
32
+
33
+ afterEach(() => {
34
+ resetOverlayFrameLoopForTests();
35
+ window.requestAnimationFrame = originalRaf;
36
+ vi.useRealTimers();
37
+ usePlayerStore.setState({ previewBooted: false, motionPathArmed: false });
38
+ document.body.innerHTML = "";
39
+ });
40
+
41
+ it("reads no layout per frame until there is a path or a create ring to draw", () => {
42
+ const iframe = document.createElement("iframe");
43
+ document.body.append(iframe);
44
+ const reads = vi.spyOn(iframe, "getBoundingClientRect");
45
+ function Probe() {
46
+ const ref = useRef(iframe);
47
+ useMotionPathData(ref, "#box");
48
+ return null;
49
+ }
50
+ const host = document.createElement("div");
51
+ document.body.append(host);
52
+ const root = createRoot(host);
53
+ try {
54
+ act(() => root.render(<Probe />));
55
+ runFrames(10);
56
+ expect(reads).not.toHaveBeenCalled();
57
+
58
+ act(() => usePlayerStore.setState({ motionPathArmed: true }));
59
+ runFrames(10);
60
+ expect(reads).toHaveBeenCalled();
61
+ } finally {
62
+ act(() => root.unmount());
63
+ }
64
+ });
@@ -3,6 +3,7 @@ import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes";
3
3
  import { isElementVisibleForOverlay } from "./domEditOverlayGeometry";
4
4
  import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGeometry";
5
5
  import { subscribeOverlayFrame } from "./overlayFrameLoop";
6
+ import { usePlayerStore } from "../../player/store/playerStore";
6
7
 
7
8
  type Rect = { left: number; top: number; width: number; height: number };
8
9
 
@@ -114,6 +115,8 @@ export function useMotionPathData(
114
115
  // Perspective magnification (1/m44) of the selected element — applied to the
115
116
  // path's offset points so depth (translateZ) elements' paths track on screen.
116
117
  const [pScale, setPScale] = useState(1);
118
+ const armed = usePlayerStore((s) => s.motionPathArmed);
119
+ const drawn = geometry !== null || armed;
117
120
 
118
121
  useEffect(() => {
119
122
  if (!selector) {
@@ -122,6 +125,7 @@ export function useMotionPathData(
122
125
  return;
123
126
  }
124
127
  setHome(null);
128
+ if (!drawn) return;
125
129
  const tick = () => {
126
130
  const el = iframeRef.current;
127
131
  if (el) {
@@ -155,7 +159,7 @@ export function useMotionPathData(
155
159
  }
156
160
  };
157
161
  return subscribeOverlayFrame(tick);
158
- }, [selector, iframeRef]);
162
+ }, [selector, iframeRef, drawn]);
159
163
 
160
164
  useEffect(() => {
161
165
  if (!selector) {
@@ -3,7 +3,7 @@ import { usePlayerStore } from "../player";
3
3
  import type { TimelineElement } from "../player";
4
4
  import type { DomEditSelection } from "../components/editor/domEditing";
5
5
  import { useDockLayoutStore } from "../components/dock/dockLayoutStore";
6
- import { isTypingTarget } from "../utils/typingTarget";
6
+ import { isTypingTarget, ownsPlainKeys } from "../utils/typingTarget";
7
7
  import { isEditableTarget } from "../utils/timelineDiscovery";
8
8
  import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
9
  import { canSplitElement } from "../utils/timelineElementSplit";
@@ -153,6 +153,7 @@ export function dispatchModifierKey(
153
153
  * Delete arbitration between keyframes, an automation range and the clip can
154
154
  * be asserted without standing up the whole hook. */
155
155
  export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
156
+ if (ownsPlainKeys(event.target)) return;
156
157
  if (key === STUDIO_PLAIN_KEYS.fullscreen && !event.shiftKey && !event.altKey) {
157
158
  event.preventDefault();
158
159
  if (document.fullscreenElement) void document.exitFullscreen();
@@ -0,0 +1,180 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { DomEditSelection } from "../components/editor/domEditing";
6
+ import { resolveElementForOverlay } from "../components/editor/domEditOverlayGeometry";
7
+ import {
8
+ makePreview,
9
+ mountPlayerWithPreview,
10
+ paintShadow,
11
+ resetPlayerStore,
12
+ type TimelinePlayerApi,
13
+ } from "../player/hooks/timelinePlayerTestHarness";
14
+ import { announcePreviewPromoted } from "../player/sceneSwap";
15
+ import { stageElementOffset } from "./elementOffsetStager";
16
+ import { savePlainRotation } from "./plainRotation";
17
+ import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
18
+ import { useLivePreviewIframe } from "./useLivePreviewIframe";
19
+
20
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
21
+
22
+ vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({
23
+ ...(await importOriginal<typeof import("../utils/gsapSoftReload")>()),
24
+ ensureMotionPathPluginLoaded: vi.fn(),
25
+ }));
26
+
27
+ afterEach(() => {
28
+ document.body.innerHTML = "";
29
+ resetPlayerStore();
30
+ });
31
+
32
+ const previewWithTitle = (query = "") => makePreview('<h1 id="title">Title</h1>', query);
33
+
34
+ const titleOf = (iframe: HTMLIFrameElement | null) =>
35
+ iframe!.contentDocument!.getElementById("title") as HTMLElement;
36
+
37
+ const selectionOf = (element: HTMLElement) =>
38
+ ({ element, id: "title", selector: "#title", label: "title" }) as unknown as DomEditSelection;
39
+
40
+ /** A host shaped like Desktop's: it reads its iframe once and never re-renders for a reload. */
41
+ function mountHost(followPromotions: boolean) {
42
+ const selectionRef = { current: null as DomEditSelection | null };
43
+ function Session({ host }: { host: HTMLIFrameElement }) {
44
+ const live = useLivePreviewIframe(host);
45
+ useDomEditPreviewSync({
46
+ previewIframe: followPromotions ? live : host,
47
+ activeCompPath: null,
48
+ captionEditMode: false,
49
+ domEditSelectionRef: selectionRef,
50
+ domEditGroupSelectionsRef: { current: [] },
51
+ domEditSelection: selectionRef.current,
52
+ refreshDomEditGroupSelectionsFromPreview: async () => {},
53
+ applyDomSelection: (selection) => void (selectionRef.current = selection),
54
+ buildDomSelectionFromTarget: async (element) => selectionOf(element),
55
+ refreshPreviewDocumentVersion: () => {},
56
+ syncPreviewHotkeys: () => {},
57
+ applyStudioManualEditsToPreviewRef: { current: async () => {} },
58
+ });
59
+ return null;
60
+ }
61
+ const live = previewWithTitle();
62
+ const player = mountPlayerWithPreview(live);
63
+ selectionRef.current = selectionOf(titleOf(live));
64
+ const sessionRoot = createRoot(document.body.appendChild(document.createElement("div")));
65
+ act(() => sessionRoot.render(React.createElement(Session, { host: live })));
66
+ const unmount = () => {
67
+ act(() => sessionRoot.unmount());
68
+ act(() => player.root.unmount());
69
+ };
70
+ return { player: player.getApi, selectionRef, live, unmount };
71
+ }
72
+
73
+ /** The real shadow reload: load a shadow, report it painted, and let the player promote it. */
74
+ async function promoteShadow(player: () => TimelinePlayerApi): Promise<HTMLIFrameElement> {
75
+ act(() => player().refreshPlayer());
76
+ const shadow = previewWithTitle("?_t=1");
77
+ await paintShadow(player, shadow);
78
+ expect(player().iframeRef.current).toBe(shadow);
79
+ return shadow;
80
+ }
81
+
82
+ describe("a shadow reload promoted without a host re-render", () => {
83
+ // A host stage holds both players, each with the same #title, in either order.
84
+ it.each([
85
+ ["second", true],
86
+ ["first", false],
87
+ ])(
88
+ "with the live preview %s on the stage, the selection, overlay, rotate and move land on it",
89
+ async (_, liveSecond) => {
90
+ const host = mountHost(true);
91
+ const shadow = await promoteShadow(host.player);
92
+ // The stage only orders the two players; nothing here needs their pages to load.
93
+ for (const frame of [host.live, shadow]) frame.removeAttribute("src");
94
+ const stage = document.body.appendChild(document.createElement("div"));
95
+ stage.append(...(liveSecond ? [host.live, shadow] : [shadow, host.live]));
96
+
97
+ const selection = host.selectionRef.current!;
98
+ expect(selection.element).toBe(titleOf(shadow));
99
+ const overlayNode = resolveElementForOverlay(shadow.contentDocument!, selection, null, {
100
+ current: null,
101
+ });
102
+ expect(overlayNode).toBe(selection.element);
103
+
104
+ const commitPositionPatchToHtml = vi.fn(async () => {});
105
+ await savePlainRotation({ commitPositionPatchToHtml }, selection, { angle: 30 });
106
+ await stageElementOffset(
107
+ { commitPositionPatchToHtml, showToast: vi.fn() },
108
+ selection,
109
+ { x: 10, y: 5 },
110
+ true,
111
+ ).save();
112
+ expect(titleOf(shadow).style.getPropertyValue("rotate")).toBe("30deg");
113
+ expect(titleOf(shadow).style.getPropertyValue("translate")).toBe("10px 5px");
114
+ expect(titleOf(host.live).getAttribute("style")).toBeNull();
115
+ host.unmount();
116
+ },
117
+ );
118
+
119
+ it("left the selection on the retired node when the session kept the host's first iframe", async () => {
120
+ const host = mountHost(false);
121
+ await promoteShadow(host.player);
122
+ expect(host.selectionRef.current!.element).toBe(titleOf(host.live));
123
+ host.unmount();
124
+ });
125
+ });
126
+
127
+ describe("useLivePreviewIframe", () => {
128
+ function track(host: HTMLIFrameElement) {
129
+ const seen: Array<HTMLIFrameElement | null> = [];
130
+ function Probe({ iframe }: { iframe: HTMLIFrameElement }) {
131
+ seen.push(useLivePreviewIframe(iframe));
132
+ return null;
133
+ }
134
+ const root = createRoot(document.createElement("div"));
135
+ act(() => root.render(React.createElement(Probe, { iframe: host })));
136
+ const rerender = (iframe: HTMLIFrameElement) =>
137
+ act(() => root.render(React.createElement(Probe, { iframe })));
138
+ return { live: () => seen.at(-1), rerender, root };
139
+ }
140
+
141
+ it("follows each promotion of its own preview and ignores another preview's", () => {
142
+ const [a, b, c, other] = [0, 1, 2, 3].map(() => document.createElement("iframe"));
143
+ const probe = track(a!);
144
+ act(() => announcePreviewPromoted({ retired: other!, live: c! }));
145
+ expect(probe.live()).toBe(a);
146
+ act(() => announcePreviewPromoted({ retired: a!, live: b! }));
147
+ expect(probe.live()).toBe(b);
148
+ act(() => announcePreviewPromoted({ retired: b!, live: c! }));
149
+ expect(probe.live()).toBe(c);
150
+ act(() => probe.root.unmount());
151
+ });
152
+
153
+ it("lands on the same iframe wrapped twice, as when a host wraps what it hands the session", () => {
154
+ const [a, b, c] = [0, 1, 2].map(() => document.createElement("iframe"));
155
+ let seen: { outer: HTMLIFrameElement | null; inner: HTMLIFrameElement | null } | null = null;
156
+ function Probe({ host }: { host: HTMLIFrameElement }) {
157
+ const outer = useLivePreviewIframe(host);
158
+ seen = { outer, inner: useLivePreviewIframe(outer) };
159
+ return null;
160
+ }
161
+ const root = createRoot(document.createElement("div"));
162
+ act(() => root.render(React.createElement(Probe, { host: a! })));
163
+ act(() => announcePreviewPromoted({ retired: a!, live: b! }));
164
+ expect(seen).toEqual({ outer: b, inner: b });
165
+ act(() => announcePreviewPromoted({ retired: b!, live: c! }));
166
+ expect(seen).toEqual({ outer: c, inner: c });
167
+ act(() => root.unmount());
168
+ });
169
+
170
+ it("takes a new host iframe as given, and stops listening on unmount", () => {
171
+ const [a, b, d] = [0, 1, 2].map(() => document.createElement("iframe"));
172
+ const probe = track(a!);
173
+ act(() => announcePreviewPromoted({ retired: a!, live: b! }));
174
+ probe.rerender(d!);
175
+ expect(probe.live()).toBe(d);
176
+ const remove = vi.spyOn(document, "removeEventListener");
177
+ act(() => probe.root.unmount());
178
+ expect(remove).toHaveBeenCalledWith("hf-preview-promoted", expect.any(Function));
179
+ });
180
+ });