Skip to content

Commit 8daa229

Browse files
committed
fix(studio): paste DOM elements as siblings, not at composition root
DOM element paste was inserting at the composition root, losing the parent context that provides CSS styles and positioning. Now stores the origin selector on copy and inserts the paste as a sibling immediately after the original element, preserving style inheritance. Falls back to root insertion if the selector can't be matched.
1 parent 6bfac98 commit 8daa229

2 files changed

Lines changed: 132 additions & 3 deletions

File tree

packages/studio/src/hooks/useClipboard.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type ClipboardPayload,
77
serializeClipboardPayload,
88
deduplicateIds,
9+
insertAsSibling,
910
} from "../utils/clipboardPayload";
1011
import { copyTextToClipboard } from "../utils/clipboard";
1112
import { collectHtmlIds } from "../utils/studioHelpers";
@@ -131,7 +132,13 @@ export function useClipboard({
131132
return false;
132133
}
133134
const targetPath = domSelection.sourceFile || activeCompPath || "index.html";
134-
const payload: ClipboardPayload = { kind: "dom-element", html, sourceFile: targetPath };
135+
const payload: ClipboardPayload = {
136+
kind: "dom-element",
137+
html,
138+
sourceFile: targetPath,
139+
originSelector: domSelection.selector,
140+
originSelectorIndex: domSelection.selectorIndex,
141+
};
135142
clipboardRef.current = payload;
136143
void copyTextToClipboard(serializeClipboardPayload(payload));
137144
showToast("Copied element", "info");
@@ -165,7 +172,12 @@ export function useClipboard({
165172
);
166173
patchedContent = insertTimelineAssetIntoSource(originalContent, withNewStart);
167174
} else {
168-
patchedContent = insertTimelineAssetIntoSource(originalContent, deduped);
175+
patchedContent = insertAsSibling(
176+
originalContent,
177+
deduped,
178+
payload.originSelector,
179+
payload.originSelectorIndex,
180+
);
169181
}
170182

171183
domEditSaveTimestampRef.current = Date.now();

packages/studio/src/utils/clipboardPayload.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ export interface ClipboardPayload {
44
kind: "timeline-clip" | "dom-element";
55
html: string;
66
sourceFile: string;
7+
originSelector?: string;
8+
originSelectorIndex?: number;
79
}
810

911
interface SerializedPayload {
1012
_marker: string;
1113
kind: "timeline-clip" | "dom-element";
1214
html: string;
1315
sourceFile: string;
16+
originSelector?: string;
17+
originSelectorIndex?: number;
1418
}
1519

1620
export function serializeClipboardPayload(payload: ClipboardPayload): string {
@@ -19,6 +23,8 @@ export function serializeClipboardPayload(payload: ClipboardPayload): string {
1923
kind: payload.kind,
2024
html: payload.html,
2125
sourceFile: payload.sourceFile,
26+
originSelector: payload.originSelector,
27+
originSelectorIndex: payload.originSelectorIndex,
2228
};
2329
return JSON.stringify(data);
2430
}
@@ -35,7 +41,118 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
3541
if (obj._marker !== CLIPBOARD_MARKER) return null;
3642
if (obj.kind !== "timeline-clip" && obj.kind !== "dom-element") return null;
3743
if (typeof obj.html !== "string" || typeof obj.sourceFile !== "string") return null;
38-
return { kind: obj.kind, html: obj.html, sourceFile: obj.sourceFile };
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.replace(/"/g, '"')}[^>]*>`, "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;
39156
}
40157

41158
export function deduplicateIds(html: string, existingIds: string[]): string {

0 commit comments

Comments
 (0)