Files
hyperframes/packages/studio/src/utils/clipboardPayload.ts
T
Miguel Ángel acd141b2ae feat(studio): add Ctrl+C/V/X copy/paste for timeline clips and DOM elements (#894)
* feat(studio): add clipboard payload types and ID deduplication

* feat(studio): add Ctrl+C/V/X copy/paste for timeline clips and DOM elements

* fix(studio): use duck-typing for cross-frame element access in clipboard

Elements from the preview iframe are from a different window context,
so `el instanceof HTMLElement` always returns false. Use `"outerHTML"
in el` instead to correctly detect elements across frame boundaries.

* fix(studio): preserve playhead position after paste

reloadPreview() used location.reload() which bypassed the
NLELayout saveSeekPosition effect, causing the playhead to reset
to 0:00 after paste. Switch to setRefreshKey which triggers the
effect and restores the seek position after the iframe reloads.

* 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.

* fix(studio): address review — deduplicateIds, native copy, altKey guard

- deduplicateIds regex used \b which matched data-composition-id,
  data-clip-id, etc. Switch to lookbehind (?<=\s) so only standalone
  id="..." attributes are rewritten. Add test pinning this.
- Ctrl+C no longer calls preventDefault() before confirming there's
  a selected element. Native browser copy (text selections outside
  inputs) is preserved when nothing is selected in the Studio.
- Add !event.altKey guard on C/V/X to avoid intercepting Cmd+Alt+V
  (paste-as-plain-text) and similar OS gestures.
- Remove no-op .replace(/"/g, '"') flagged by CodeQL.

* fix(studio): address review round 2 — Cmd+X guard, data-start scope, revert drive-by

- Cmd+X now pre-checks selection state before preventDefault, mirroring
  the Cmd+C fix. Native cut preserved when nothing is selected.
- handleCut returns Promise<boolean> so the caller can gate on it.
- data-start rewrite scoped to the outermost opening tag only, so nested
  clip timing is preserved on paste.
- Removed system clipboard write (cross-tab paste unsupported, in-memory
  ref is the only read path).
- Reverted the reloadPreview drive-by (setRefreshKey→location.reload);
  the perf branch (#895) handles this properly via refreshPlayer().
2026-05-16 09:46:04 +02:00

169 lines
5.5 KiB
TypeScript

const CLIPBOARD_MARKER = "hyperframes-clipboard:v1";
export interface ClipboardPayload {
kind: "timeline-clip" | "dom-element";
html: string;
sourceFile: string;
originSelector?: string;
originSelectorIndex?: number;
}
interface SerializedPayload {
_marker: string;
kind: "timeline-clip" | "dom-element";
html: string;
sourceFile: string;
originSelector?: string;
originSelectorIndex?: number;
}
export function serializeClipboardPayload(payload: ClipboardPayload): string {
const data: SerializedPayload = {
_marker: CLIPBOARD_MARKER,
kind: payload.kind,
html: payload.html,
sourceFile: payload.sourceFile,
originSelector: payload.originSelector,
originSelectorIndex: payload.originSelectorIndex,
};
return JSON.stringify(data);
}
export function deserializeClipboardPayload(json: string): ClipboardPayload | null {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const obj = parsed as Record<string, unknown>;
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,
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}[^>]*>`, "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 {
const existingSet = new Set(existingIds);
return html.replace(/(?<=\s)id="([^"]+)"/g, (full, id: string) => {
if (!existingSet.has(id)) return full;
let counter = 2;
while (existingSet.has(`${id}-${counter}`)) counter++;
const newId = `${id}-${counter}`;
existingSet.add(newId);
return `id="${newId}"`;
});
}