@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.
- package/dist/assets/index-DsFKgqkT.js +116 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/App.tsx +21 -4
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/editor/PropertyPanel.tsx +68 -1
- package/src/components/editor/domEditingLayers.ts +39 -5
- package/src/components/editor/domEditingTextFields.test.ts +60 -0
- package/src/components/editor/domEditingTypes.ts +1 -1
- package/src/components/nle/NLELayout.tsx +7 -4
- package/src/components/nle/NLEPreview.tsx +9 -44
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/hooks/useAppHotkeys.ts +54 -0
- package/src/hooks/useClipboard.ts +229 -0
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.ts +33 -0
- package/src/player/components/Player.tsx +7 -1
- package/src/utils/clipboardPayload.test.ts +62 -0
- package/src/utils/clipboardPayload.ts +168 -0
- package/dist/assets/index-BP8No8kB.js +0 -115
|
@@ -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
|
-
|
|
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
|
+
}
|