@hyperframes/studio 0.8.105 → 0.8.107

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/index-CM1xA_jD.js +394 -0
  3. package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
  6. package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
  7. package/dist/chunk-4OZC426H.js.map +1 -0
  8. package/dist/index.d.ts +381 -14
  9. package/dist/index.html +11 -2
  10. package/dist/index.js +9974 -9045
  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/App.tsx +5 -3
  16. package/src/agentToolsExport.test.ts +8 -0
  17. package/src/captions/keyboard.test.ts +12 -0
  18. package/src/captions/keyboard.ts +2 -1
  19. package/src/components/StudioHeader.dom.test.tsx +31 -9
  20. package/src/components/StudioHeader.tsx +4 -1
  21. package/src/components/StudioRightPanels.tsx +2 -0
  22. package/src/components/ThemeToggle.tsx +99 -0
  23. package/src/components/TimelineToolbar.test.tsx +27 -0
  24. package/src/components/TimelineToolbar.tsx +2 -5
  25. package/src/components/editor/DomEditOverlay.tsx +31 -5
  26. package/src/components/editor/PropertyPanel.tsx +1 -14
  27. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  28. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  29. package/src/components/editor/SnapToolbar.tsx +3 -4
  30. package/src/components/editor/SourceEditor.tsx +2 -6
  31. package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
  32. package/src/components/editor/fontAssets.test.ts +12 -22
  33. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
  34. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  35. package/src/components/editor/manualEdits.test.ts +4 -3
  36. package/src/components/editor/manualEditsDom.ts +58 -2
  37. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  38. package/src/components/editor/manualOffsetDrag.ts +4 -1
  39. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  40. package/src/components/editor/marqueeCommit.ts +6 -1
  41. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  42. package/src/components/editor/mediaAudioEdits.ts +253 -0
  43. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  44. package/src/components/editor/overlayFrameLoop.ts +21 -0
  45. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  46. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  47. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  48. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  49. package/src/components/editor/propertyPanelFont.tsx +69 -46
  50. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  51. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  52. package/src/components/editor/propertyPanelTypes.ts +2 -0
  53. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  54. package/src/components/editor/studioCodeTheme.ts +99 -0
  55. package/src/components/editor/useDomEditNudge.test.tsx +165 -2
  56. package/src/components/editor/useDomEditNudge.ts +43 -25
  57. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  58. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  59. package/src/components/editor/useMotionPathData.ts +5 -1
  60. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  61. package/src/components/panels/SlideshowPanel.tsx +12 -7
  62. package/src/contexts/DomEditContext.test.tsx +62 -0
  63. package/src/contexts/DomEditContext.tsx +94 -77
  64. package/src/hooks/appHotkeysDispatch.ts +2 -1
  65. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  66. package/src/hooks/domEditCommitTypes.ts +10 -0
  67. package/src/hooks/domEditPersistFailure.ts +12 -1
  68. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  69. package/src/hooks/domStyleCommit.ts +2 -8
  70. package/src/hooks/gsapDragCommit.ts +3 -8
  71. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  72. package/src/hooks/gsapEditOutcome.ts +2 -1
  73. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  74. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  75. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  76. package/src/hooks/gsapResizeIntercept.ts +6 -2
  77. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  78. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  79. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  80. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  81. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  82. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  83. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  84. package/src/hooks/useAppHotkeys.test.ts +49 -0
  85. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  86. package/src/hooks/useAppHotkeys.ts +4 -5
  87. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  88. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  89. package/src/hooks/useClipboard.source.test.ts +99 -19
  90. package/src/hooks/useClipboard.ts +145 -124
  91. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  92. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  93. package/src/hooks/useDomEditCommits.ts +2 -0
  94. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  95. package/src/hooks/useDomEditPersist.ts +28 -13
  96. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  97. package/src/hooks/useDomEditSession.test.tsx +76 -72
  98. package/src/hooks/useDomEditSession.ts +9 -46
  99. package/src/hooks/useDomEditTextCommits.ts +4 -8
  100. package/src/hooks/useDomGeometryCommits.ts +11 -2
  101. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  102. package/src/hooks/useGsapAwareEditing.ts +6 -6
  103. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  104. package/src/hooks/useGsapScriptCommits.ts +5 -4
  105. package/src/hooks/useGsapTweenCache.ts +1 -2
  106. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  107. package/src/hooks/useInlineTextEdit.ts +21 -5
  108. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  109. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  110. package/src/hooks/useLivePreviewIframe.ts +18 -0
  111. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  112. package/src/hooks/usePreviewPersistence.ts +8 -5
  113. package/src/hooks/useRenderClipContent.ts +3 -3
  114. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  115. package/src/index.ts +9 -0
  116. package/src/livePreviewIframeExport.test.tsx +33 -0
  117. package/src/main.tsx +4 -1
  118. package/src/player/components/AudioWaveform.test.tsx +36 -15
  119. package/src/player/components/AudioWaveform.tsx +24 -7
  120. package/src/player/components/Timeline.test.ts +0 -21
  121. package/src/player/components/Timeline.tsx +0 -1
  122. package/src/player/components/TimelineClip.test.tsx +38 -0
  123. package/src/player/components/TimelineClip.tsx +14 -6
  124. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  125. package/src/player/components/TimelineClipFades.tsx +368 -137
  126. package/src/player/components/timelineLayout.ts +0 -28
  127. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  128. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  129. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  130. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  131. package/src/player/lib/automationStoreSync.test.ts +18 -1
  132. package/src/player/lib/automationStoreSync.ts +29 -17
  133. package/src/player/lib/playbackShortcuts.ts +3 -7
  134. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  135. package/src/player/lib/timelineElementHelpers.ts +16 -2
  136. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  137. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  138. package/src/player/sceneSwap.ts +18 -0
  139. package/src/styles/components.css +26 -0
  140. package/src/styles/studio.css +6 -0
  141. package/src/styles/theme.css +10 -0
  142. package/src/utils/clipboardPayload.ts +10 -1
  143. package/src/utils/gsapSoftReload.ts +3 -2
  144. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  145. package/src/utils/gsapUndoRestore.test.ts +145 -1
  146. package/src/utils/gsapUndoRestore.ts +70 -21
  147. package/src/utils/sourcePatcher.ts +1 -1
  148. package/src/utils/studioFontHelpers.ts +2 -23
  149. package/src/utils/studioHelpers.test.ts +30 -0
  150. package/src/utils/studioHelpers.ts +18 -4
  151. package/src/utils/studioPendingEdits.test.ts +63 -0
  152. package/src/utils/studioPendingEdits.ts +37 -7
  153. package/src/utils/studioTheme.test.tsx +141 -0
  154. package/src/utils/studioTheme.ts +34 -0
  155. package/src/utils/studioUiPreferences.ts +3 -0
  156. package/src/utils/typingTarget.test.ts +21 -1
  157. package/src/utils/typingTarget.ts +8 -0
  158. package/src/webmcp/useStudioAgentTools.ts +3 -1
  159. package/dist/assets/index-Cre9X4RS.css +0 -1
  160. package/dist/assets/index-DEDhPmPD.js +0 -399
  161. 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;
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useState } from "react";
2
+ import { isVideoAudible } from "../../player/lib/timelineElementHelpers";
2
3
  import { Check, ClipboardList, Film, Music, Scissors } from "../../icons/SystemIcons";
3
4
  import type { DomEditSelection } from "./domEditing";
4
5
  import {
@@ -22,23 +23,29 @@ import {
22
23
  audioGainToFaderPosition,
23
24
  audioGainToText,
24
25
  } from "@hyperframes/core/audio-gain";
26
+ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
27
+ import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
25
28
 
26
29
  // fallow-ignore-next-line complexity
27
30
  export function MediaSection({
31
+ projectId = null,
28
32
  projectDir,
29
33
  element,
30
34
  styles,
31
35
  onSetStyle,
32
36
  onSetAttribute,
33
37
  onSetHtmlAttribute,
38
+ onSetAttributeBatch,
34
39
  onRemoveBackground,
35
40
  }: {
41
+ projectId?: string | null;
36
42
  projectDir: string | null;
37
43
  element: DomEditSelection;
38
44
  styles: Record<string, string>;
39
45
  onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
40
46
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
41
47
  onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
48
+ onSetAttributeBatch: CommitDomAttributeBatch;
42
49
  onRemoveBackground?: (
43
50
  inputPath: string,
44
51
  options: {
@@ -62,7 +69,11 @@ export function MediaSection({
62
69
 
63
70
  const hasLoop = el.hasAttribute("loop");
64
71
  const hasMuted = el.hasAttribute("muted");
65
- const hasAudio = element.dataAttributes["has-audio"] === "true";
72
+ const hasAudio = isVideoAudible({
73
+ tag: el.tagName,
74
+ hasAudioAttr: element.dataAttributes["has-audio"],
75
+ muted: el.hasAttribute("muted"),
76
+ });
66
77
 
67
78
  const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
68
79
 
@@ -96,13 +107,7 @@ export function MediaSection({
96
107
  setCreatePlate(false);
97
108
  }, [srcAttr]);
98
109
 
99
- const applyCutoutResult = async (result: BackgroundRemovalResult) => {
100
- await onSetHtmlAttribute("src", result.outputPath);
101
- if (isVideo) {
102
- await onSetAttribute("has-audio", "");
103
- await onSetHtmlAttribute("muted", "true");
104
- }
105
- };
110
+ const mediaEdit = { element, projectId, projectSrc, commit: onSetAttributeBatch };
106
111
 
107
112
  const runBackgroundRemoval = async () => {
108
113
  if (!onRemoveBackground || !projectSrc || removeBusy) return;
@@ -115,13 +120,8 @@ export function MediaSection({
115
120
  quality,
116
121
  onProgress: setRemoveProgress,
117
122
  });
118
- await applyCutoutResult(result);
119
- setRemoveProgress({
120
- status: "complete",
121
- progress: 100,
122
- stage: "Applied cutout",
123
- ...result,
124
- });
123
+ const stage = await commitCutout(mediaEdit, result.outputPath, hasAudio);
124
+ setRemoveProgress({ status: "complete", progress: 100, stage, ...result });
125
125
  } catch (error) {
126
126
  setRemoveProgress({
127
127
  status: "failed",
@@ -320,9 +320,7 @@ export function MediaSection({
320
320
  <SegmentedControl
321
321
  trackName="Muted"
322
322
  value={hasMuted ? "on" : "off"}
323
- onChange={(next) => {
324
- void onSetHtmlAttribute("muted", next === "on" ? "true" : null);
325
- }}
323
+ onChange={(next) => void commitMutedToggle(mediaEdit, next === "on")}
326
324
  options={[
327
325
  { label: "On", value: "on" },
328
326
  { label: "Off", value: "off" },
@@ -337,15 +335,7 @@ export function MediaSection({
337
335
  <SegmentedControl
338
336
  trackName="Has audio track"
339
337
  value={hasAudio ? "yes" : "no"}
340
- onChange={(next) => {
341
- if (next === "yes") {
342
- void onSetAttribute("has-audio", "true");
343
- void onSetHtmlAttribute("muted", null);
344
- } else {
345
- void onSetAttribute("has-audio", "");
346
- void onSetHtmlAttribute("muted", "true");
347
- }
348
- }}
338
+ onChange={(next) => void commitHasAudioToggle(mediaEdit, next === "yes")}
349
339
  options={[
350
340
  { label: "Yes", value: "yes" },
351
341
  { label: "No", value: "no" },
@@ -3,6 +3,7 @@ import type { ArcPathSegment, GsapAnimation } from "@hyperframes/parsers/gsap-pa
3
3
  import type { DomEditSelection } from "./domEditing";
4
4
  import type { ImportedFontAsset } from "./fontAssets";
5
5
  import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
6
+ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
6
7
 
7
8
  export interface BackgroundRemovalProgress {
8
9
  status: "processing" | "complete" | "failed";
@@ -68,6 +69,7 @@ export interface PropertyPanelProps {
68
69
  value: string | null,
69
70
  ) => Promise<{ changedFiles: number; changedElements: number }>;
70
71
  onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
72
+ onSetAttributeBatch: CommitDomAttributeBatch;
71
73
  onRemoveBackground?: (
72
74
  inputPath: string,
73
75
  options: {
@@ -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)];
@@ -6,6 +6,7 @@ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelect
6
6
  import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge";
7
7
  import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge";
8
8
  import { __resetForTests } from "../../utils/canvasNudgeGate";
9
+ import { flushStudioPendingEdits, hasStudioPendingEdits } from "../../utils/studioPendingEdits";
9
10
  import type { DomEditSelection } from "./domEditing";
10
11
  import type { OverlayRect } from "./domEditOverlayGeometry";
11
12
 
@@ -27,6 +28,7 @@ const REST_RECT: OverlayRect = {
27
28
  // Stable across renders on purpose: the test targets the `selection` identity
28
29
  // key specifically, so `groupSelections` must not itself be a source of churn.
29
30
  const EMPTY_GROUP_SELECTIONS: DomEditSelection[] = [];
31
+ let flushNudge = () => {};
30
32
 
31
33
  function Harness({
32
34
  selection,
@@ -35,7 +37,7 @@ function Harness({
35
37
  selection: DomEditSelection | null;
36
38
  onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"];
37
39
  }) {
38
- useDomEditNudge({
40
+ flushNudge = useDomEditNudge({
39
41
  selection,
40
42
  groupSelections: EMPTY_GROUP_SELECTIONS,
41
43
  allowCanvasMovement: true,
@@ -49,7 +51,7 @@ function Harness({
49
51
  onBlockedMoveRef: makeRef(() => {}),
50
52
  onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
51
53
  onGroupPathOffsetCommitRef: makeRef(async () => {}),
52
- });
54
+ }).flushNudge;
53
55
  return null;
54
56
  }
55
57
 
@@ -124,6 +126,37 @@ describe("useDomEditNudge — selection cleanup keyed on stable identity", () =>
124
126
  element.remove();
125
127
  });
126
128
 
129
+ it("leaves arrows on a focused slider alone", () => {
130
+ const host = document.createElement("div");
131
+ document.body.append(host);
132
+ const root = createRoot(host);
133
+ const element = document.createElement("div");
134
+ element.id = "dot-a";
135
+ const slider = document.createElement("div");
136
+ slider.setAttribute("role", "slider");
137
+ document.body.append(element, slider);
138
+ const commit = vi.fn();
139
+ act(() => {
140
+ root.render(
141
+ React.createElement(Harness, {
142
+ selection: makeSelection("Dot", element),
143
+ onPathOffsetCommit: commit,
144
+ }),
145
+ );
146
+ });
147
+ act(() => {
148
+ slider.dispatchEvent(
149
+ new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
150
+ );
151
+ vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
152
+ });
153
+ expect(commit).not.toHaveBeenCalled();
154
+ act(() => root.unmount());
155
+ host.remove();
156
+ element.remove();
157
+ slider.remove();
158
+ });
159
+
127
160
  it("still flushes the burst when the selection actually changes to a different element", () => {
128
161
  const host = document.createElement("div");
129
162
  document.body.append(host);
@@ -228,6 +261,51 @@ describe("useDomEditNudge — selection cleanup keyed on stable identity", () =>
228
261
  });
229
262
  });
230
263
 
264
+ describe("useDomEditNudge — a focused native player owns the arrow keys", () => {
265
+ beforeEach(() => {
266
+ vi.useFakeTimers();
267
+ __resetForTests();
268
+ });
269
+ let root: ReturnType<typeof createRoot> | null = null;
270
+ afterEach(() => {
271
+ act(() => root?.unmount());
272
+ root = null;
273
+ document.body.innerHTML = "";
274
+ vi.useRealTimers();
275
+ });
276
+
277
+ it.each(["video", "audio"])(
278
+ "does not nudge the selection while a <%s controls> has focus",
279
+ (tag) => {
280
+ root = createRoot(document.body.appendChild(document.createElement("div")));
281
+ const element = document.body.appendChild(document.createElement("div"));
282
+ element.id = "dot";
283
+ const player = document.body.appendChild(document.createElement(tag));
284
+ player.setAttribute("controls", "");
285
+ const commit = vi.fn();
286
+ act(() => {
287
+ root?.render(
288
+ React.createElement(Harness, {
289
+ selection: makeSelection("Dot", element),
290
+ onPathOffsetCommit: commit,
291
+ }),
292
+ );
293
+ });
294
+ const event = new KeyboardEvent("keydown", {
295
+ key: "ArrowRight",
296
+ bubbles: true,
297
+ cancelable: true,
298
+ });
299
+ act(() => {
300
+ player.dispatchEvent(event);
301
+ vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
302
+ });
303
+ expect(event.defaultPrevented).toBe(false);
304
+ expect(commit).not.toHaveBeenCalled();
305
+ },
306
+ );
307
+ });
308
+
231
309
  describe("useDomEditNudge carries the route its press chose", () => {
232
310
  beforeEach(() => {
233
311
  vi.useFakeTimers();
@@ -260,3 +338,88 @@ describe("useDomEditNudge carries the route its press chose", () => {
260
338
  element.remove();
261
339
  });
262
340
  });
341
+
342
+ describe("useDomEditNudge — undo right after a burst", () => {
343
+ it("undo's drain commits a burst still inside its debounce and waits for its save", async () => {
344
+ __resetForTests();
345
+ vi.useFakeTimers();
346
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
347
+ const element = document.body.appendChild(document.createElement("div"));
348
+ element.id = "dot-undo";
349
+ let saved!: () => void;
350
+ const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
351
+ act(() => {
352
+ root.render(
353
+ React.createElement(Harness, {
354
+ selection: makeSelection("Dot", element),
355
+ onPathOffsetCommit: commit,
356
+ }),
357
+ );
358
+ });
359
+ act(() => dispatchArrowRight());
360
+
361
+ let drained = false;
362
+ const drain = flushStudioPendingEdits().then(() => (drained = true));
363
+ expect(commit).toHaveBeenCalledTimes(1);
364
+ vi.useRealTimers();
365
+ await new Promise((resolve) => setTimeout(resolve, 20));
366
+ expect(drained).toBe(false);
367
+ saved();
368
+ await drain;
369
+ expect(drained).toBe(true);
370
+ act(() => root.unmount());
371
+ });
372
+
373
+ it("counts the burst as a pending edit from its first key until its save lands", async () => {
374
+ __resetForTests();
375
+ vi.useFakeTimers();
376
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
377
+ const element = document.body.appendChild(document.createElement("div"));
378
+ element.id = "dot-pending";
379
+ let saved!: () => void;
380
+ const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
381
+ act(() => {
382
+ root.render(
383
+ React.createElement(Harness, {
384
+ selection: makeSelection("Dot", element),
385
+ onPathOffsetCommit: commit,
386
+ }),
387
+ );
388
+ });
389
+ expect(hasStudioPendingEdits()).toBe(false);
390
+ act(() => dispatchArrowRight());
391
+ expect(hasStudioPendingEdits()).toBe(true);
392
+ act(() => vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10));
393
+ expect(commit).toHaveBeenCalledTimes(1);
394
+ expect(hasStudioPendingEdits()).toBe(true);
395
+ vi.useRealTimers();
396
+ saved();
397
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
398
+ act(() => root.unmount());
399
+ });
400
+ });
401
+
402
+ describe("useDomEditNudge — a commit that throws", () => {
403
+ it("still ends the burst's pending edit, so undo and export never wait on it", async () => {
404
+ __resetForTests();
405
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
406
+ const element = document.body.appendChild(document.createElement("div"));
407
+ element.id = "dot-throws";
408
+ const commit = vi.fn(() => {
409
+ throw new Error("The commit threw.");
410
+ });
411
+ act(() => {
412
+ root.render(
413
+ React.createElement(Harness, {
414
+ selection: makeSelection("Dot", element),
415
+ onPathOffsetCommit: commit,
416
+ }),
417
+ );
418
+ });
419
+ act(() => dispatchArrowRight());
420
+ expect(hasStudioPendingEdits()).toBe(true);
421
+ expect(() => flushNudge()).toThrow("The commit threw.");
422
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
423
+ act(() => root.unmount());
424
+ });
425
+ });