@hyperframes/studio 0.8.86 → 0.8.88

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 (96) hide show
  1. package/dist/assets/hyperframes-player-w3Bbv0kd.js +473 -0
  2. package/dist/assets/{index-Cz3E_ZSj.js → index-B6PGJTzp.js} +1 -1
  3. package/dist/assets/{index-d5DKK15Q.js → index-DE6cZYR5.js} +199 -199
  4. package/dist/assets/{index-8Fgryj7q.js → index-DldknZPp.js} +1 -1
  5. package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
  6. package/dist/{chunk-7PLTC6SV.js → chunk-KU4F7QZ3.js} +9 -1
  7. package/dist/chunk-KU4F7QZ3.js.map +1 -0
  8. package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
  9. package/dist/chunk-PQFEGZWM.js.map +1 -0
  10. package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
  11. package/dist/index.d.ts +206 -160
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +2121 -1702
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +1 -1
  16. package/dist/styles.css +1 -1
  17. package/package.json +8 -8
  18. package/src/components/TimelineHistoryButtons.dom.test.tsx +14 -3
  19. package/src/components/TimelineToolPicker.tsx +90 -0
  20. package/src/components/TimelineToolbar.tsx +2 -27
  21. package/src/components/editor/DomEditOverlay.tsx +1 -2
  22. package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
  23. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
  24. package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
  25. package/src/components/editor/LayersPanel.test.ts +14 -0
  26. package/src/components/editor/LayersPanel.tsx +2 -2
  27. package/src/components/editor/TopologyLens.tsx +2 -2
  28. package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
  29. package/src/components/editor/domEditOverlayGestures.ts +0 -1
  30. package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
  31. package/src/components/editor/domEditing.test.ts +29 -0
  32. package/src/components/editor/domEditingDom.ts +1 -0
  33. package/src/components/editor/domEditingTypes.ts +1 -0
  34. package/src/components/editor/marqueeCommit.test.tsx +84 -0
  35. package/src/components/editor/marqueeCommit.ts +13 -1
  36. package/src/components/editor/useColorGradingController.test.ts +24 -0
  37. package/src/components/editor/useColorGradingController.ts +3 -2
  38. package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
  39. package/src/components/nle/useCompositionStack.test.tsx +53 -0
  40. package/src/components/nle/useCompositionStack.ts +4 -8
  41. package/src/hooks/appHotkeysDispatch.ts +8 -0
  42. package/src/hooks/domStyleCommit.ts +129 -0
  43. package/src/hooks/gsapEditOutcome.ts +2 -2
  44. package/src/hooks/gsapElementMatch.ts +38 -0
  45. package/src/hooks/gsapResizeIntercept.ts +10 -31
  46. package/src/hooks/gsapResizePreflight.ts +56 -0
  47. package/src/hooks/gsapRuntimeBridge.ts +66 -26
  48. package/src/hooks/keyframeCacheAstLoad.ts +4 -1
  49. package/src/hooks/useAppHotkeys.test.ts +58 -1
  50. package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
  51. package/src/hooks/useDomEditCommits.ts +17 -182
  52. package/src/hooks/useDomEditPersist.ts +225 -0
  53. package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
  54. package/src/hooks/useDomEditPreviewSync.ts +2 -4
  55. package/src/hooks/useDomEditSession.test.tsx +51 -1
  56. package/src/hooks/useDomEditSession.ts +11 -2
  57. package/src/hooks/useDomEditTextCommits.ts +15 -99
  58. package/src/hooks/useDomStyleCommit.test.tsx +155 -0
  59. package/src/hooks/useDomStyleCommit.ts +74 -0
  60. package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
  61. package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
  62. package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
  63. package/src/hooks/useGsapAwareEditing.ts +1 -1
  64. package/src/hooks/useGsapTweenCache.ts +2 -48
  65. package/src/index.ts +10 -1
  66. package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
  67. package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
  68. package/src/player/components/Timeline.playheadRemount.test.tsx +3 -27
  69. package/src/player/components/Timeline.showKeyframes.test.tsx +91 -0
  70. package/src/player/components/TimelineGroupHeader.tsx +21 -10
  71. package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
  72. package/src/player/components/TimelineGroupRow.test.tsx +24 -33
  73. package/src/player/components/TimelineGroupRow.tsx +4 -1
  74. package/src/player/components/TimelineLanes.tsx +5 -5
  75. package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
  76. package/src/player/components/TimelineTrackHeader.tsx +6 -3
  77. package/src/player/components/TimelineTypes.ts +2 -0
  78. package/src/player/components/studioShortcuts.ts +2 -0
  79. package/src/player/components/timelineLaneProps.ts +1 -0
  80. package/src/player/components/timelineMountTestEnv.ts +29 -0
  81. package/src/player/components/useTimelineKeyframeData.ts +14 -0
  82. package/src/player/components/useTimelineLabelColumn.ts +18 -0
  83. package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
  84. package/src/player/components/useTimelineProviderState.tsx +10 -14
  85. package/src/player/components/useTimelineRangeSelection.ts +13 -12
  86. package/src/player/hooks/useShadowPreviewReload.ts +2 -0
  87. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
  88. package/src/player/hooks/useTimelinePlayer.ts +29 -7
  89. package/src/player/lib/runtimeProtocol.test.ts +1 -0
  90. package/src/player/sceneSwap.test.ts +95 -0
  91. package/src/player/sceneSwap.ts +53 -0
  92. package/src/player/store/playerStore.ts +16 -0
  93. package/dist/assets/hyperframes-player-Dx-2Gpsu.js +0 -473
  94. package/dist/chunk-3IOCVWB6.js.map +0 -1
  95. package/dist/chunk-7PLTC6SV.js.map +0 -1
  96. /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
@@ -0,0 +1,155 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement, useRef } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { useDomStyleCommit, type UseDomStyleCommitOptions } from "../index";
6
+ import { makeSelection } from "./domSelectionTestHarness";
7
+
8
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
9
+
10
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
11
+
12
+ const SOURCE = '<div id="card" style="color: red">Card</div>';
13
+
14
+ /** Each patch answers with the next status in `failures` (none left: success). */
15
+ function stubServer(failures: number[] = []) {
16
+ const patches: unknown[] = [];
17
+ vi.stubGlobal(
18
+ "fetch",
19
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
20
+ const url = String(input instanceof Request ? input.url : input);
21
+ const json = (body: unknown) =>
22
+ new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } });
23
+ if (url.includes("/api/projects/p1/files/")) return json({ content: SOURCE });
24
+ if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
25
+ patches.push(JSON.parse(String(init?.body)));
26
+ const status = failures.shift();
27
+ if (status) return new Response(JSON.stringify({ error: "refused" }), { status });
28
+ return json({
29
+ ok: true,
30
+ changed: true,
31
+ matched: true,
32
+ content: "<div>saved</div>",
33
+ path: "index.html",
34
+ version: "v2",
35
+ });
36
+ }
37
+ throw new Error(`Unexpected fetch: ${url}`);
38
+ }),
39
+ );
40
+ return patches;
41
+ }
42
+
43
+ function renderHost(options: Partial<UseDomStyleCommitOptions> = {}) {
44
+ const iframe = document.createElement("iframe");
45
+ document.body.append(iframe);
46
+ const doc = iframe.contentDocument;
47
+ if (!doc) throw new Error("Expected iframe document");
48
+ doc.body.innerHTML = SOURCE;
49
+ const element = doc.getElementById("card") as HTMLElement;
50
+ const recordEdit = vi.fn(async () => {});
51
+ const api: { current: ReturnType<typeof useDomStyleCommit> | null } = { current: null };
52
+ function Host() {
53
+ const iframeRef = useRef<HTMLIFrameElement | null>(iframe);
54
+ api.current = useDomStyleCommit({
55
+ projectId: "p1",
56
+ iframeRef,
57
+ writeProjectFile: vi.fn(async () => {}),
58
+ recordEdit,
59
+ ...options,
60
+ });
61
+ return null;
62
+ }
63
+ const root = createRoot(document.createElement("div"));
64
+ act(() => root.render(createElement(Host)));
65
+ const hook = () => {
66
+ if (!api.current) throw new Error("Expected the hook to render");
67
+ return api.current;
68
+ };
69
+ return { element, recordEdit, hook, unmount: () => act(() => root.unmount()) };
70
+ }
71
+
72
+ afterEach(() => {
73
+ vi.unstubAllGlobals();
74
+ document.body.innerHTML = "";
75
+ });
76
+
77
+ describe("useDomStyleCommit, from the package entry", () => {
78
+ it("applies several properties live and saves them as one patch and one undo step", async () => {
79
+ const patches = stubServer();
80
+ const { element, recordEdit, hook, unmount } = renderHost();
81
+
82
+ const outcome = await hook().commitStyle(makeSelection("card", element), {
83
+ color: "blue",
84
+ "font-size": "40px",
85
+ });
86
+ await hook().waitForPendingSaves();
87
+
88
+ expect(outcome.ok).toBe(true);
89
+ expect(element.style.color).toBe("blue");
90
+ expect(element.style.fontSize).toBe("40px");
91
+ expect(patches).toHaveLength(1);
92
+ expect((patches[0] as { operations: unknown[] }).operations).toHaveLength(2);
93
+ expect(recordEdit).toHaveBeenCalledTimes(1);
94
+ expect(recordEdit).toHaveBeenCalledWith(expect.objectContaining({ label: "Edit layer style" }));
95
+ unmount();
96
+ });
97
+
98
+ it("refuses geometry and read-only selections with a reason, and writes nothing", async () => {
99
+ const patches = stubServer();
100
+ const { element, hook, unmount } = renderHost();
101
+ const selection = makeSelection("card", element);
102
+
103
+ await expect(hook().commitStyle(selection, { width: "10px" })).resolves.toEqual({
104
+ ok: false,
105
+ reason: "geometry-property",
106
+ });
107
+ const readOnly = {
108
+ ...selection,
109
+ capabilities: { ...selection.capabilities, canEditStyles: false },
110
+ };
111
+ await expect(hook().commitStyle(readOnly, { color: "blue" })).resolves.toEqual({
112
+ ok: false,
113
+ reason: "styles-not-editable",
114
+ });
115
+ expect(patches).toHaveLength(0);
116
+ expect(element.style.color).toBe("red");
117
+ unmount();
118
+ });
119
+
120
+ it("never lets a failed save revert a later edit's property", async () => {
121
+ stubServer([500]);
122
+ const { element, hook, unmount } = renderHost();
123
+ const selection = makeSelection("card", element);
124
+
125
+ const first = hook().commitStyle(selection, { color: "blue" });
126
+ const second = hook().commitStyle(selection, { color: "green", "font-size": "40px" });
127
+
128
+ expect((await first).ok).toBe(false);
129
+ expect((await second).ok).toBe(true);
130
+ expect(element.style.color).toBe("green");
131
+ unmount();
132
+ });
133
+
134
+ it("saves again after a conflict paused the queue", async () => {
135
+ stubServer([409]);
136
+ const { element, hook, unmount } = renderHost();
137
+ const selection = makeSelection("card", element);
138
+
139
+ expect((await hook().commitStyle(selection, { color: "blue" })).ok).toBe(false);
140
+ expect((await hook().commitStyle(selection, { color: "green" })).ok).toBe(true);
141
+ unmount();
142
+ });
143
+
144
+ it("refuses without a project", async () => {
145
+ stubServer();
146
+ const { element, hook, unmount } = renderHost({ projectId: null });
147
+ await expect(
148
+ hook().commitStyle(makeSelection("card", element), { color: "blue" }),
149
+ ).resolves.toEqual({
150
+ ok: false,
151
+ reason: "no-project",
152
+ });
153
+ unmount();
154
+ });
155
+ });
@@ -0,0 +1,74 @@
1
+ import { useCallback, useMemo, useRef, useState } from "react";
2
+ import type { DomEditSelection } from "../components/editor/domEditing";
3
+ import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
4
+ import type { DomEditCommitOutcome } from "./domEditCommitRunner";
5
+ import { commitDomStyles } from "./domStyleCommit";
6
+ import { useDomEditPersist, type RecordEditInput } from "./useDomEditPersist";
7
+ import { useMountEffect } from "./useMountEffect";
8
+
9
+ export interface UseDomStyleCommitOptions {
10
+ projectId: string | null;
11
+ /** The preview the host already runs: the style applies there live, and it reseeks after a save. */
12
+ iframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
13
+ /** The same function objects the host gives the timeline, so there is one writer and one undo list. */
14
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
15
+ recordEdit: (entry: RecordEditInput) => Promise<void>;
16
+ activeCompPath?: string | null;
17
+ showToast?: (message: string, tone?: "error" | "info") => void;
18
+ }
19
+
20
+ const noop = () => {};
21
+
22
+ /** Saves style edits on a selection through Studio's own style commit, for a host without the editor. */
23
+ export function useDomStyleCommit({
24
+ projectId,
25
+ iframeRef,
26
+ writeProjectFile,
27
+ recordEdit,
28
+ activeCompPath = null,
29
+ showToast = noop,
30
+ }: UseDomStyleCommitOptions): {
31
+ commitStyle: (
32
+ selection: DomEditSelection,
33
+ styles: Record<string, string>,
34
+ ) => Promise<DomEditCommitOutcome>;
35
+ waitForPendingSaves: () => Promise<DomEditSaveDrainResult>;
36
+ } {
37
+ const projectIdRef = useRef(projectId);
38
+ projectIdRef.current = projectId;
39
+ const [queue] = useState(createDomEditSaveQueue);
40
+ useMountEffect(() => () => queue.destroy());
41
+ const versions = useRef(new Map<string, symbol>()).current;
42
+ const editHistory = useMemo(() => ({ recordEdit }), [recordEdit]);
43
+ const persistDomEditOperations = useDomEditPersist({
44
+ activeCompPath,
45
+ previewIframeRef: iframeRef,
46
+ showToast,
47
+ queueDomEditSave: queue.enqueue,
48
+ writeProjectFile,
49
+ editHistory,
50
+ projectIdRef,
51
+ reloadPreview: noop,
52
+ });
53
+ const commitStyle = useCallback(
54
+ (selection: DomEditSelection, styles: Record<string, string>) => {
55
+ // Each call is the user's own retry, so a pause left by an earlier failed save never blocks it.
56
+ queue.reset();
57
+ return projectIdRef.current
58
+ ? commitDomStyles(
59
+ {
60
+ activeCompPath,
61
+ previewIframeRef: iframeRef,
62
+ persistDomEditOperations,
63
+ showToast,
64
+ versions,
65
+ },
66
+ selection,
67
+ styles,
68
+ )
69
+ : Promise.resolve<DomEditCommitOutcome>({ ok: false, reason: "no-project" });
70
+ },
71
+ [activeCompPath, iframeRef, persistDomEditOperations, queue, showToast, versions],
72
+ );
73
+ return { commitStyle, waitForPendingSaves: queue.waitForIdle };
74
+ }
@@ -1,3 +1,5 @@
1
+ // @vitest-environment happy-dom
2
+
1
3
  import { describe, expect, it } from "vitest";
2
4
  import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
3
5
  import {
@@ -41,6 +43,16 @@ describe("selectElementAnimationsOrRetry", () => {
41
43
  ]);
42
44
  });
43
45
 
46
+ it("matches a class tween through the live element, as the selected-element list does", () => {
47
+ const element = document.createElement("div");
48
+ element.id = "puck-a";
49
+ element.className = "puck";
50
+ const outcome = selectElementAnimationsOrRetry(parsed([anim(".puck")]), target, element);
51
+ expect(outcome.kind === "resolved" && outcome.animations.map((a) => a.targetSelector)).toEqual([
52
+ ".puck",
53
+ ]);
54
+ });
55
+
44
56
  it("resolves to [] (no retry) for a warm parse with no match — element genuinely has no animation", () => {
45
57
  const outcome = selectElementAnimationsOrRetry(parsed([anim("#other")]), target);
46
58
  expect(outcome).toEqual({ kind: "resolved", animations: [] });
@@ -49,10 +49,14 @@ export function gsapSourceFileForSelection(selection: DomEditSelection): string
49
49
  export function selectElementAnimationsOrRetry(
50
50
  parsed: Pick<ParsedGsap, "animations"> | null,
51
51
  target: { id: string | null; selector: string | null },
52
+ element?: Element | null,
52
53
  ): ElementAnimationsOutcome {
53
54
  if (!parsed) return { kind: "fetch-error" };
54
55
  if (parsed.animations.length === 0) return { kind: "cold" };
55
- return { kind: "resolved", animations: getAnimationsForElement(parsed.animations, target) };
56
+ return {
57
+ kind: "resolved",
58
+ animations: getAnimationsForElement(parsed.animations, target, element),
59
+ };
56
60
  }
57
61
 
58
62
  // Retry policy deliberately distinguishes cold parses from hard fetch errors.
@@ -61,6 +65,7 @@ async function fetchElementAnimationsWithRetry(
61
65
  projectId: string,
62
66
  gsapSourceFile: string,
63
67
  target: { id: string | null; selector: string | null },
68
+ element: Element,
64
69
  failOnFetchError: boolean,
65
70
  fresh: boolean,
66
71
  ): Promise<GsapAnimation[]> {
@@ -69,7 +74,7 @@ async function fetchElementAnimationsWithRetry(
69
74
  for (;;) {
70
75
  const parsed = await fetchParsedAnimations(projectId, gsapSourceFile, { fresh });
71
76
  fresh = false;
72
- const outcome = selectElementAnimationsOrRetry(parsed, target);
77
+ const outcome = selectElementAnimationsOrRetry(parsed, target, element);
73
78
  if (outcome.kind === "resolved") return outcome.animations;
74
79
  if (outcome.kind === "fetch-error") {
75
80
  if (errorAttempts >= FETCH_ERROR_RETRIES) {
@@ -96,6 +101,7 @@ export function useGsapAnimationFetchFallback(projectId: string | null) {
96
101
  projectId,
97
102
  gsapSourceFileForSelection(selection),
98
103
  target,
104
+ selection.element,
99
105
  options?.failOnFetchError === true,
100
106
  options?.fresh === true,
101
107
  );
@@ -300,6 +300,10 @@ describe("useGsapAwareEditing anchored resize", () => {
300
300
  });
301
301
  expect(commitMutation).not.toHaveBeenCalled();
302
302
  expect(mocks.drag).toHaveBeenCalledTimes(2);
303
+ expect(mocks.drag.mock.calls.map((call) => call[6])).toEqual([
304
+ { preflightOnly: true, group: true },
305
+ { preflightOnly: true, group: true },
306
+ ]);
303
307
  expect(makeFetchFallback).toHaveBeenNthCalledWith(1, updates[0]!.selection, {
304
308
  failOnFetchError: true,
305
309
  });
@@ -240,7 +240,7 @@ export function useGsapAwareEditing({
240
240
  previewIframeRef.current,
241
241
  coalescedCommit,
242
242
  undefined,
243
- { preflightOnly: true },
243
+ { preflightOnly: true, group: true },
244
244
  );
245
245
  assertGsapEditPersisted(outcome);
246
246
  }),
@@ -19,58 +19,12 @@ import {
19
19
  type MergeableKeyframe,
20
20
  } from "./gsapTweenSynth";
21
21
  import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad";
22
+ import { getAnimationsForElement } from "./gsapElementMatch";
22
23
 
23
24
  // Re-exported so callers keep importing the GSAP cache surface from one module.
24
25
  export { resolveClipTimingBasis } from "./gsapShared";
25
26
  export { fetchParsedAnimations, resolveSelectorElementIds } from "./keyframeCacheAstLoad";
26
-
27
- /** The selected element's identity for matching tweens to it. */
28
- export interface GsapElementTarget {
29
- id?: string | null;
30
- selector?: string | null;
31
- }
32
-
33
- /**
34
- * A tween belongs to the selected element when its target selector addresses
35
- * that element — by id (`#id`), by the exact CSS selector the element was
36
- * selected through (`.kicker`), or as one member of a group selector
37
- * (`.clock-face, .clock-hand`, emitted for array/`toArray` targets). Real
38
- * compositions target tweens by class via `querySelector`, so id-only matching
39
- * misses them.
40
- *
41
- * When the live DOM `element` is supplied, each comma-part of a tween's selector
42
- * is also tested with `element.matches(part)` — true CSS semantics — so a
43
- * class/descendant tween shared across elements (e.g. `gsap.from(".dot", {stagger})`)
44
- * is attributed to *every* matching element, not just the one whose exact
45
- * selector string happens to equal the tween's.
46
- */
47
- export function getAnimationsForElement(
48
- animations: GsapAnimation[],
49
- target: GsapElementTarget,
50
- element?: Element | null,
51
- ): GsapAnimation[] {
52
- const matchers = new Set<string>();
53
- if (target.id) matchers.add(`#${target.id}`);
54
- if (target.selector) matchers.add(target.selector);
55
- if (matchers.size === 0 && !element) return [];
56
- return animations.filter((a) =>
57
- a.targetSelector.split(",").some((part) => {
58
- const trimmed = part.trim();
59
- if (!trimmed) return false;
60
- if (matchers.has(trimmed)) return true;
61
- const lastSimple = trimmed.split(/\s+/).pop();
62
- if (lastSimple && matchers.has(lastSimple)) return true;
63
- if (element) {
64
- try {
65
- if (element.matches(trimmed)) return true;
66
- } catch {
67
- /* tween selector isn't a valid CSS selector for matches() — skip */
68
- }
69
- }
70
- return false;
71
- }),
72
- );
73
- }
27
+ export { getAnimationsForElement } from "./gsapElementMatch";
74
28
 
75
29
  export function useGsapAnimationsForElement(
76
30
  projectId: string | null,
package/src/index.ts CHANGED
@@ -167,7 +167,7 @@ export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
167
167
  export { useClipboard } from "./hooks/useClipboard";
168
168
  export type { UseClipboardOptions } from "./hooks/useClipboard";
169
169
 
170
- // DOM editing: the canvas session, persistence and overlay, for a host outside EditorShell.
170
+ // DOM editing for a host outside EditorShell; useDomStyleCommit saves styles without the session.
171
171
  export { useDomEditSession } from "./hooks/useDomEditSession";
172
172
  export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
173
173
  export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
@@ -177,6 +177,15 @@ export { PreviewReadOnlyProvider } from "./components/editor/previewReadOnlyCont
177
177
  export { ConnectedDomEditOverlay } from "./components/editor/ConnectedDomEditOverlay";
178
178
  export type { ConnectedDomEditOverlayProps } from "./components/editor/ConnectedDomEditOverlay";
179
179
  export type { DomEditCapabilities, DomEditSelection } from "./components/editor/domEditingTypes";
180
+ export { useDomStyleCommit } from "./hooks/useDomStyleCommit";
181
+ export type { UseDomStyleCommitOptions } from "./hooks/useDomStyleCommit";
182
+ export type { DomEditCommitDeclineReason, DomEditCommitOutcome } from "./hooks/domEditCommitRunner";
183
+ export { resolveDomEditSelection } from "./components/editor/domEditingLayers";
184
+ export { DomEditOverlay } from "./components/editor/DomEditOverlay";
185
+ export type {
186
+ DomEditGroupPathOffsetCommit,
187
+ DomEditOverlayProps,
188
+ } from "./components/editor/DomEditOverlay";
180
189
 
181
190
  export {
182
191
  playSeamTransitionLoop,
@@ -0,0 +1,80 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { Timeline } from "./Timeline";
7
+ import { installTimelineMountEnv } from "./timelineMountTestEnv";
8
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
9
+
10
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ installTimelineMountEnv();
13
+
14
+ afterEach(() => {
15
+ document.body.innerHTML = "";
16
+ });
17
+
18
+ const voice: TimelineElement = {
19
+ id: "vo",
20
+ domId: "vo",
21
+ tag: "audio",
22
+ start: 0,
23
+ duration: 4,
24
+ track: 0,
25
+ };
26
+ const grouped: TimelineElement[] = ["vo-1", "vo-2"].map((id, track) => ({
27
+ id,
28
+ domId: id,
29
+ tag: "audio",
30
+ start: 0,
31
+ duration: 4,
32
+ track,
33
+ audioGroup: "voiceover",
34
+ audioGroupLabel: "Voiceover",
35
+ }));
36
+
37
+ async function mount(elements: TimelineElement[], showAudioEffects?: boolean) {
38
+ usePlayerStore.setState({ duration: 10, currentTime: 0, timelineReady: true, elements });
39
+ const host = document.createElement("div");
40
+ document.body.append(host);
41
+ const root = createRoot(host);
42
+ await act(async () => root.render(<Timeline showAudioEffects={showAudioEffects} />));
43
+ const labels = Array.from(host.querySelectorAll("button")).map(
44
+ (b) => b.getAttribute("aria-label") ?? "",
45
+ );
46
+ act(() => root.unmount());
47
+ return labels;
48
+ }
49
+
50
+ const effects = (labels: string[]) => labels.filter((l) => l.startsWith("Effects"));
51
+
52
+ describe("Timeline showAudioEffects", () => {
53
+ it("shows the effects button on an audio track by default", async () => {
54
+ expect(effects(await mount([voice]))).toHaveLength(1);
55
+ });
56
+
57
+ it("hides the effects button on an audio track when the host turns effects off", async () => {
58
+ expect(effects(await mount([voice], false))).toEqual([]);
59
+ });
60
+
61
+ it("hides the group-these-clips effects button on a track of several clips", async () => {
62
+ const clips: TimelineElement[] = [
63
+ { ...voice, id: "sfx-1", domId: "sfx-1" },
64
+ { ...voice, id: "sfx-2", domId: "sfx-2", start: 5 },
65
+ ];
66
+ expect(effects(await mount(clips))).toHaveLength(1);
67
+ expect(effects(await mount(clips, false))).toEqual([]);
68
+ });
69
+
70
+ it("hides a group's effects button and its open-effects name when effects are off", async () => {
71
+ const on = await mount(grouped);
72
+ expect(effects(on).length).toBeGreaterThan(0);
73
+ expect(on).toContain("Open Voiceover effects");
74
+
75
+ const off = await mount(grouped, false);
76
+ expect(effects(off)).toEqual([]);
77
+ expect(off).not.toContain("Open Voiceover effects");
78
+ expect(off).toContain("Select Voiceover");
79
+ });
80
+ });
@@ -0,0 +1,75 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import type { Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
5
+ import { Timeline } from "./Timeline";
6
+ import { usePlayerStore } from "../store/playerStore";
7
+ import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
8
+
9
+ installReactActEnvironment();
10
+ for (const [prop, px] of [
11
+ ["clientWidth", 1000],
12
+ ["clientHeight", 400],
13
+ ["offsetWidth", 1000],
14
+ ["offsetHeight", 400],
15
+ ] as const) {
16
+ vi.spyOn(HTMLElement.prototype, prop, "get").mockReturnValue(px);
17
+ }
18
+
19
+ const pointer = (type: string, clientX: number, clientY: number) =>
20
+ new PointerEvent(type, { bubbles: true, button: 0, pointerId: 1, clientX, clientY });
21
+ const band = () => document.querySelector('[style*="dashed"]');
22
+
23
+ let root: Root;
24
+ let viewport: HTMLElement;
25
+ let hostEscapes: KeyboardEvent[];
26
+ const hostListener = (e: KeyboardEvent) => {
27
+ if (e.key === "Escape") hostEscapes.push(e);
28
+ };
29
+
30
+ beforeEach(async () => {
31
+ usePlayerStore.setState({
32
+ duration: 20,
33
+ timelineReady: true,
34
+ elements: [{ id: "intro", key: "intro", tag: "div", start: 0, duration: 1, track: 0 }],
35
+ });
36
+ usePlayerStore.getState().setSelectedElementId("intro");
37
+ root = mountReactHarness(<Timeline />);
38
+ await act(async () => new Promise((r) => setTimeout(r, 50)));
39
+ viewport = document.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
40
+ hostEscapes = [];
41
+ document.addEventListener("keydown", hostListener);
42
+ });
43
+
44
+ afterEach(() => {
45
+ document.removeEventListener("keydown", hostListener);
46
+ act(() => root.unmount());
47
+ usePlayerStore.setState({ elements: [], timelineReady: false, selectedElementId: null });
48
+ document.body.innerHTML = "";
49
+ });
50
+
51
+ const escape = () => {
52
+ const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
53
+ act(() => void document.body.dispatchEvent(event));
54
+ return event;
55
+ };
56
+
57
+ it("an escape that cancels a marquee band stops there and never reaches the host", () => {
58
+ act(() => void viewport.dispatchEvent(pointer("pointerdown", 800, 200)));
59
+ act(() => void viewport.dispatchEvent(pointer("pointermove", 860, 260)));
60
+ expect(band()).not.toBeNull();
61
+
62
+ const event = escape();
63
+
64
+ expect(hostEscapes).toHaveLength(0);
65
+ expect(event.defaultPrevented).toBe(true);
66
+ expect(band()).toBeNull();
67
+ expect(usePlayerStore.getState().selectedElementId).toBe("intro");
68
+ });
69
+
70
+ it("an escape with no band in flight still reaches the host", () => {
71
+ const event = escape();
72
+
73
+ expect(event.defaultPrevented).toBe(false);
74
+ expect(hostEscapes).toHaveLength(1);
75
+ });
@@ -2,38 +2,14 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
+ import { describe, expect, it } from "vitest";
6
6
  import { Timeline } from "./Timeline";
7
+ import { installTimelineMountEnv } from "./timelineMountTestEnv";
7
8
  import { liveTime, usePlayerStore } from "../store/playerStore";
8
9
 
9
10
  (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
11
- class MockResizeObserver {
12
- constructor(private readonly callback: ResizeObserverCallback) {}
13
- observe(target: Element) {
14
- this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver);
15
- }
16
- unobserve() {}
17
- disconnect() {}
18
- }
19
-
20
- const originalResizeObserver = globalThis.ResizeObserver;
21
- const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
22
-
23
- beforeAll(() => {
24
- globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
25
- Object.defineProperty(HTMLElement.prototype, "clientWidth", {
26
- configurable: true,
27
- get: () => 900,
28
- });
29
- });
30
-
31
- afterAll(() => {
32
- globalThis.ResizeObserver = originalResizeObserver;
33
- if (originalClientWidth)
34
- Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth);
35
- document.body.innerHTML = "";
36
- });
12
+ installTimelineMountEnv();
37
13
 
38
14
  const clips = [{ id: "intro", tag: "div", start: 2, duration: 10, track: 0 }];
39
15
  const needle = (host: HTMLElement) =>