mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
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.
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
type ClipboardPayload,
|
||||
serializeClipboardPayload,
|
||||
deduplicateIds,
|
||||
insertAsSibling,
|
||||
} from "../utils/clipboardPayload";
|
||||
import { copyTextToClipboard } from "../utils/clipboard";
|
||||
import { collectHtmlIds } from "../utils/studioHelpers";
|
||||
@@ -131,7 +132,13 @@ export function useClipboard({
|
||||
return false;
|
||||
}
|
||||
const targetPath = domSelection.sourceFile || activeCompPath || "index.html";
|
||||
const payload: ClipboardPayload = { kind: "dom-element", html, sourceFile: targetPath };
|
||||
const payload: ClipboardPayload = {
|
||||
kind: "dom-element",
|
||||
html,
|
||||
sourceFile: targetPath,
|
||||
originSelector: domSelection.selector,
|
||||
originSelectorIndex: domSelection.selectorIndex,
|
||||
};
|
||||
clipboardRef.current = payload;
|
||||
void copyTextToClipboard(serializeClipboardPayload(payload));
|
||||
showToast("Copied element", "info");
|
||||
@@ -165,7 +172,12 @@ export function useClipboard({
|
||||
);
|
||||
patchedContent = insertTimelineAssetIntoSource(originalContent, withNewStart);
|
||||
} else {
|
||||
patchedContent = insertTimelineAssetIntoSource(originalContent, deduped);
|
||||
patchedContent = insertAsSibling(
|
||||
originalContent,
|
||||
deduped,
|
||||
payload.originSelector,
|
||||
payload.originSelectorIndex,
|
||||
);
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface ClipboardPayload {
|
||||
kind: "timeline-clip" | "dom-element";
|
||||
html: string;
|
||||
sourceFile: string;
|
||||
originSelector?: string;
|
||||
originSelectorIndex?: number;
|
||||
}
|
||||
|
||||
interface SerializedPayload {
|
||||
@@ -11,6 +13,8 @@ interface SerializedPayload {
|
||||
kind: "timeline-clip" | "dom-element";
|
||||
html: string;
|
||||
sourceFile: string;
|
||||
originSelector?: string;
|
||||
originSelectorIndex?: number;
|
||||
}
|
||||
|
||||
export function serializeClipboardPayload(payload: ClipboardPayload): string {
|
||||
@@ -19,6 +23,8 @@ export function serializeClipboardPayload(payload: ClipboardPayload): string {
|
||||
kind: payload.kind,
|
||||
html: payload.html,
|
||||
sourceFile: payload.sourceFile,
|
||||
originSelector: payload.originSelector,
|
||||
originSelectorIndex: payload.originSelectorIndex,
|
||||
};
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
@@ -35,7 +41,118 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
|
||||
if (obj._marker !== CLIPBOARD_MARKER) return null;
|
||||
if (obj.kind !== "timeline-clip" && obj.kind !== "dom-element") return null;
|
||||
if (typeof obj.html !== "string" || typeof obj.sourceFile !== "string") return null;
|
||||
return { kind: obj.kind, html: obj.html, sourceFile: obj.sourceFile };
|
||||
return {
|
||||
kind: obj.kind,
|
||||
html: obj.html,
|
||||
sourceFile: obj.sourceFile,
|
||||
originSelector: typeof obj.originSelector === "string" ? obj.originSelector : undefined,
|
||||
originSelectorIndex:
|
||||
typeof obj.originSelectorIndex === "number" ? obj.originSelectorIndex : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert `newHtml` as a sibling immediately after the element matched by
|
||||
* `selector` (at `selectorIndex`) in `source`. Falls back to inserting after
|
||||
* the composition root if the selector doesn't match — so paste never silently
|
||||
* drops the content.
|
||||
*/
|
||||
export function insertAsSibling(
|
||||
source: string,
|
||||
newHtml: string,
|
||||
selector: string | undefined,
|
||||
selectorIndex: number | undefined,
|
||||
): string {
|
||||
if (selector) {
|
||||
const idx = selectorIndex ?? 0;
|
||||
let matchCount = 0;
|
||||
|
||||
// Find the element by searching for its opening tag pattern.
|
||||
// For id selectors like #foo, search for id="foo".
|
||||
// For class selectors like .name-text, search for class="...name-text...".
|
||||
// For attribute selectors like [data-composition-id="x"], search literally.
|
||||
|
||||
let searchPattern: RegExp | null = null;
|
||||
if (selector.startsWith("#")) {
|
||||
const id = selector.slice(1);
|
||||
searchPattern = new RegExp(`<[a-z][^>]*\\bid="${id}"[^>]*>`, "gi");
|
||||
} else if (selector.startsWith(".")) {
|
||||
const cls = selector.slice(1);
|
||||
searchPattern = new RegExp(`<[a-z][^>]*\\bclass="[^"]*\\b${cls}\\b[^"]*"[^>]*>`, "gi");
|
||||
} else if (selector.startsWith("[")) {
|
||||
const inner = selector.slice(1, -1);
|
||||
searchPattern = new RegExp(`<[a-z][^>]*\\b${inner.replace(/"/g, '"')}[^>]*>`, "gi");
|
||||
}
|
||||
|
||||
if (searchPattern) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = searchPattern.exec(source)) !== null) {
|
||||
if (matchCount === idx) {
|
||||
const insertPos = findClosingTagPosition(source, match.index);
|
||||
if (insertPos > 0) {
|
||||
return source.slice(0, insertPos) + "\n" + newHtml + source.slice(insertPos);
|
||||
}
|
||||
}
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: insert after composition root opening tag (same as timeline clips)
|
||||
const rootOpenTag = /<[^>]*data-composition-id="[^"]+"[^>]*>/i;
|
||||
const rootMatch = rootOpenTag.exec(source);
|
||||
if (rootMatch && rootMatch.index != null) {
|
||||
const insertAt = rootMatch.index + rootMatch[0].length;
|
||||
return source.slice(0, insertAt) + newHtml + source.slice(insertAt);
|
||||
}
|
||||
|
||||
return source + newHtml;
|
||||
}
|
||||
|
||||
function findClosingTagPosition(html: string, openTagStart: number): number {
|
||||
// Find the end of the opening tag
|
||||
const openTagEnd = html.indexOf(">", openTagStart);
|
||||
if (openTagEnd < 0) return -1;
|
||||
|
||||
// Self-closing tag?
|
||||
if (html[openTagEnd - 1] === "/") return openTagEnd + 1;
|
||||
|
||||
// Extract the tag name
|
||||
const tagNameMatch = html.slice(openTagStart).match(/^<([a-z][a-z0-9]*)/i);
|
||||
if (!tagNameMatch) return -1;
|
||||
const tagName = tagNameMatch[1]!;
|
||||
|
||||
// Walk forward counting open/close tags of the same name
|
||||
let depth = 1;
|
||||
let pos = openTagEnd + 1;
|
||||
const openRe = new RegExp(`<${tagName}(?:\\s|>|/>)`, "gi");
|
||||
const closeRe = new RegExp(`</${tagName}\\s*>`, "gi");
|
||||
|
||||
while (depth > 0 && pos < html.length) {
|
||||
openRe.lastIndex = pos;
|
||||
closeRe.lastIndex = pos;
|
||||
|
||||
const nextOpen = openRe.exec(html);
|
||||
const nextClose = closeRe.exec(html);
|
||||
|
||||
if (!nextClose) return -1;
|
||||
|
||||
if (nextOpen && nextOpen.index < nextClose.index) {
|
||||
// Check if it's self-closing
|
||||
const selfCloseCheck = html.lastIndexOf("/", html.indexOf(">", nextOpen.index));
|
||||
if (selfCloseCheck > nextOpen.index) {
|
||||
pos = html.indexOf(">", nextOpen.index) + 1;
|
||||
} else {
|
||||
depth++;
|
||||
pos = html.indexOf(">", nextOpen.index) + 1;
|
||||
}
|
||||
} else {
|
||||
depth--;
|
||||
if (depth === 0) return nextClose.index + nextClose[0].length;
|
||||
pos = nextClose.index + nextClose[0].length;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function deduplicateIds(html: string, existingIds: string[]): string {
|
||||
|
||||
Reference in New Issue
Block a user