@hyperframes/studio 0.6.11 → 0.6.13

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.
@@ -0,0 +1,229 @@
1
+ import { useCallback, useRef } from "react";
2
+ import type { TimelineElement } from "../player";
3
+ import { usePlayerStore } from "../player";
4
+ import type { DomEditSelection } from "../components/editor/domEditing";
5
+ import { type ClipboardPayload, deduplicateIds, insertAsSibling } from "../utils/clipboardPayload";
6
+ import { collectHtmlIds } from "../utils/studioHelpers";
7
+ import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
8
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
9
+ import type { EditHistoryKind } from "../utils/editHistory";
10
+ import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
11
+
12
+ interface RecordEditInput {
13
+ label: string;
14
+ kind: EditHistoryKind;
15
+ coalesceKey?: string;
16
+ files: Record<string, { before: string; after: string }>;
17
+ }
18
+
19
+ interface UseClipboardOptions {
20
+ projectId: string | null;
21
+ activeCompPath: string | null;
22
+ domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
23
+ showToast: (message: string, tone?: "error" | "info") => void;
24
+ writeProjectFile: (path: string, content: string) => Promise<void>;
25
+ recordEdit: (input: RecordEditInput) => Promise<void>;
26
+ domEditSaveTimestampRef: React.MutableRefObject<number>;
27
+ reloadPreview: () => void;
28
+ handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
29
+ handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
30
+ previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
31
+ }
32
+
33
+ async function readFileContent(projectId: string, targetPath: string): Promise<string> {
34
+ const response = await fetch(
35
+ `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
36
+ );
37
+ if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
38
+ const data = (await response.json()) as { content?: string };
39
+ if (typeof data.content !== "string") throw new Error(`Missing file contents for ${targetPath}`);
40
+ return data.content;
41
+ }
42
+
43
+ function getElementOuterHtml(
44
+ iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
45
+ selection: DomEditSelection,
46
+ ): string | null {
47
+ let doc: Document | null = null;
48
+ try {
49
+ doc = iframeRef.current?.contentDocument ?? null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ if (!doc) return null;
54
+
55
+ let el: Element | null = null;
56
+ if (selection.id) {
57
+ el = doc.getElementById(selection.id);
58
+ }
59
+ if (!el && selection.selector) {
60
+ const matches = doc.querySelectorAll(selection.selector);
61
+ el = matches[selection.selectorIndex ?? 0] ?? null;
62
+ }
63
+ return el && "outerHTML" in el ? (el as Element).outerHTML : null;
64
+ }
65
+
66
+ export function useClipboard({
67
+ projectId,
68
+ activeCompPath,
69
+ domEditSelectionRef,
70
+ showToast,
71
+ writeProjectFile,
72
+ recordEdit,
73
+ domEditSaveTimestampRef,
74
+ reloadPreview,
75
+ handleTimelineElementDelete,
76
+ handleDomEditElementDelete,
77
+ previewIframeRef,
78
+ }: UseClipboardOptions) {
79
+ const clipboardRef = useRef<ClipboardPayload | null>(null);
80
+ const projectIdRef = useRef(projectId);
81
+ projectIdRef.current = projectId;
82
+
83
+ const handleCopy = useCallback((): boolean => {
84
+ const { selectedElementId, elements } = usePlayerStore.getState();
85
+
86
+ // Timeline clip copy
87
+ if (selectedElementId) {
88
+ const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
89
+ if (!element) return false;
90
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
91
+
92
+ let html: string | null = null;
93
+ try {
94
+ const doc = previewIframeRef.current?.contentDocument;
95
+ if (doc) {
96
+ let el: Element | null = null;
97
+ if (element.domId) el = doc.getElementById(element.domId);
98
+ if (!el && element.selector) {
99
+ const matches = doc.querySelectorAll(element.selector);
100
+ el = matches[element.selectorIndex ?? 0] ?? null;
101
+ }
102
+ if (el && "outerHTML" in el) html = (el as Element).outerHTML;
103
+ }
104
+ } catch {
105
+ // cross-origin frame
106
+ }
107
+
108
+ if (!html) {
109
+ showToast("Unable to copy this element.", "info");
110
+ return false;
111
+ }
112
+
113
+ const payload: ClipboardPayload = { kind: "timeline-clip", html, sourceFile: targetPath };
114
+ clipboardRef.current = payload;
115
+ showToast("Copied clip", "info");
116
+ return true;
117
+ }
118
+
119
+ // DOM element copy
120
+ const domSelection = domEditSelectionRef.current;
121
+ if (domSelection) {
122
+ const html = getElementOuterHtml(previewIframeRef, domSelection);
123
+ if (!html) {
124
+ showToast("Unable to copy this element.", "info");
125
+ return false;
126
+ }
127
+ const targetPath = domSelection.sourceFile || activeCompPath || "index.html";
128
+ const payload: ClipboardPayload = {
129
+ kind: "dom-element",
130
+ html,
131
+ sourceFile: targetPath,
132
+ originSelector: domSelection.selector,
133
+ originSelectorIndex: domSelection.selectorIndex,
134
+ };
135
+ clipboardRef.current = payload;
136
+ showToast("Copied element", "info");
137
+ return true;
138
+ }
139
+
140
+ return false;
141
+ }, [activeCompPath, domEditSelectionRef, previewIframeRef, showToast]);
142
+
143
+ const handlePaste = useCallback(async () => {
144
+ const payload = clipboardRef.current;
145
+ if (!payload) {
146
+ showToast("Nothing to paste.", "info");
147
+ return;
148
+ }
149
+ const pid = projectIdRef.current;
150
+ if (!pid) return;
151
+
152
+ const targetPath = activeCompPath || "index.html";
153
+ try {
154
+ const originalContent = await readFileContent(pid, targetPath);
155
+ const existingIds = collectHtmlIds(originalContent);
156
+ const deduped = deduplicateIds(payload.html, existingIds);
157
+
158
+ let patchedContent: string;
159
+ if (payload.kind === "timeline-clip") {
160
+ // Only rewrite data-start on the outermost opening tag. The non-global
161
+ // regex matches the first occurrence, which is always in the root tag
162
+ // since outerHTML starts with it. Nested clips keep their own timing.
163
+ const { currentTime } = usePlayerStore.getState();
164
+ const rootTagEnd = deduped.indexOf(">");
165
+ const rootTag = rootTagEnd >= 0 ? deduped.slice(0, rootTagEnd + 1) : deduped;
166
+ const patchedRootTag = rootTag.replace(
167
+ /data-start="[^"]*"/,
168
+ `data-start="${formatTimelineAttributeNumber(currentTime)}"`,
169
+ );
170
+ const withNewStart = patchedRootTag + deduped.slice(rootTagEnd + 1);
171
+ patchedContent = insertTimelineAssetIntoSource(originalContent, withNewStart);
172
+ } else {
173
+ patchedContent = insertAsSibling(
174
+ originalContent,
175
+ deduped,
176
+ payload.originSelector,
177
+ payload.originSelectorIndex,
178
+ );
179
+ }
180
+
181
+ domEditSaveTimestampRef.current = Date.now();
182
+ await saveProjectFilesWithHistory({
183
+ projectId: pid,
184
+ label: payload.kind === "timeline-clip" ? "Paste clip" : "Paste element",
185
+ kind: "timeline" as EditHistoryKind,
186
+ files: { [targetPath]: patchedContent },
187
+ readFile: async () => originalContent,
188
+ writeFile: writeProjectFile,
189
+ recordEdit,
190
+ });
191
+
192
+ reloadPreview();
193
+ showToast(payload.kind === "timeline-clip" ? "Pasted clip" : "Pasted element", "info");
194
+ } catch (error) {
195
+ const message = error instanceof Error ? error.message : "Failed to paste";
196
+ showToast(message);
197
+ }
198
+ }, [
199
+ activeCompPath,
200
+ domEditSaveTimestampRef,
201
+ recordEdit,
202
+ reloadPreview,
203
+ showToast,
204
+ writeProjectFile,
205
+ ]);
206
+
207
+ const handleCut = useCallback(async (): Promise<boolean> => {
208
+ const copied = handleCopy();
209
+ if (!copied) return false;
210
+
211
+ const { selectedElementId, elements } = usePlayerStore.getState();
212
+ if (selectedElementId) {
213
+ const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
214
+ if (element) {
215
+ await handleTimelineElementDelete(element);
216
+ return true;
217
+ }
218
+ }
219
+
220
+ const domSelection = domEditSelectionRef.current;
221
+ if (domSelection) {
222
+ await handleDomEditElementDelete(domSelection);
223
+ return true;
224
+ }
225
+ return true;
226
+ }, [handleCopy, domEditSelectionRef, handleTimelineElementDelete, handleDomEditElementDelete]);
227
+
228
+ return { handleCopy, handlePaste, handleCut };
229
+ }
@@ -189,6 +189,7 @@ export function useDomEditCommits({
189
189
 
190
190
  const {
191
191
  handleDomStyleCommit,
192
+ handleDomAttributeCommit,
192
193
  handleDomTextCommit,
193
194
  commitDomTextFields,
194
195
  handleDomTextFieldStyleCommit,
@@ -437,6 +438,7 @@ export function useDomEditCommits({
437
438
  return {
438
439
  resolveImportedFontAsset,
439
440
  handleDomStyleCommit,
441
+ handleDomAttributeCommit,
440
442
  handleDomTextCommit,
441
443
  commitDomTextFields,
442
444
  handleDomTextFieldStyleCommit,
@@ -193,6 +193,7 @@ export function useDomEditSession({
193
193
  const {
194
194
  resolveImportedFontAsset,
195
195
  handleDomStyleCommit,
196
+ handleDomAttributeCommit,
196
197
  handleDomTextCommit,
197
198
  handleDomTextFieldStyleCommit,
198
199
  handleDomAddTextField,
@@ -305,6 +306,7 @@ export function useDomEditSession({
305
306
  applyDomSelection,
306
307
  clearDomSelection,
307
308
  handleDomStyleCommit,
309
+ handleDomAttributeCommit,
308
310
  handleDomPathOffsetCommit,
309
311
  handleDomGroupPathOffsetCommit,
310
312
  handleDomBoxSizeCommit,
@@ -113,6 +113,38 @@ export function useDomEditTextCommits({
113
113
  ],
114
114
  );
115
115
 
116
+ const handleDomAttributeCommit = useCallback(
117
+ async (attr: string, value: string) => {
118
+ if (!domEditSelection) return;
119
+ const iframe = previewIframeRef.current;
120
+ const doc = iframe?.contentDocument;
121
+ if (doc) {
122
+ const el = findElementForSelection(doc, domEditSelection, activeCompPath);
123
+ if (el) el.setAttribute(`data-${attr}`, value);
124
+ }
125
+ const op: PatchOperation = { type: "attribute", property: attr, value };
126
+ try {
127
+ await persistDomEditOperations(domEditSelection, [op], {
128
+ label: "Edit timing",
129
+ skipRefresh: false,
130
+ });
131
+ } catch (err) {
132
+ console.warn(
133
+ "[Studio] Attribute persist failed:",
134
+ err instanceof Error ? err.message : err,
135
+ );
136
+ }
137
+ refreshDomEditSelectionFromPreview(domEditSelection);
138
+ },
139
+ [
140
+ activeCompPath,
141
+ domEditSelection,
142
+ persistDomEditOperations,
143
+ refreshDomEditSelectionFromPreview,
144
+ previewIframeRef,
145
+ ],
146
+ );
147
+
116
148
  const handleDomTextCommit = useCallback(
117
149
  async (value: string, fieldKey?: string) => {
118
150
  if (!domEditSelection) return;
@@ -321,6 +353,7 @@ export function useDomEditTextCommits({
321
353
 
322
354
  return {
323
355
  handleDomStyleCommit,
356
+ handleDomAttributeCommit,
324
357
  handleDomTextCommit,
325
358
  commitDomTextFields,
326
359
  handleDomTextFieldStyleCommit,
@@ -229,13 +229,19 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
229
229
  // data arrives), but the overlay communicates why the first frame
230
230
  // or first audio beat may lag.
231
231
  //
232
+ // Skip the overlay on subsequent loads (content refreshes via
233
+ // refreshPlayer). The browser has already cached the assets from
234
+ // the first load, so they resolve near-instantly and the overlay
235
+ // just creates a disruptive flash.
236
+ //
232
237
  // Poll with a 10 s safety cap (100 ticks × 100 ms). If the cap
233
238
  // trips we hide the overlay so the UI doesn't appear stuck forever,
234
239
  // but we log a debug warning so the case is diagnosable — a long
235
240
  // cold video or a broken asset can legitimately exceed 10 s on a
236
241
  // slow network.
237
242
  if (assetPollRef.current) clearInterval(assetPollRef.current);
238
- let lastUnloaded = hasUnloadedAssets(iframe, false);
243
+ const isContentRefresh = loadCountRef.current > 1;
244
+ let lastUnloaded = isContentRefresh ? false : hasUnloadedAssets(iframe, false);
239
245
  if (lastUnloaded) {
240
246
  setAssetsLoading(true);
241
247
  let attempts = 0;
@@ -0,0 +1,62 @@
1
+ // @vitest-environment node
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ deduplicateIds,
5
+ serializeClipboardPayload,
6
+ deserializeClipboardPayload,
7
+ type ClipboardPayload,
8
+ } from "./clipboardPayload";
9
+
10
+ describe("deduplicateIds", () => {
11
+ it("renames ids that collide with existing ids", () => {
12
+ const html = '<div id="hero"><img id="photo" src="a.png" /></div>';
13
+ const existingIds = ["hero", "other"];
14
+ const result = deduplicateIds(html, existingIds);
15
+ expect(result).not.toContain('id="hero"');
16
+ expect(result).toContain('id="photo"');
17
+ expect(result).toMatch(/id="hero-\d+"/);
18
+ });
19
+
20
+ it("returns html unchanged when no collisions", () => {
21
+ const html = '<div id="unique"><p>hello</p></div>';
22
+ const result = deduplicateIds(html, ["other"]);
23
+ expect(result).toBe(html);
24
+ });
25
+
26
+ it("does not rewrite data-composition-id or other data-*-id attributes", () => {
27
+ const html = '<div data-composition-id="hero" data-clip-id="hero" id="hero">content</div>';
28
+ const result = deduplicateIds(html, ["hero"]);
29
+ expect(result).toContain('data-composition-id="hero"');
30
+ expect(result).toContain('data-clip-id="hero"');
31
+ expect(result).toMatch(/\sid="hero-\d+"/);
32
+ });
33
+ });
34
+
35
+ describe("serializeClipboardPayload / deserializeClipboardPayload", () => {
36
+ it("round-trips a timeline clip payload", () => {
37
+ const payload: ClipboardPayload = {
38
+ kind: "timeline-clip",
39
+ html: '<img id="photo" src="a.png" data-start="1" data-duration="3" />',
40
+ sourceFile: "index.html",
41
+ };
42
+ const json = serializeClipboardPayload(payload);
43
+ const parsed = deserializeClipboardPayload(json);
44
+ expect(parsed).toEqual(payload);
45
+ });
46
+
47
+ it("round-trips a dom-element payload", () => {
48
+ const payload: ClipboardPayload = {
49
+ kind: "dom-element",
50
+ html: '<div class="card"><p>Hello</p></div>',
51
+ sourceFile: "compositions/scene.html",
52
+ };
53
+ const json = serializeClipboardPayload(payload);
54
+ const parsed = deserializeClipboardPayload(json);
55
+ expect(parsed).toEqual(payload);
56
+ });
57
+
58
+ it("returns null for invalid JSON", () => {
59
+ expect(deserializeClipboardPayload("not json")).toBeNull();
60
+ expect(deserializeClipboardPayload('{"kind":"unknown"}')).toBeNull();
61
+ });
62
+ });
@@ -0,0 +1,168 @@
1
+ const CLIPBOARD_MARKER = "hyperframes-clipboard:v1";
2
+
3
+ export interface ClipboardPayload {
4
+ kind: "timeline-clip" | "dom-element";
5
+ html: string;
6
+ sourceFile: string;
7
+ originSelector?: string;
8
+ originSelectorIndex?: number;
9
+ }
10
+
11
+ interface SerializedPayload {
12
+ _marker: string;
13
+ kind: "timeline-clip" | "dom-element";
14
+ html: string;
15
+ sourceFile: string;
16
+ originSelector?: string;
17
+ originSelectorIndex?: number;
18
+ }
19
+
20
+ export function serializeClipboardPayload(payload: ClipboardPayload): string {
21
+ const data: SerializedPayload = {
22
+ _marker: CLIPBOARD_MARKER,
23
+ kind: payload.kind,
24
+ html: payload.html,
25
+ sourceFile: payload.sourceFile,
26
+ originSelector: payload.originSelector,
27
+ originSelectorIndex: payload.originSelectorIndex,
28
+ };
29
+ return JSON.stringify(data);
30
+ }
31
+
32
+ export function deserializeClipboardPayload(json: string): ClipboardPayload | null {
33
+ let parsed: unknown;
34
+ try {
35
+ parsed = JSON.parse(json);
36
+ } catch {
37
+ return null;
38
+ }
39
+ if (!parsed || typeof parsed !== "object") return null;
40
+ const obj = parsed as Record<string, unknown>;
41
+ if (obj._marker !== CLIPBOARD_MARKER) return null;
42
+ if (obj.kind !== "timeline-clip" && obj.kind !== "dom-element") return null;
43
+ if (typeof obj.html !== "string" || typeof obj.sourceFile !== "string") return null;
44
+ return {
45
+ kind: obj.kind,
46
+ html: obj.html,
47
+ sourceFile: obj.sourceFile,
48
+ originSelector: typeof obj.originSelector === "string" ? obj.originSelector : undefined,
49
+ originSelectorIndex:
50
+ typeof obj.originSelectorIndex === "number" ? obj.originSelectorIndex : undefined,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Insert `newHtml` as a sibling immediately after the element matched by
56
+ * `selector` (at `selectorIndex`) in `source`. Falls back to inserting after
57
+ * the composition root if the selector doesn't match — so paste never silently
58
+ * drops the content.
59
+ */
60
+ export function insertAsSibling(
61
+ source: string,
62
+ newHtml: string,
63
+ selector: string | undefined,
64
+ selectorIndex: number | undefined,
65
+ ): string {
66
+ if (selector) {
67
+ const idx = selectorIndex ?? 0;
68
+ let matchCount = 0;
69
+
70
+ // Find the element by searching for its opening tag pattern.
71
+ // For id selectors like #foo, search for id="foo".
72
+ // For class selectors like .name-text, search for class="...name-text...".
73
+ // For attribute selectors like [data-composition-id="x"], search literally.
74
+
75
+ let searchPattern: RegExp | null = null;
76
+ if (selector.startsWith("#")) {
77
+ const id = selector.slice(1);
78
+ searchPattern = new RegExp(`<[a-z][^>]*\\bid="${id}"[^>]*>`, "gi");
79
+ } else if (selector.startsWith(".")) {
80
+ const cls = selector.slice(1);
81
+ searchPattern = new RegExp(`<[a-z][^>]*\\bclass="[^"]*\\b${cls}\\b[^"]*"[^>]*>`, "gi");
82
+ } else if (selector.startsWith("[")) {
83
+ const inner = selector.slice(1, -1);
84
+ searchPattern = new RegExp(`<[a-z][^>]*\\b${inner}[^>]*>`, "gi");
85
+ }
86
+
87
+ if (searchPattern) {
88
+ let match: RegExpExecArray | null;
89
+ while ((match = searchPattern.exec(source)) !== null) {
90
+ if (matchCount === idx) {
91
+ const insertPos = findClosingTagPosition(source, match.index);
92
+ if (insertPos > 0) {
93
+ return source.slice(0, insertPos) + "\n" + newHtml + source.slice(insertPos);
94
+ }
95
+ }
96
+ matchCount++;
97
+ }
98
+ }
99
+ }
100
+
101
+ // Fallback: insert after composition root opening tag (same as timeline clips)
102
+ const rootOpenTag = /<[^>]*data-composition-id="[^"]+"[^>]*>/i;
103
+ const rootMatch = rootOpenTag.exec(source);
104
+ if (rootMatch && rootMatch.index != null) {
105
+ const insertAt = rootMatch.index + rootMatch[0].length;
106
+ return source.slice(0, insertAt) + newHtml + source.slice(insertAt);
107
+ }
108
+
109
+ return source + newHtml;
110
+ }
111
+
112
+ function findClosingTagPosition(html: string, openTagStart: number): number {
113
+ // Find the end of the opening tag
114
+ const openTagEnd = html.indexOf(">", openTagStart);
115
+ if (openTagEnd < 0) return -1;
116
+
117
+ // Self-closing tag?
118
+ if (html[openTagEnd - 1] === "/") return openTagEnd + 1;
119
+
120
+ // Extract the tag name
121
+ const tagNameMatch = html.slice(openTagStart).match(/^<([a-z][a-z0-9]*)/i);
122
+ if (!tagNameMatch) return -1;
123
+ const tagName = tagNameMatch[1]!;
124
+
125
+ // Walk forward counting open/close tags of the same name
126
+ let depth = 1;
127
+ let pos = openTagEnd + 1;
128
+ const openRe = new RegExp(`<${tagName}(?:\\s|>|/>)`, "gi");
129
+ const closeRe = new RegExp(`</${tagName}\\s*>`, "gi");
130
+
131
+ while (depth > 0 && pos < html.length) {
132
+ openRe.lastIndex = pos;
133
+ closeRe.lastIndex = pos;
134
+
135
+ const nextOpen = openRe.exec(html);
136
+ const nextClose = closeRe.exec(html);
137
+
138
+ if (!nextClose) return -1;
139
+
140
+ if (nextOpen && nextOpen.index < nextClose.index) {
141
+ // Check if it's self-closing
142
+ const selfCloseCheck = html.lastIndexOf("/", html.indexOf(">", nextOpen.index));
143
+ if (selfCloseCheck > nextOpen.index) {
144
+ pos = html.indexOf(">", nextOpen.index) + 1;
145
+ } else {
146
+ depth++;
147
+ pos = html.indexOf(">", nextOpen.index) + 1;
148
+ }
149
+ } else {
150
+ depth--;
151
+ if (depth === 0) return nextClose.index + nextClose[0].length;
152
+ pos = nextClose.index + nextClose[0].length;
153
+ }
154
+ }
155
+ return -1;
156
+ }
157
+
158
+ export function deduplicateIds(html: string, existingIds: string[]): string {
159
+ const existingSet = new Set(existingIds);
160
+ return html.replace(/(?<=\s)id="([^"]+)"/g, (full, id: string) => {
161
+ if (!existingSet.has(id)) return full;
162
+ let counter = 2;
163
+ while (existingSet.has(`${id}-${counter}`)) counter++;
164
+ const newId = `${id}-${counter}`;
165
+ existingSet.add(newId);
166
+ return `id="${newId}"`;
167
+ });
168
+ }