@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
@@ -9,6 +9,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
9
9
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
10
  import type { TimelineElement } from "../player";
11
11
  import type { UseDomEditSessionParams } from "./useDomEditSession";
12
+ import { makeSelection } from "./domSelectionTestHarness";
12
13
 
13
14
  const styleOp = (property: string, value: string): PatchOperation => ({
14
15
  type: "inline-style",
@@ -132,26 +133,30 @@ vi.mock("./useDomEditCommits", () => ({
132
133
  };
133
134
  },
134
135
  }));
136
+ const iframesHandedOn: Array<{ hook: string; iframe: unknown }> = [];
135
137
  vi.mock("./useDomSelection", () => ({
136
- useDomSelection: () => ({
137
- domEditSelection: domEditSelectionRef.current,
138
- domEditGroupSelections: [],
139
- domEditHoverSelection: null,
140
- activeGroupElement: null,
141
- domEditSelectionRef,
142
- domEditGroupSelectionsRef,
143
- setActiveGroupElement: vi.fn(),
144
- applyDomSelection: vi.fn(),
145
- clearDomSelection: vi.fn(),
146
- buildDomSelectionFromTarget: vi.fn(),
147
- resolveDomSelectionFromPreviewPoint: vi.fn(),
148
- resolveAllDomSelectionsFromPreviewPoint: vi.fn(),
149
- updateDomEditHoverSelection: vi.fn(),
150
- buildDomSelectionForTimelineElement: vi.fn(),
151
- handleTimelineElementSelect: vi.fn(),
152
- refreshDomEditSelectionFromPreview: vi.fn(),
153
- applyMarqueeSelection: vi.fn(),
154
- }),
138
+ useDomSelection: ({ previewIframe }: { previewIframe: unknown }) => {
139
+ iframesHandedOn.push({ hook: "selection", iframe: previewIframe });
140
+ return {
141
+ domEditSelection: domEditSelectionRef.current,
142
+ domEditGroupSelections: [],
143
+ domEditHoverSelection: null,
144
+ activeGroupElement: null,
145
+ domEditSelectionRef,
146
+ domEditGroupSelectionsRef,
147
+ setActiveGroupElement: vi.fn(),
148
+ applyDomSelection: vi.fn(),
149
+ clearDomSelection: vi.fn(),
150
+ buildDomSelectionFromTarget: vi.fn(),
151
+ resolveDomSelectionFromPreviewPoint: vi.fn(),
152
+ resolveAllDomSelectionsFromPreviewPoint: vi.fn(),
153
+ updateDomEditHoverSelection: vi.fn(),
154
+ buildDomSelectionForTimelineElement: vi.fn(),
155
+ handleTimelineElementSelect: vi.fn(),
156
+ refreshDomEditSelectionFromPreview: vi.fn(),
157
+ applyMarqueeSelection: vi.fn(),
158
+ };
159
+ },
155
160
  }));
156
161
  vi.mock("./useAskAgentModal", () => ({
157
162
  useAskAgentModal: () => ({
@@ -208,33 +213,36 @@ vi.mock("./useGroupCommits", () => ({
208
213
  }),
209
214
  }));
210
215
  vi.mock("./useDomEditWiring", () => ({
211
- useDomEditWiring: () => ({
212
- onClickToSource: vi.fn(),
213
- selectedGsapAnimations: [],
214
- gsapMultipleTimelines: false,
215
- gsapUnsupportedTimelinePattern: false,
216
- trackGsapInteractionFailure: vi.fn(),
217
- makeFetchFallback: vi.fn(),
218
- handleGsapUpdateProperty: vi.fn(),
219
- handleGsapUpdateMeta: vi.fn(),
220
- handleGsapDeleteAnimation: vi.fn(),
221
- handleGsapDeleteAllForElement: vi.fn(),
222
- handleGsapAddAnimation: vi.fn(),
223
- handleGsapAddProperty: vi.fn(),
224
- handleGsapRemoveProperty: vi.fn(),
225
- handleGsapUpdateFromProperty: vi.fn(),
226
- handleGsapAddFromProperty: vi.fn(),
227
- handleGsapRemoveFromProperty: vi.fn(),
228
- handleGsapAddKeyframe: vi.fn(),
229
- handleGsapAddKeyframeBatch: vi.fn(),
230
- handleGsapRemoveKeyframe: vi.fn(),
231
- handleGsapMoveKeyframeToPlayhead: vi.fn(),
232
- handleGsapMoveKeyframe: vi.fn(),
233
- handleGsapResizeKeyframedTween: vi.fn(),
234
- handleGsapConvertToKeyframes: vi.fn(),
235
- handleGsapRemoveAllKeyframes: vi.fn(),
236
- handleResetSelectedElementKeyframes: vi.fn(),
237
- }),
216
+ useDomEditWiring: ({ previewIframe }: { previewIframe: unknown }) => {
217
+ iframesHandedOn.push({ hook: "wiring", iframe: previewIframe });
218
+ return {
219
+ onClickToSource: vi.fn(),
220
+ selectedGsapAnimations: [],
221
+ gsapMultipleTimelines: false,
222
+ gsapUnsupportedTimelinePattern: false,
223
+ trackGsapInteractionFailure: vi.fn(),
224
+ makeFetchFallback: vi.fn(),
225
+ handleGsapUpdateProperty: vi.fn(),
226
+ handleGsapUpdateMeta: vi.fn(),
227
+ handleGsapDeleteAnimation: vi.fn(),
228
+ handleGsapDeleteAllForElement: vi.fn(),
229
+ handleGsapAddAnimation: vi.fn(),
230
+ handleGsapAddProperty: vi.fn(),
231
+ handleGsapRemoveProperty: vi.fn(),
232
+ handleGsapUpdateFromProperty: vi.fn(),
233
+ handleGsapAddFromProperty: vi.fn(),
234
+ handleGsapRemoveFromProperty: vi.fn(),
235
+ handleGsapAddKeyframe: vi.fn(),
236
+ handleGsapAddKeyframeBatch: vi.fn(),
237
+ handleGsapRemoveKeyframe: vi.fn(),
238
+ handleGsapMoveKeyframeToPlayhead: vi.fn(),
239
+ handleGsapMoveKeyframe: vi.fn(),
240
+ handleGsapResizeKeyframedTween: vi.fn(),
241
+ handleGsapConvertToKeyframes: vi.fn(),
242
+ handleGsapRemoveAllKeyframes: vi.fn(),
243
+ handleResetSelectedElementKeyframes: vi.fn(),
244
+ };
245
+ },
238
246
  }));
239
247
  vi.mock("./usePreviewInteraction", () => ({
240
248
  usePreviewInteraction: () => ({
@@ -310,32 +318,9 @@ describe("onReorderShadow source filter", () => {
310
318
  describe("bulk segment ease commits", () => {
311
319
  it("uses one ordered batch for many ids and sane paths for one or no ids", async () => {
312
320
  const { useDomEditSession } = await import("./useDomEditSession");
313
- const selection: DomEditSelection = {
314
- id: "hero",
315
- element: document.createElement("div"),
316
- label: "Hero",
317
- tagName: "DIV",
318
- sourceFile: "index.html",
319
- compositionPath: "index.html",
320
- isCompositionHost: false,
321
- isInsideLockedComposition: false,
322
- boundingBox: { x: 0, y: 0, width: 100, height: 100 },
323
- textContent: null,
324
- dataAttributes: {},
325
- inlineStyles: {},
326
- computedStyles: {},
327
- textFields: [],
328
- capabilities: {
329
- canSelect: true,
330
- canEditStyles: true,
331
- canCrop: true,
332
- canMove: true,
333
- canResize: true,
334
- canApplyManualOffset: true,
335
- canApplyManualSize: true,
336
- canApplyManualRotation: true,
337
- },
338
- };
321
+ const hero = document.createElement("div");
322
+ hero.id = "hero";
323
+ const selection = makeSelection("Hero", hero);
339
324
  domEditSelectionRef.current = selection;
340
325
  gsapCommitMutation.mockClear();
341
326
  gsapCommitMutation.batch.mockClear();
@@ -601,3 +586,22 @@ describe("handleDomEditElementDelete routing", () => {
601
586
  expect(handleDomEditElementsDeleteMock).not.toHaveBeenCalled();
602
587
  });
603
588
  });
589
+
590
+ describe("a shadow reload promoted without a host re-render", () => {
591
+ it("hands the promoted iframe to the session's selection and preview sync", async () => {
592
+ const { useDomEditSession } = await import("./useDomEditSession");
593
+ const { announcePreviewPromoted } = await import("../player/sceneSwap");
594
+ const [host, promoted] = [0, 1].map(() => document.createElement("iframe"));
595
+ function Probe() {
596
+ useDomEditSession(createSessionParams({ previewIframe: host! }));
597
+ return null;
598
+ }
599
+ const root = createRoot(document.createElement("div"));
600
+ act(() => root.render(<Probe />));
601
+ act(() => announcePreviewPromoted({ retired: host!, live: promoted! }));
602
+ const last = (hook: string) => iframesHandedOn.findLast((seen) => seen.hook === hook)?.iframe;
603
+ expect(last("selection")).toBe(promoted);
604
+ expect(last("wiring")).toBe(promoted);
605
+ act(() => root.unmount());
606
+ });
607
+ });
@@ -24,6 +24,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
24
24
  import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMembers";
25
25
  import type { RecordEditInput } from "./domEditDeleteMembers";
26
26
  import type { DomEditTimelineParams } from "./useDomSelectionTypes";
27
+ import { useLivePreviewIframe } from "./useLivePreviewIframe";
27
28
  // Re-exported: the delete rule lives in its own module; callers and its test import it from here.
28
29
  export { membersForDelete };
29
30
 
@@ -86,14 +87,13 @@ export function useDomEditSession({
86
87
  importedFontAssetsRef,
87
88
  projectDir,
88
89
  projectIdRef,
89
- previewIframe,
90
+ previewIframe: hostPreviewIframe,
90
91
  refreshKey,
91
92
  previewDocumentVersion,
92
93
  rightPanelTab,
93
94
  applyStudioManualEditsToPreviewRef,
94
95
  syncPreviewHotkeys,
95
96
  reloadPreview,
96
- setRefreshKey: _setRefreshKey,
97
97
  openSourceForSelection,
98
98
  sdkSession,
99
99
  publishSdkSession,
@@ -103,7 +103,7 @@ export function useDomEditSession({
103
103
  }: UseDomEditSessionParams) {
104
104
  const isMasterView = !activeCompPath || activeCompPath === "index.html";
105
105
  const previewCaptionEditMode = captionEditMode && !readOnlyPreview;
106
- void _setRefreshKey;
106
+ const previewIframe = useLivePreviewIframe(hostPreviewIframe);
107
107
  const {
108
108
  domEditSelection,
109
109
  domEditGroupSelections,
@@ -173,29 +173,11 @@ export function useDomEditSession({
173
173
 
174
174
  const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
175
175
 
176
- // ── GSAP script commits ──
177
-
178
176
  const {
179
177
  commitMutation: gsapCommitMutation,
180
- updateGsapProperty,
181
- updateGsapMeta,
182
- deleteGsapAnimation,
183
- deleteAllForSelector,
184
- addGsapAnimation,
185
- addGsapProperty,
186
- removeGsapProperty,
187
- updateGsapFromProperty,
188
- addGsapFromProperty,
189
- removeGsapFromProperty,
190
- addKeyframe,
191
- addKeyframeBatch,
192
- removeKeyframe,
193
- moveKeyframe,
194
- resizeKeyframedTween,
195
- convertToKeyframes,
196
- removeAllKeyframes,
197
178
  setArcPath,
198
179
  updateArcSegment,
180
+ ...gsapScriptEdits
199
181
  } = useGsapScriptCommits({
200
182
  projectIdRef,
201
183
  activeCompPath,
@@ -211,8 +193,6 @@ export function useDomEditSession({
211
193
  forceReloadSdkSession,
212
194
  });
213
195
 
214
- // ── DOM commit handlers ──
215
-
216
196
  const {
217
197
  resolveImportedFontAsset,
218
198
  handleDomStyleCommit,
@@ -430,23 +410,7 @@ export function useDomEditSession({
430
410
  applyDomSelection,
431
411
  buildDomSelectionFromTarget,
432
412
  openSourceForSelection,
433
- updateGsapProperty,
434
- updateGsapMeta,
435
- deleteGsapAnimation,
436
- deleteAllForSelector,
437
- addGsapAnimation,
438
- addGsapProperty,
439
- removeGsapProperty,
440
- updateGsapFromProperty,
441
- addGsapFromProperty,
442
- removeGsapFromProperty,
443
- addKeyframe,
444
- addKeyframeBatch,
445
- removeKeyframe,
446
- moveKeyframe,
447
- resizeKeyframedTween,
448
- convertToKeyframes,
449
- removeAllKeyframes,
413
+ ...gsapScriptEdits,
450
414
  handleDomManualEditsReset,
451
415
  });
452
416
  const {
@@ -492,8 +456,8 @@ export function useDomEditSession({
492
456
  handleDomBoxSizeCommit,
493
457
  handleDomRotationCommit: handleDomCssRotationCommit,
494
458
  commitPositionPatchToHtml,
495
- addGsapAnimation,
496
- convertToKeyframes,
459
+ addGsapAnimation: gsapScriptEdits.addGsapAnimation,
460
+ convertToKeyframes: gsapScriptEdits.convertToKeyframes,
497
461
  setArcPath,
498
462
  updateArcSegment,
499
463
  });
@@ -336,8 +336,7 @@ export function usePopulateKeyframeCacheForFile(
336
336
  // fallow-ignore-next-line complexity
337
337
  const tryRuntimeScan = () => {
338
338
  if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
339
- const iframe =
340
- iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
339
+ const iframe = iframeRef?.current;
341
340
  if (!iframe) return false;
342
341
  // Clip dims per element so the scan converts tween-relative keyframes to
343
342
  // clip-relative (matching the static path) instead of timeline-relative.
@@ -1,6 +1,12 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { sanitizeRichTextChildren } from "@hyperframes/core/rich-text-sanitize";
3
3
  import { usePreviewReadOnly } from "../components/editor/previewReadOnlyContext";
4
+ import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
5
+ import {
6
+ beginStudioManualEditGesture,
7
+ endStudioManualEditGesture,
8
+ noteStudioManualEditSave,
9
+ } from "../components/editor/manualEditsDom";
4
10
 
5
11
  /**
6
12
  * Editing an element's text where it sits, in the composition itself.
@@ -43,6 +49,8 @@ export interface InlineTextEditSession {
43
49
  outline: string;
44
50
  /** The element's own outline offset, restored with the outline. */
45
51
  outlineOffset: string;
52
+ /** Holds the preview on screen while the edit is open. */
53
+ gesture?: string;
46
54
  }
47
55
 
48
56
  /** The live markup to persist and the session snapshot to restore on failure. */
@@ -86,7 +94,7 @@ export function useInlineTextEdit({
86
94
  onPause,
87
95
  }: {
88
96
  /** Where the edited text goes. The caller owns persistence. */
89
- onCommit: (commit: InlineTextEditCommit) => void;
97
+ onCommit: (commit: InlineTextEditCommit) => unknown;
90
98
  /** Stop playback, so the element is not animating under the caret. */
91
99
  onPause?: () => void;
92
100
  }): InlineTextEditControls {
@@ -106,6 +114,7 @@ export function useInlineTextEdit({
106
114
  framesRef.current = null;
107
115
  }
108
116
  openRef.current = null;
117
+ endStudioManualEditGesture(open.element, open.gesture);
109
118
  setSession(null);
110
119
  // An element removed from the document mid-session is not an error, it is
111
120
  // just nothing left to clean up.
@@ -132,6 +141,7 @@ export function useInlineTextEdit({
132
141
  original: element.innerHTML,
133
142
  outline: element.style.outline,
134
143
  outlineOffset: element.style.outlineOffset,
144
+ gesture: beginStudioManualEditGesture(element),
135
145
  };
136
146
  // Drawn on the element itself, not in Studio's overlay above it. This is
137
147
  // the only mark that says the caret is in the TEXT rather than the
@@ -168,10 +178,14 @@ export function useInlineTextEdit({
168
178
  // saved rather than something the server will quietly cut down.
169
179
  sanitizeRichTextChildren(open.element);
170
180
  const html = open.element.innerHTML;
181
+ // Counted and pending before the edit closes, so a reload waits for the save, not the mark.
182
+ let save: (saving: unknown) => void = () => {};
183
+ trackStudioPendingEdit(new Promise((resolve) => (save = resolve)));
184
+ noteStudioManualEditSave(open.element);
171
185
  teardown();
172
186
  // After teardown, so the commit path's own resync does not fight an
173
187
  // element that is still editable.
174
- onCommit({ element: open.element, html, previousHtml: open.original });
188
+ save(onCommit({ element: open.element, html, previousHtml: open.original }));
175
189
  }, [onCommit, teardown]);
176
190
 
177
191
  const cancel = useCallback(() => {
@@ -0,0 +1,18 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { onPreviewPromoted } from "../player/sceneSwap";
3
+
4
+ /** The one source for which preview is on screen: the host's iframe, then each reload promoted in its place. */
5
+ export function useLivePreviewIframe(host: HTMLIFrameElement | null): HTMLIFrameElement | null {
6
+ const [promoted, setPromoted] = useState<{ host: HTMLIFrameElement; live: HTMLIFrameElement }>();
7
+ const live = host && promoted?.host === host ? promoted.live : host;
8
+ const liveRef = useRef(live);
9
+ liveRef.current = live;
10
+ // eslint-disable-next-line no-restricted-syntax
11
+ useEffect(() => {
12
+ if (!host) return;
13
+ return onPreviewPromoted(host.ownerDocument, ({ retired, live: next }) => {
14
+ if (retired === liveRef.current) setPromoted({ host, live: next });
15
+ });
16
+ }, [host]);
17
+ return live;
18
+ }
@@ -0,0 +1,47 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { usePlayerStore } from "../player/store/playerStore";
6
+ import type { TimelineElement } from "../player/store/timelineElement";
7
+ import { mountReactHarness } from "./domSelectionTestHarness";
8
+ import { usePreviewPersistence } from "./usePreviewPersistence";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ afterEach(() => {
13
+ document.body.innerHTML = "";
14
+ usePlayerStore.getState().reset();
15
+ });
16
+
17
+ describe("undo that reloads the preview", () => {
18
+ it("keeps the timeline's clips until the reloaded preview reports its own", async () => {
19
+ const clips = [{ id: "a" }, { id: "b" }] as unknown as TimelineElement[];
20
+ usePlayerStore.getState().setElements(clips);
21
+ usePlayerStore.getState().setTimelineReady(true);
22
+ const reloadPreview = vi.fn();
23
+ let sync: ReturnType<typeof usePreviewPersistence>["syncHistoryPreviewAfterApply"] | null =
24
+ null;
25
+ function Harness() {
26
+ sync = usePreviewPersistence({
27
+ showToast: () => {},
28
+ readOptionalProjectFile: async () => "",
29
+ writeProjectFile: async () => {},
30
+ recordEdit: async () => {},
31
+ previewIframeRef: { current: null },
32
+ activeCompPathRef: { current: "index.html" },
33
+ reloadPreview,
34
+ }).syncHistoryPreviewAfterApply;
35
+ return null;
36
+ }
37
+ mountReactHarness(<Harness />);
38
+
39
+ // A nested composition's undo is not the active file's, so it reloads the preview.
40
+ const files = { "compositions/sub.html": { previous: "<p>1</p>", restored: "<p>2</p>" } };
41
+ await act(async () => sync!({ paths: Object.keys(files), files }));
42
+
43
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
44
+ const player = usePlayerStore.getState();
45
+ expect([player.elements, player.timelineReady]).toEqual([clips, true]);
46
+ });
47
+ });
@@ -207,16 +207,13 @@ export function usePreviewPersistence({
207
207
  reloadPreview,
208
208
  );
209
209
  if (strategy === "full") {
210
- const player = usePlayerStore.getState();
211
- player.setElements([]);
212
- player.setSelectedElementId(null);
213
- player.setTimelineReady(false);
210
+ usePlayerStore.getState().setSelectedElementId(null);
214
211
  return;
215
212
  }
216
213
  // A soft restore patched the reverted attributes onto the live preview, but the
217
214
  // player store keeps its own copy and that copy is what the automation lanes
218
215
  // draw — so without this an undone envelope edit stayed invisible until a
219
- // reload. The full path above clears the store and waits for discovery instead.
216
+ // reload. The full path above waits for the reloaded preview to report instead.
220
217
  syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
221
218
  },
222
219
  [previewIframeRef, activeCompPathRef, reloadPreview],
@@ -9,7 +9,7 @@ import {
9
9
  import type { TimelineElement } from "../player";
10
10
  import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
11
11
  import { audioPillFlags } from "../player/components/audioClipLink";
12
- import { AudioWaveform } from "../player/components/AudioWaveform";
12
+ import { AudioWaveform, rendersWaveform } from "../player/components/AudioWaveform";
13
13
  import { ImageThumbnail } from "../player/components/ImageThumbnail";
14
14
  import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
15
15
  import { usePlayerStore } from "../player/store/playerStore";
@@ -135,7 +135,7 @@ export function useRenderClipContent({
135
135
  // Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
136
136
  // its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
137
137
  if (effectiveMode === "hidden") {
138
- return el.tag === "audio"
138
+ return rendersWaveform(el)
139
139
  ? renderAudioClip(el, pid, sessionEpoch, style.label, context, elements)
140
140
  : null;
141
141
  }
@@ -173,7 +173,7 @@ export function useRenderClipContent({
173
173
  // Audio clips — waveform visualization. Resolve these before the generic
174
174
  // activePreviewUrl thumbnail branch; audio rows need waveform data, not a
175
175
  // captured frame from the currently drilled composition preview.
176
- if (el.tag === "audio") {
176
+ if (rendersWaveform(el)) {
177
177
  return renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
178
178
  }
179
179
 
package/src/index.ts CHANGED
@@ -181,6 +181,8 @@ export type { UseClipboardOptions } from "./hooks/useClipboard";
181
181
  // DOM editing for a host outside EditorShell; the Commit hooks save without useDomEditSession.
182
182
  export { useDomEditSession } from "./hooks/useDomEditSession";
183
183
  export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
184
+ export { useLivePreviewIframe } from "./hooks/useLivePreviewIframe";
185
+ export type { PreviewPromotion } from "./player/sceneSwap";
184
186
  export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
185
187
  export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence";
186
188
  export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditContext";
@@ -0,0 +1,33 @@
1
+ // @vitest-environment happy-dom
2
+ // Reads the preview on screen the way a host app does: by package name.
3
+ import { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { expect, expectTypeOf, it } from "vitest";
6
+ import { useLivePreviewIframe, type PreviewPromotion } from "@hyperframes/studio";
7
+ import {
8
+ makePreview,
9
+ mountPlayerWithPreview,
10
+ paintShadow,
11
+ } from "./player/hooks/timelinePlayerTestHarness";
12
+
13
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
14
+
15
+ it("gives a host the preview on screen after a real shadow reload is promoted", async () => {
16
+ expectTypeOf<PreviewPromotion>().toHaveProperty("live");
17
+ const host = makePreview("<h1>Title</h1>");
18
+ const player = mountPlayerWithPreview(host);
19
+ let live: HTMLIFrameElement | null = null;
20
+ function Probe() {
21
+ live = useLivePreviewIframe(host);
22
+ return null;
23
+ }
24
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
25
+ act(() => root.render(<Probe />));
26
+ act(() => player.getApi().refreshPlayer());
27
+ const shadow = makePreview("<h1>Title</h1>", "?_t=1");
28
+ await paintShadow(player.getApi, shadow);
29
+ expect(player.getApi().iframeRef.current).toBe(shadow);
30
+ expect(live).toBe(shadow);
31
+ act(() => root.unmount());
32
+ act(() => player.root.unmount());
33
+ });
package/src/main.tsx CHANGED
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import { IconContext } from "@phosphor-icons/react";
4
4
  import { StudioApp } from "./App";
5
+ import { ShowThemeToggle } from "./components/ThemeToggle";
5
6
  import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
6
7
  import { readIconTokens } from "./styles/iconTokens";
7
8
  import { trackStudioEvent } from "./utils/studioTelemetry";
@@ -128,7 +129,9 @@ createRoot(document.getElementById("root")!).render(
128
129
  Icons that pass their own size or weight still win. */}
129
130
  <IconContext.Provider value={readIconTokens()}>
130
131
  <StudioErrorBoundary>
131
- <StudioApp />
132
+ <ShowThemeToggle.Provider value>
133
+ <StudioApp />
134
+ </ShowThemeToggle.Provider>
132
135
  </StudioErrorBoundary>
133
136
  </IconContext.Provider>
134
137
  </StrictMode>,
@@ -20,24 +20,30 @@ afterEach(() => {
20
20
  document.body.innerHTML = "";
21
21
  });
22
22
 
23
+ /** A 6 x 20 canvas whose 2D context records every fill as [style, x, y, width, height]. */
24
+ function recordingCanvas() {
25
+ const fills: Array<[string, number, number, number, number]> = [];
26
+ const context = {
27
+ scale: vi.fn(),
28
+ clearRect: vi.fn(),
29
+ fillStyle: "",
30
+ fillRect: (x: number, y: number, width: number, height: number) =>
31
+ fills.push([String(context.fillStyle), x, y, width, height]),
32
+ } as unknown as CanvasRenderingContext2D;
33
+ const canvas = document.createElement("canvas");
34
+ Object.defineProperties(canvas, {
35
+ clientWidth: { value: 6 },
36
+ clientHeight: { value: 20 },
37
+ });
38
+ vi.spyOn(canvas, "getContext").mockReturnValue(context);
39
+ return { canvas, fills };
40
+ }
41
+
23
42
  describe("AudioWaveform", () => {
24
43
  it("paints a baseline and peak bar for every mapped waveform bin", () => {
25
- const fillRect = vi.fn();
26
- const context = {
27
- scale: vi.fn(),
28
- clearRect: vi.fn(),
29
- fillRect,
30
- fillStyle: "",
31
- } as unknown as CanvasRenderingContext2D;
32
- const canvas = document.createElement("canvas");
33
- Object.defineProperties(canvas, {
34
- clientWidth: { value: 6 },
35
- clientHeight: { value: 20 },
36
- });
37
- vi.spyOn(canvas, "getContext").mockReturnValue(context);
44
+ const { canvas, fills } = recordingCanvas();
38
45
  drawWaveformCanvas(canvas, [0.25, 1], false, 0, 1);
39
- expect(fillRect).toHaveBeenCalledTimes(4);
40
- expect(fillRect.mock.calls.map(([x, y, width, height]) => [x, y, width, height])).toEqual([
46
+ expect(fills.map(([, x, y, width, height]) => [x, y, width, height])).toEqual([
41
47
  [0, 18, 3, 2],
42
48
  [0, 15, 3, 5],
43
49
  [3, 18, 3, 2],
@@ -45,6 +51,21 @@ describe("AudioWaveform", () => {
45
51
  ]);
46
52
  });
47
53
 
54
+ it("shrinks each bar to the fade's gain and keeps the cut-away part as a ghost", () => {
55
+ const { canvas, fills } = recordingCanvas();
56
+ // A 1 s fade-in on a 2 s clip: the first bar's centre (0.5 s) plays at half gain.
57
+ drawWaveformCanvas(canvas, [1, 1], false, 0, 1, { fadeIn: 1, fadeOut: 0, duration: 2 });
58
+ const bars = fills.filter(([, , y, , height]) => !(y === 18 && height === 2));
59
+ // The heard half, then the ghost only above it, so the two never stack.
60
+ expect(bars.map(([, x, y, width, height]) => [x, y, width, height])).toEqual([
61
+ [0, 10, 3, 10],
62
+ [0, 0, 3, 10],
63
+ [3, 0, 3, 20],
64
+ ]);
65
+ const alpha = (style: string) => Number(style.split(",").at(-1)?.replace(")", ""));
66
+ expect(alpha(bars[1][0])).toBeCloseTo(alpha(bars[0][0]) * 0.27, 2);
67
+ });
68
+
48
69
  it("leases waveform decoding with the clip's project, session, and viewport priority", () => {
49
70
  const host = document.createElement("div");
50
71
  document.body.append(host);