@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
|
@@ -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
|
|
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
|
-
|
|
179
|
-
|
|
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 (
|
|
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,
|
|
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
|
-
...
|
|
294
|
-
...
|
|
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,
|
|
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={
|
|
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 {
|
|
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
|
|
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) =>
|
|
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
|
+
});
|