@hyperframes/studio 0.7.38 → 0.7.40

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 (102) hide show
  1. package/dist/assets/{index-ClUipc8i.js → index-89kPtC4s.js} +1 -1
  2. package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
  3. package/dist/assets/index-BpM6cnfP.css +1 -0
  4. package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
  5. package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
  6. package/dist/chunk-BA66NM4L.js.map +1 -0
  7. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
  8. package/dist/index.d.ts +47 -29
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +4432 -3045
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +9 -7
  14. package/src/components/StudioPreviewArea.tsx +14 -0
  15. package/src/components/StudioRightPanel.tsx +9 -0
  16. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  17. package/src/components/editor/DomEditOverlay.tsx +140 -151
  18. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  19. package/src/components/editor/PropertyPanel.test.ts +73 -0
  20. package/src/components/editor/PropertyPanel.tsx +42 -37
  21. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  22. package/src/components/editor/SnapToolbar.tsx +20 -1
  23. package/src/components/editor/clipPathHelpers.ts +113 -0
  24. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  25. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  27. package/src/components/editor/domEditOverlayShape.ts +39 -0
  28. package/src/components/editor/domEditing.test.ts +2 -0
  29. package/src/components/editor/domEditingElement.ts +8 -1
  30. package/src/components/editor/domEditingTypes.ts +3 -0
  31. package/src/components/editor/manualEditsDom.ts +1 -18
  32. package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
  33. package/src/components/editor/marqueeCommit.ts +3 -2
  34. package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
  35. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
  36. package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
  37. package/src/components/editor/propertyPanelHelpers.ts +21 -34
  38. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  39. package/src/components/editor/propertyPanelTypes.ts +3 -0
  40. package/src/components/editor/snapTargetCollection.ts +2 -3
  41. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  43. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  44. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  45. package/src/components/sidebar/AssetsTab.tsx +113 -15
  46. package/src/components/sidebar/BlocksTab.tsx +2 -1
  47. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  48. package/src/contexts/TimelineEditContext.tsx +1 -0
  49. package/src/hooks/domSelectionTestHarness.ts +1 -0
  50. package/src/hooks/gsapDragCommit.test.ts +56 -0
  51. package/src/hooks/gsapDragCommit.ts +41 -109
  52. package/src/hooks/gsapDragPositionCommit.ts +3 -0
  53. package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
  54. package/src/hooks/gsapRuntimeBridge.ts +23 -2
  55. package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
  56. package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
  58. package/src/hooks/timelineEditingHelpers.ts +17 -5
  59. package/src/hooks/timelineTrackVisibility.test.ts +202 -0
  60. package/src/hooks/timelineTrackVisibility.ts +371 -0
  61. package/src/hooks/useCropMode.ts +91 -0
  62. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  63. package/src/hooks/useRenderClipContent.test.ts +68 -2
  64. package/src/hooks/useRenderClipContent.ts +5 -6
  65. package/src/hooks/useTimelineEditing.ts +35 -30
  66. package/src/hooks/useTimelineEditingTypes.ts +30 -0
  67. package/src/icons/SystemIcons.tsx +12 -4
  68. package/src/player/components/AudioWaveform.tsx +11 -8
  69. package/src/player/components/CompositionThumbnail.tsx +14 -11
  70. package/src/player/components/PlayheadIndicator.tsx +21 -11
  71. package/src/player/components/ShortcutsPanel.tsx +9 -0
  72. package/src/player/components/Timeline.test.ts +105 -5
  73. package/src/player/components/Timeline.tsx +37 -47
  74. package/src/player/components/TimelineCanvas.tsx +275 -256
  75. package/src/player/components/TimelineClip.test.tsx +105 -0
  76. package/src/player/components/TimelineClip.tsx +44 -57
  77. package/src/player/components/TimelineRuler.tsx +17 -23
  78. package/src/player/components/TimelineShortcutHint.tsx +26 -0
  79. package/src/player/components/TimelineTypes.ts +18 -0
  80. package/src/player/components/VideoThumbnail.tsx +16 -13
  81. package/src/player/components/timelineCallbacks.ts +1 -0
  82. package/src/player/components/timelineIcons.tsx +2 -44
  83. package/src/player/components/timelineLayout.ts +18 -22
  84. package/src/player/components/timelineMotionStyles.test.ts +101 -0
  85. package/src/player/components/timelineTheme.test.ts +21 -4
  86. package/src/player/components/timelineTheme.ts +15 -15
  87. package/src/player/components/useTimelineActiveClips.test.ts +93 -0
  88. package/src/player/components/useTimelineActiveClips.ts +125 -0
  89. package/src/player/components/useTimelinePlayhead.ts +6 -0
  90. package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
  91. package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
  92. package/src/player/lib/time.test.ts +1 -0
  93. package/src/player/lib/timelineDOM.test.ts +48 -1
  94. package/src/player/lib/timelineDOM.ts +7 -0
  95. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  96. package/src/player/store/playerStore.ts +19 -1
  97. package/src/styles/studio.css +108 -0
  98. package/src/utils/sourcePatcher.test.ts +11 -0
  99. package/src/utils/sourcePatcher.ts +6 -4
  100. package/dist/assets/index-DJaiR8T2.css +0 -1
  101. package/dist/chunk-JND3XUJL.js.map +0 -1
  102. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
@@ -0,0 +1,202 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { usePlayerStore, type TimelineElement } from "../player";
5
+ import { toggleTimelineElementHidden, toggleTimelineTrackHidden } from "./timelineTrackVisibility";
6
+
7
+ afterEach(() => {
8
+ document.body.innerHTML = "";
9
+ vi.unstubAllGlobals();
10
+ usePlayerStore.getState().reset();
11
+ });
12
+
13
+ function element(overrides: Partial<TimelineElement>): TimelineElement {
14
+ return {
15
+ id: "clip",
16
+ tag: "div",
17
+ start: 0,
18
+ duration: 2,
19
+ track: 0,
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ function stubProjectFiles(files: Map<string, string>) {
25
+ vi.stubGlobal(
26
+ "fetch",
27
+ vi.fn(async (input: RequestInfo | URL) => {
28
+ const url = String(input);
29
+ const encodedPath = url.slice(url.lastIndexOf("/") + 1);
30
+ const path = decodeURIComponent(encodedPath);
31
+ const content = files.get(path);
32
+ return new Response(JSON.stringify({ content }), {
33
+ status: content === undefined ? 404 : 200,
34
+ headers: { "Content-Type": "application/json" },
35
+ });
36
+ }),
37
+ );
38
+ }
39
+
40
+ describe("toggleTimelineTrackHidden", () => {
41
+ it("patches iframe DOM and persists all track elements as one edit-history entry", async () => {
42
+ const iframe = document.createElement("iframe");
43
+ document.body.append(iframe);
44
+ if (iframe.contentDocument) {
45
+ iframe.contentDocument.body.innerHTML = `
46
+ <div id="hero"></div>
47
+ <div id="subtitle"></div>
48
+ `;
49
+ }
50
+
51
+ const files = new Map([
52
+ [
53
+ "index.html",
54
+ `<div id="hero" data-start="0" data-duration="2"></div>
55
+ <div id="skip" data-start="0" data-duration="2"></div>`,
56
+ ],
57
+ ["scene.html", `<div id="subtitle" data-start="1" data-duration="2"></div>`],
58
+ ]);
59
+ stubProjectFiles(files);
60
+
61
+ const writes = new Map<string, string>();
62
+ const recordEdit = vi.fn();
63
+ const timestampRef = { current: 0 };
64
+ const pendingRef = { current: new Set<string>() };
65
+
66
+ await toggleTimelineTrackHidden({
67
+ projectId: "project-1",
68
+ activeCompPath: "index.html",
69
+ timelineElements: [
70
+ element({ id: "hero", domId: "hero", track: 0 }),
71
+ element({ id: "skip", domId: "skip", track: 1 }),
72
+ element({ id: "subtitle", domId: "subtitle", track: 0, sourceFile: "scene.html" }),
73
+ ],
74
+ track: 0,
75
+ hidden: true,
76
+ previewIframe: iframe,
77
+ writeProjectFile: async (path, content) => {
78
+ writes.set(path, content);
79
+ },
80
+ recordEdit,
81
+ domEditSaveTimestampRef: timestampRef,
82
+ pendingTimelineEditPathRef: pendingRef,
83
+ });
84
+
85
+ expect(iframe.contentDocument?.getElementById("hero")?.hasAttribute("data-hidden")).toBe(true);
86
+ expect(iframe.contentDocument?.getElementById("subtitle")?.hasAttribute("data-hidden")).toBe(
87
+ true,
88
+ );
89
+ expect(writes.get("index.html")).toContain('id="hero" data-start="0" data-duration="2"');
90
+ expect(writes.get("index.html")).toContain('data-hidden=""');
91
+ expect(writes.get("index.html")).toContain('id="skip" data-start="0" data-duration="2"');
92
+ expect(writes.get("scene.html")).toContain('data-hidden=""');
93
+ expect(pendingRef.current).toEqual(new Set(["index.html", "scene.html"]));
94
+ expect(timestampRef.current).toBeGreaterThan(0);
95
+ expect(recordEdit).toHaveBeenCalledTimes(1);
96
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 0");
97
+ expect(Object.keys(recordEdit.mock.calls[0]?.[0]?.files ?? {}).sort()).toEqual([
98
+ "index.html",
99
+ "scene.html",
100
+ ]);
101
+ });
102
+
103
+ it("removes data-hidden from every element on the track", async () => {
104
+ const files = new Map([
105
+ [
106
+ "index.html",
107
+ `<div id="hero" data-start="0" data-duration="2" data-hidden=""></div>
108
+ <div id="caption" data-start="2" data-duration="2" data-hidden=""></div>`,
109
+ ],
110
+ ]);
111
+ stubProjectFiles(files);
112
+
113
+ const writes = new Map<string, string>();
114
+
115
+ await toggleTimelineTrackHidden({
116
+ projectId: "project-1",
117
+ activeCompPath: "index.html",
118
+ timelineElements: [
119
+ element({ id: "hero", domId: "hero", track: 0, hidden: true }),
120
+ element({ id: "caption", domId: "caption", track: 0, hidden: true }),
121
+ ],
122
+ track: 0,
123
+ hidden: false,
124
+ previewIframe: null,
125
+ writeProjectFile: async (path, content) => {
126
+ writes.set(path, content);
127
+ },
128
+ recordEdit: vi.fn(),
129
+ domEditSaveTimestampRef: { current: 0 },
130
+ pendingTimelineEditPathRef: { current: new Set() },
131
+ });
132
+
133
+ expect(writes.get("index.html")).not.toContain("data-hidden");
134
+ });
135
+ });
136
+
137
+ describe("toggleTimelineElementHidden", () => {
138
+ it("persists data-hidden for only the selected element and updates the player store", async () => {
139
+ const iframe = document.createElement("iframe");
140
+ document.body.append(iframe);
141
+ const seek = vi.fn();
142
+ const win = iframe.contentWindow;
143
+ if (!win) throw new Error("Expected iframe contentWindow");
144
+ const playerWindow: Window & { __player?: { seek?: (time: number) => void } } = win;
145
+ playerWindow.__player = { seek };
146
+
147
+ const files = new Map([
148
+ [
149
+ "index.html",
150
+ `<div id="hero" data-start="0" data-duration="2"></div>
151
+ <div id="track-mate" data-start="1" data-duration="2"></div>`,
152
+ ],
153
+ ]);
154
+ stubProjectFiles(files);
155
+
156
+ const hero = element({ id: "hero", key: "index.html:#hero", domId: "hero", track: 0 });
157
+ const trackMate = element({
158
+ id: "track-mate",
159
+ key: "index.html:#track-mate",
160
+ domId: "track-mate",
161
+ track: 0,
162
+ });
163
+ usePlayerStore.getState().setElements([hero, trackMate]);
164
+ usePlayerStore.getState().setCurrentTime(1.25);
165
+
166
+ const writes = new Map<string, string>();
167
+ const recordEdit = vi.fn();
168
+
169
+ const changedPaths = await toggleTimelineElementHidden({
170
+ projectId: "project-1",
171
+ activeCompPath: "index.html",
172
+ timelineElements: [hero, trackMate],
173
+ elementKey: "index.html:#hero",
174
+ hidden: true,
175
+ previewIframe: iframe,
176
+ writeProjectFile: async (path, content) => {
177
+ writes.set(path, content);
178
+ },
179
+ recordEdit,
180
+ domEditSaveTimestampRef: { current: 0 },
181
+ pendingTimelineEditPathRef: { current: new Set() },
182
+ });
183
+
184
+ expect(changedPaths).toEqual(["index.html"]);
185
+ expect(writes.get("index.html")).toContain('id="hero" data-start="0" data-duration="2"');
186
+ expect(writes.get("index.html")).toContain(
187
+ 'id="hero" data-start="0" data-duration="2" data-hidden=""',
188
+ );
189
+ expect(writes.get("index.html")).toContain(
190
+ 'id="track-mate" data-start="1" data-duration="2"></div>',
191
+ );
192
+ expect(recordEdit).toHaveBeenCalledTimes(1);
193
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide element");
194
+ expect(seek).toHaveBeenCalledWith(1.25);
195
+ expect(
196
+ usePlayerStore.getState().elements.find((el) => el.key === "index.html:#hero")?.hidden,
197
+ ).toBe(true);
198
+ expect(
199
+ usePlayerStore.getState().elements.find((el) => el.key === "index.html:#track-mate")?.hidden,
200
+ ).toBeUndefined();
201
+ });
202
+ });
@@ -0,0 +1,371 @@
1
+ import { useCallback } from "react";
2
+ import { usePlayerStore, type TimelineElement } from "../player";
3
+ import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
4
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
5
+ import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
6
+ import {
7
+ applyPatchByTarget,
8
+ buildPatchTarget,
9
+ findTimelineElementInIframe,
10
+ readFileContent,
11
+ type RecordEditInput,
12
+ } from "./timelineEditingHelpers";
13
+
14
+ interface MutableRef<T> {
15
+ current: T;
16
+ }
17
+
18
+ interface ReadonlyRef<T> {
19
+ readonly current: T;
20
+ }
21
+
22
+ interface ToggleTimelineTrackHiddenInput {
23
+ projectId: string;
24
+ activeCompPath: string | null;
25
+ timelineElements: readonly TimelineElement[];
26
+ track: number;
27
+ hidden: boolean;
28
+ previewIframe: HTMLIFrameElement | null;
29
+ writeProjectFile: (path: string, content: string) => Promise<void>;
30
+ recordEdit: (input: RecordEditInput) => Promise<void>;
31
+ domEditSaveTimestampRef: MutableRef<number>;
32
+ pendingTimelineEditPathRef: MutableRef<Set<string>>;
33
+ }
34
+
35
+ interface ToggleTimelineElementHiddenInput extends Omit<ToggleTimelineTrackHiddenInput, "track"> {
36
+ elementKey: string;
37
+ }
38
+
39
+ interface SetElementsHiddenInput {
40
+ projectId: string;
41
+ activeCompPath: string | null;
42
+ elements: readonly TimelineElement[];
43
+ hidden: boolean;
44
+ label: string;
45
+ previewIframe: HTMLIFrameElement | null;
46
+ writeProjectFile: (path: string, content: string) => Promise<void>;
47
+ recordEdit: (input: RecordEditInput) => Promise<void>;
48
+ domEditSaveTimestampRef: MutableRef<number>;
49
+ pendingTimelineEditPathRef: MutableRef<Set<string>>;
50
+ }
51
+
52
+ interface UseTimelineTrackVisibilityEditingInput extends Omit<
53
+ ToggleTimelineTrackHiddenInput,
54
+ "projectId" | "track" | "hidden" | "previewIframe"
55
+ > {
56
+ projectIdRef: ReadonlyRef<string | null>;
57
+ previewIframeRef: ReadonlyRef<HTMLIFrameElement | null>;
58
+ showToast: (message: string, tone?: "error" | "info") => void;
59
+ isRecordingRef?: ReadonlyRef<boolean>;
60
+ forceReloadSdkSession?: () => void;
61
+ }
62
+
63
+ interface UseTimelineElementVisibilityEditingInput extends Omit<
64
+ ToggleTimelineElementHiddenInput,
65
+ "projectId" | "elementKey" | "hidden" | "previewIframe"
66
+ > {
67
+ projectIdRef: ReadonlyRef<string | null>;
68
+ previewIframeRef: ReadonlyRef<HTMLIFrameElement | null>;
69
+ showToast: (message: string, tone?: "error" | "info") => void;
70
+ isRecordingRef?: ReadonlyRef<boolean>;
71
+ forceReloadSdkSession?: () => void;
72
+ }
73
+
74
+ function getTimelineElementTargetPath(
75
+ element: TimelineElement,
76
+ activeCompPath: string | null,
77
+ ): string {
78
+ return element.sourceFile || activeCompPath || "index.html";
79
+ }
80
+
81
+ function patchLiveHiddenState(
82
+ iframe: HTMLIFrameElement | null,
83
+ elements: readonly TimelineElement[],
84
+ hidden: boolean,
85
+ ): void {
86
+ for (const element of elements) {
87
+ const target = findTimelineElementInIframe(iframe, element);
88
+ if (!target) continue;
89
+ if (hidden) {
90
+ target.setAttribute("data-hidden", "");
91
+ } else {
92
+ target.removeAttribute("data-hidden");
93
+ }
94
+ }
95
+ }
96
+
97
+ function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void {
98
+ try {
99
+ const win: (Window & { __player?: { seek?: (time: number) => void } }) | null =
100
+ iframe?.contentWindow ?? null;
101
+ win?.__player?.seek?.(usePlayerStore.getState().currentTime);
102
+ } catch {}
103
+ }
104
+
105
+ function groupElementsByTargetPath(
106
+ elements: readonly TimelineElement[],
107
+ activeCompPath: string | null,
108
+ ): Map<string, TimelineElement[]> {
109
+ const byPath = new Map<string, TimelineElement[]>();
110
+ for (const element of elements) {
111
+ const targetPath = getTimelineElementTargetPath(element, activeCompPath);
112
+ const existing = byPath.get(targetPath);
113
+ if (existing) {
114
+ existing.push(element);
115
+ } else {
116
+ byPath.set(targetPath, [element]);
117
+ }
118
+ }
119
+ return byPath;
120
+ }
121
+
122
+ // fallow-ignore-next-line complexity
123
+ async function setElementsHidden({
124
+ projectId,
125
+ activeCompPath,
126
+ elements,
127
+ hidden,
128
+ label,
129
+ previewIframe,
130
+ writeProjectFile,
131
+ recordEdit,
132
+ domEditSaveTimestampRef,
133
+ pendingTimelineEditPathRef,
134
+ }: SetElementsHiddenInput): Promise<string[]> {
135
+ if (elements.length === 0) return [];
136
+
137
+ patchLiveHiddenState(previewIframe, elements, hidden);
138
+ reseekPreviewRuntime(previewIframe);
139
+
140
+ const hiddenOperation: PatchOperation = {
141
+ type: "attribute",
142
+ property: "hidden",
143
+ value: hidden ? "" : null,
144
+ };
145
+ const originalByPath = new Map<string, string>();
146
+ const files: Record<string, string> = {};
147
+
148
+ try {
149
+ for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
150
+ let patchedContent = await readFileContent(projectId, targetPath);
151
+ originalByPath.set(targetPath, patchedContent);
152
+
153
+ for (const element of fileElements) {
154
+ const patchTarget = buildPatchTarget(element);
155
+ if (!patchTarget) {
156
+ throw new Error(`Timeline element ${element.id} is missing a patchable target`);
157
+ }
158
+ if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
159
+ throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
160
+ }
161
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, hiddenOperation);
162
+ }
163
+
164
+ files[targetPath] = patchedContent;
165
+ pendingTimelineEditPathRef.current.add(targetPath);
166
+ }
167
+
168
+ domEditSaveTimestampRef.current = Date.now();
169
+ const changedPaths = await saveProjectFilesWithHistory({
170
+ projectId,
171
+ label,
172
+ kind: "timeline",
173
+ files,
174
+ readFile: async (path) => {
175
+ const original = originalByPath.get(path);
176
+ if (original !== undefined) return original;
177
+ return readFileContent(projectId, path);
178
+ },
179
+ writeFile: writeProjectFile,
180
+ recordEdit,
181
+ });
182
+ domEditSaveTimestampRef.current = Date.now();
183
+ for (const element of elements) {
184
+ usePlayerStore.getState().updateElement(element.key ?? element.id, { hidden });
185
+ }
186
+ return changedPaths;
187
+ } catch (error) {
188
+ // The optimistic live patch already ran; a patch-target/save failure here would
189
+ // otherwise leave the preview showing the wrong visibility until a reload. Revert
190
+ // the live DOM to the prior state so what's on screen matches what persisted.
191
+ patchLiveHiddenState(previewIframe, elements, !hidden);
192
+ reseekPreviewRuntime(previewIframe);
193
+ throw error;
194
+ }
195
+ }
196
+
197
+ export async function toggleTimelineTrackHidden({
198
+ projectId,
199
+ activeCompPath,
200
+ timelineElements,
201
+ track,
202
+ hidden,
203
+ previewIframe,
204
+ writeProjectFile,
205
+ recordEdit,
206
+ domEditSaveTimestampRef,
207
+ pendingTimelineEditPathRef,
208
+ }: ToggleTimelineTrackHiddenInput): Promise<string[]> {
209
+ return setElementsHidden({
210
+ projectId,
211
+ activeCompPath,
212
+ elements: timelineElements.filter((element) => element.track === track),
213
+ hidden,
214
+ label: hidden ? `Hide track ${track}` : `Show track ${track}`,
215
+ previewIframe,
216
+ writeProjectFile,
217
+ recordEdit,
218
+ domEditSaveTimestampRef,
219
+ pendingTimelineEditPathRef,
220
+ });
221
+ }
222
+
223
+ export async function toggleTimelineElementHidden({
224
+ projectId,
225
+ activeCompPath,
226
+ timelineElements,
227
+ elementKey,
228
+ hidden,
229
+ previewIframe,
230
+ writeProjectFile,
231
+ recordEdit,
232
+ domEditSaveTimestampRef,
233
+ pendingTimelineEditPathRef,
234
+ }: ToggleTimelineElementHiddenInput): Promise<string[]> {
235
+ const element = timelineElements.find((item) => (item.key ?? item.id) === elementKey);
236
+ return setElementsHidden({
237
+ projectId,
238
+ activeCompPath,
239
+ elements: element ? [element] : [],
240
+ hidden,
241
+ label: hidden ? "Hide element" : "Show element",
242
+ previewIframe,
243
+ writeProjectFile,
244
+ recordEdit,
245
+ domEditSaveTimestampRef,
246
+ pendingTimelineEditPathRef,
247
+ });
248
+ }
249
+
250
+ export function useTimelineTrackVisibilityEditing({
251
+ projectIdRef,
252
+ activeCompPath,
253
+ showToast,
254
+ writeProjectFile,
255
+ recordEdit,
256
+ domEditSaveTimestampRef,
257
+ previewIframeRef,
258
+ pendingTimelineEditPathRef,
259
+ isRecordingRef,
260
+ forceReloadSdkSession,
261
+ }: UseTimelineTrackVisibilityEditingInput): (track: number, hidden: boolean) => Promise<void> {
262
+ // Resolve the eye toggle against the EXPANDED rows the canvas actually renders:
263
+ // virtual sub-comp children carry their own (display.track + idx) track numbers,
264
+ // so filtering the raw store list by a virtual track number would hide the wrong
265
+ // outer-scene sibling sharing that index.
266
+ const expandedElements = useExpandedTimelineElements();
267
+ return useCallback(
268
+ async (track: number, hidden: boolean) => {
269
+ if (isRecordingRef?.current) {
270
+ showToast("Cannot edit timeline while recording", "error");
271
+ return;
272
+ }
273
+ const pid = projectIdRef.current;
274
+ if (!pid) return;
275
+ try {
276
+ await toggleTimelineTrackHidden({
277
+ projectId: pid,
278
+ activeCompPath,
279
+ timelineElements: expandedElements,
280
+ track,
281
+ hidden,
282
+ previewIframe: previewIframeRef.current,
283
+ writeProjectFile,
284
+ recordEdit,
285
+ domEditSaveTimestampRef,
286
+ pendingTimelineEditPathRef,
287
+ });
288
+ forceReloadSdkSession?.();
289
+ } catch (error) {
290
+ console.error("[Timeline] Failed to toggle track visibility", error);
291
+ const message =
292
+ error instanceof Error ? error.message : "Failed to toggle track visibility";
293
+ showToast(message);
294
+ }
295
+ },
296
+ [
297
+ activeCompPath,
298
+ expandedElements,
299
+ previewIframeRef,
300
+ writeProjectFile,
301
+ recordEdit,
302
+ domEditSaveTimestampRef,
303
+ pendingTimelineEditPathRef,
304
+ isRecordingRef,
305
+ showToast,
306
+ forceReloadSdkSession,
307
+ projectIdRef,
308
+ ],
309
+ );
310
+ }
311
+
312
+ export function useTimelineElementVisibilityEditing({
313
+ projectIdRef,
314
+ activeCompPath,
315
+ timelineElements,
316
+ showToast,
317
+ writeProjectFile,
318
+ recordEdit,
319
+ domEditSaveTimestampRef,
320
+ previewIframeRef,
321
+ pendingTimelineEditPathRef,
322
+ isRecordingRef,
323
+ forceReloadSdkSession,
324
+ }: UseTimelineElementVisibilityEditingInput): (
325
+ elementKey: string,
326
+ hidden: boolean,
327
+ ) => Promise<void> {
328
+ return useCallback(
329
+ async (elementKey: string, hidden: boolean) => {
330
+ if (isRecordingRef?.current) {
331
+ showToast("Cannot edit timeline while recording", "error");
332
+ return;
333
+ }
334
+ const pid = projectIdRef.current;
335
+ if (!pid) return;
336
+ try {
337
+ await toggleTimelineElementHidden({
338
+ projectId: pid,
339
+ activeCompPath,
340
+ timelineElements,
341
+ elementKey,
342
+ hidden,
343
+ previewIframe: previewIframeRef.current,
344
+ writeProjectFile,
345
+ recordEdit,
346
+ domEditSaveTimestampRef,
347
+ pendingTimelineEditPathRef,
348
+ });
349
+ forceReloadSdkSession?.();
350
+ } catch (error) {
351
+ console.error("[Timeline] Failed to toggle element visibility", error);
352
+ const message =
353
+ error instanceof Error ? error.message : "Failed to toggle element visibility";
354
+ showToast(message);
355
+ }
356
+ },
357
+ [
358
+ activeCompPath,
359
+ timelineElements,
360
+ previewIframeRef,
361
+ writeProjectFile,
362
+ recordEdit,
363
+ domEditSaveTimestampRef,
364
+ pendingTimelineEditPathRef,
365
+ isRecordingRef,
366
+ showToast,
367
+ forceReloadSdkSession,
368
+ projectIdRef,
369
+ ],
370
+ );
371
+ }
@@ -0,0 +1,91 @@
1
+ import { useEffect, useMemo, useReducer } from "react";
2
+ import { usePlayerStore } from "../player";
3
+
4
+ export interface CropModeProps {
5
+ cropMode: boolean;
6
+ onCropModeChange: (active: boolean) => void;
7
+ }
8
+
9
+ /** Crop mode lives in the player store so the canvas toolbar, the Clip panel,
10
+ * and the overlay all share one switch without prop threading. */
11
+ export function useCropModeProps(): CropModeProps {
12
+ const cropMode = usePlayerStore((s) => s.cropMode);
13
+ const setCropMode = usePlayerStore((s) => s.setCropMode);
14
+ return useMemo(
15
+ () => ({
16
+ cropMode,
17
+ onCropModeChange: setCropMode,
18
+ }),
19
+ [cropMode, setCropMode],
20
+ );
21
+ }
22
+
23
+ import type { OverlayRect } from "../components/editor/domEditOverlayGeometry";
24
+ import type { DomEditSelection } from "../components/editor/domEditing";
25
+ import { readElementCropInsets } from "../components/editor/domEditOverlayCrop";
26
+
27
+ /** Overlay-side crop state: Escape-to-exit, toolbar availability publishing,
28
+ * and the box clip that makes the selection outline hug the cropped region.
29
+ * The box div itself always sits at the FULL element bounds — gestures write
30
+ * its position directly during drags, so moving/resizing it in React would
31
+ * fight them. The hug is purely visual: the element's inset clip-path scaled
32
+ * into overlay space and applied to the box. */
33
+ export function useCropOverlay(params: {
34
+ selection: DomEditSelection | null;
35
+ groupCount: number;
36
+ cropMode: boolean;
37
+ onCropModeChange?: (active: boolean) => void;
38
+ overlayRect: OverlayRect | null;
39
+ }) {
40
+ const { selection, groupCount, cropMode, onCropModeChange, overlayRect } = params;
41
+
42
+ useEffect(() => {
43
+ if (!cropMode || !onCropModeChange) return;
44
+ const handleKeyDown = (event: KeyboardEvent) => {
45
+ if (event.key === "Escape") onCropModeChange(false);
46
+ };
47
+ window.addEventListener("keydown", handleKeyDown);
48
+ return () => window.removeEventListener("keydown", handleKeyDown);
49
+ }, [cropMode, onCropModeChange]);
50
+
51
+ // Publish availability so the canvas toolbar shows the Crop button only
52
+ // when the selection can take a clip-path crop.
53
+ const setCropAvailable = usePlayerStore((s) => s.setCropAvailable);
54
+ const cropAvailable = Boolean(selection && groupCount <= 1 && selection.capabilities.canCrop);
55
+ useEffect(() => {
56
+ setCropAvailable(cropAvailable);
57
+ return () => setCropAvailable(false);
58
+ }, [cropAvailable, setCropAvailable]);
59
+
60
+ // Crop-mode exit restores the element's clip in an effect cleanup — after
61
+ // this hook already read it. One forced re-render picks up the fresh insets
62
+ // so the selection box hugs the crop immediately.
63
+ const [, bumpAfterExit] = useReducer((x: number) => x + 1, 0);
64
+ useEffect(() => {
65
+ if (!cropMode) bumpAfterExit();
66
+ }, [cropMode]);
67
+
68
+ const cropInsets = selection ? readElementCropInsets(selection.element) : null;
69
+ const hasCropInsets = Boolean(
70
+ cropInsets &&
71
+ (cropInsets.top > 0 || cropInsets.right > 0 || cropInsets.bottom > 0 || cropInsets.left > 0),
72
+ );
73
+
74
+ // Scaled insets for the crop outline child + the resize-handle shift. The
75
+ // box div itself stays border-less at full bounds; a child draws the
76
+ // outline ON the crop boundary (a clip on the box would swallow the
77
+ // border everywhere the crop edge doesn't touch the element edge).
78
+ const sx = overlayRect && overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
79
+ const sy = overlayRect && overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
80
+ const cropOutlineInsetPx =
81
+ cropInsets && hasCropInsets && !cropMode
82
+ ? {
83
+ top: cropInsets.top * sy,
84
+ right: cropInsets.right * sx,
85
+ bottom: cropInsets.bottom * sy,
86
+ left: cropInsets.left * sx,
87
+ }
88
+ : undefined;
89
+
90
+ return { hasCropInsets, cropOutlineInsetPx };
91
+ }
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  // @vitest-environment happy-dom
2
3
  import { act, createElement } from "react";
3
4
  import { createRoot, type Root } from "react-dom/client";
@@ -183,6 +184,7 @@ function createSelection(
183
184
  capabilities: {
184
185
  canSelect: true,
185
186
  canEditStyles: true,
187
+ canCrop: true,
186
188
  canMove: true,
187
189
  canResize: true,
188
190
  canApplyManualOffset: true,