mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
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().
This commit is contained in:
@@ -12,6 +12,7 @@ import { useManifestPersistence } from "./hooks/useManifestPersistence";
|
||||
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
||||
import { useDomEditSession } from "./hooks/useDomEditSession";
|
||||
import { useAppHotkeys } from "./hooks/useAppHotkeys";
|
||||
import { useClipboard } from "./hooks/useClipboard";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
|
||||
import { useCaptionDetection } from "./hooks/useCaptionDetection";
|
||||
import { useRenderClipContent } from "./hooks/useRenderClipContent";
|
||||
@@ -162,15 +163,28 @@ export function StudioApp() {
|
||||
|
||||
const clearDomSelectionRef = useRef<() => void>(() => {});
|
||||
const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
|
||||
const handleDomEditElementDeleteRef = useRef<(selection: DomEditSelection) => Promise<void>>(
|
||||
const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise<void>>(
|
||||
async () => {},
|
||||
);
|
||||
|
||||
const domEditDeleteBridge = async (s: DomEditSelection) =>
|
||||
handleDomEditElementDeleteRef.current(s);
|
||||
const { handleCopy, handlePaste, handleCut } = useClipboard({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
domEditSelectionRef: domEditSelectionBridgeRef,
|
||||
showToast,
|
||||
writeProjectFile: fileManager.writeProjectFile,
|
||||
recordEdit: editHistory.recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
|
||||
handleDomEditElementDelete: domEditDeleteBridge,
|
||||
previewIframeRef,
|
||||
});
|
||||
const appHotkeys = useAppHotkeys({
|
||||
toggleTimelineVisibility,
|
||||
handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
|
||||
handleDomEditElementDelete: async (s: DomEditSelection) =>
|
||||
handleDomEditElementDeleteRef.current(s),
|
||||
handleDomEditElementDelete: domEditDeleteBridge,
|
||||
domEditSelectionRef: domEditSelectionBridgeRef,
|
||||
clearDomSelectionRef,
|
||||
editHistory,
|
||||
@@ -182,6 +196,9 @@ export function StudioApp() {
|
||||
syncHistoryPreviewAfterApply: manifestPersistence.syncHistoryPreviewAfterApply,
|
||||
waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves,
|
||||
leftSidebarRef,
|
||||
handleCopy,
|
||||
handlePaste,
|
||||
handleCut,
|
||||
});
|
||||
|
||||
const domEditSession = useDomEditSession({
|
||||
|
||||
@@ -45,6 +45,9 @@ interface UseAppHotkeysParams {
|
||||
syncHistoryPreviewAfterApply: (paths: string[] | undefined) => Promise<void>;
|
||||
waitForPendingDomEditSaves: () => Promise<void>;
|
||||
leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
|
||||
handleCopy: () => boolean;
|
||||
handlePaste: () => Promise<void>;
|
||||
handleCut: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
@@ -64,6 +67,9 @@ export function useAppHotkeys({
|
||||
syncHistoryPreviewAfterApply,
|
||||
waitForPendingDomEditSaves,
|
||||
leftSidebarRef,
|
||||
handleCopy,
|
||||
handlePaste,
|
||||
handleCut,
|
||||
}: UseAppHotkeysParams) {
|
||||
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
||||
const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
|
||||
@@ -161,6 +167,12 @@ export function useAppHotkeys({
|
||||
handleUndoRef.current = handleUndo;
|
||||
const handleRedoRef = useRef(handleRedo);
|
||||
handleRedoRef.current = handleRedo;
|
||||
const handleCopyRef = useRef(handleCopy);
|
||||
handleCopyRef.current = handleCopy;
|
||||
const handlePasteRef = useRef(handlePaste);
|
||||
handlePasteRef.current = handlePaste;
|
||||
const handleCutRef = useRef(handleCut);
|
||||
handleCutRef.current = handleCut;
|
||||
|
||||
// ── Consolidated keydown handler ──
|
||||
|
||||
@@ -197,6 +209,48 @@ export function useAppHotkeys({
|
||||
leftSidebarRef.current?.selectTab("assets");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+C — copy (only preventDefault if we actually have something to copy)
|
||||
const copyPasteKey = event.key.toLowerCase();
|
||||
if (
|
||||
copyPasteKey === "c" &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
if (handleCopyRef.current()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+V — paste
|
||||
if (
|
||||
copyPasteKey === "v" &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
event.preventDefault();
|
||||
void handlePasteRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+X — cut (only preventDefault if there's a selected element to cut)
|
||||
if (
|
||||
copyPasteKey === "x" &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
const hasSelection =
|
||||
!!usePlayerStore.getState().selectedElementId || !!domEditSelectionRef.current;
|
||||
if (hasSelection) {
|
||||
event.preventDefault();
|
||||
void handleCutRef.current();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete / Backspace — remove selected element (timeline clip or preview selection)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import { type ClipboardPayload, deduplicateIds, insertAsSibling } from "../utils/clipboardPayload";
|
||||
import { collectHtmlIds } from "../utils/studioHelpers";
|
||||
import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
||||
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
interface UseClipboardOptions {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
|
||||
handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
}
|
||||
|
||||
async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
|
||||
);
|
||||
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
|
||||
const data = (await response.json()) as { content?: string };
|
||||
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${targetPath}`);
|
||||
return data.content;
|
||||
}
|
||||
|
||||
function getElementOuterHtml(
|
||||
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
|
||||
selection: DomEditSelection,
|
||||
): string | null {
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = iframeRef.current?.contentDocument ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!doc) return null;
|
||||
|
||||
let el: Element | null = null;
|
||||
if (selection.id) {
|
||||
el = doc.getElementById(selection.id);
|
||||
}
|
||||
if (!el && selection.selector) {
|
||||
const matches = doc.querySelectorAll(selection.selector);
|
||||
el = matches[selection.selectorIndex ?? 0] ?? null;
|
||||
}
|
||||
return el && "outerHTML" in el ? (el as Element).outerHTML : null;
|
||||
}
|
||||
|
||||
export function useClipboard({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
domEditSelectionRef,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
handleTimelineElementDelete,
|
||||
handleDomEditElementDelete,
|
||||
previewIframeRef,
|
||||
}: UseClipboardOptions) {
|
||||
const clipboardRef = useRef<ClipboardPayload | null>(null);
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
|
||||
const handleCopy = useCallback((): boolean => {
|
||||
const { selectedElementId, elements } = usePlayerStore.getState();
|
||||
|
||||
// Timeline clip copy
|
||||
if (selectedElementId) {
|
||||
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
|
||||
if (!element) return false;
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
|
||||
let html: string | null = null;
|
||||
try {
|
||||
const doc = previewIframeRef.current?.contentDocument;
|
||||
if (doc) {
|
||||
let el: Element | null = null;
|
||||
if (element.domId) el = doc.getElementById(element.domId);
|
||||
if (!el && element.selector) {
|
||||
const matches = doc.querySelectorAll(element.selector);
|
||||
el = matches[element.selectorIndex ?? 0] ?? null;
|
||||
}
|
||||
if (el && "outerHTML" in el) html = (el as Element).outerHTML;
|
||||
}
|
||||
} catch {
|
||||
// cross-origin frame
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
showToast("Unable to copy this element.", "info");
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload: ClipboardPayload = { kind: "timeline-clip", html, sourceFile: targetPath };
|
||||
clipboardRef.current = payload;
|
||||
showToast("Copied clip", "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
// DOM element copy
|
||||
const domSelection = domEditSelectionRef.current;
|
||||
if (domSelection) {
|
||||
const html = getElementOuterHtml(previewIframeRef, domSelection);
|
||||
if (!html) {
|
||||
showToast("Unable to copy this element.", "info");
|
||||
return false;
|
||||
}
|
||||
const targetPath = domSelection.sourceFile || activeCompPath || "index.html";
|
||||
const payload: ClipboardPayload = {
|
||||
kind: "dom-element",
|
||||
html,
|
||||
sourceFile: targetPath,
|
||||
originSelector: domSelection.selector,
|
||||
originSelectorIndex: domSelection.selectorIndex,
|
||||
};
|
||||
clipboardRef.current = payload;
|
||||
showToast("Copied element", "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [activeCompPath, domEditSelectionRef, previewIframeRef, showToast]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const payload = clipboardRef.current;
|
||||
if (!payload) {
|
||||
showToast("Nothing to paste.", "info");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
|
||||
const targetPath = activeCompPath || "index.html";
|
||||
try {
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
const existingIds = collectHtmlIds(originalContent);
|
||||
const deduped = deduplicateIds(payload.html, existingIds);
|
||||
|
||||
let patchedContent: string;
|
||||
if (payload.kind === "timeline-clip") {
|
||||
// Only rewrite data-start on the outermost opening tag. The non-global
|
||||
// regex matches the first occurrence, which is always in the root tag
|
||||
// since outerHTML starts with it. Nested clips keep their own timing.
|
||||
const { currentTime } = usePlayerStore.getState();
|
||||
const rootTagEnd = deduped.indexOf(">");
|
||||
const rootTag = rootTagEnd >= 0 ? deduped.slice(0, rootTagEnd + 1) : deduped;
|
||||
const patchedRootTag = rootTag.replace(
|
||||
/data-start="[^"]*"/,
|
||||
`data-start="${formatTimelineAttributeNumber(currentTime)}"`,
|
||||
);
|
||||
const withNewStart = patchedRootTag + deduped.slice(rootTagEnd + 1);
|
||||
patchedContent = insertTimelineAssetIntoSource(originalContent, withNewStart);
|
||||
} else {
|
||||
patchedContent = insertAsSibling(
|
||||
originalContent,
|
||||
deduped,
|
||||
payload.originSelector,
|
||||
payload.originSelectorIndex,
|
||||
);
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: payload.kind === "timeline-clip" ? "Paste clip" : "Paste element",
|
||||
kind: "timeline" as EditHistoryKind,
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
reloadPreview();
|
||||
showToast(payload.kind === "timeline-clip" ? "Pasted clip" : "Pasted element", "info");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to paste";
|
||||
showToast(message);
|
||||
}
|
||||
}, [
|
||||
activeCompPath,
|
||||
domEditSaveTimestampRef,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
]);
|
||||
|
||||
const handleCut = useCallback(async (): Promise<boolean> => {
|
||||
const copied = handleCopy();
|
||||
if (!copied) return false;
|
||||
|
||||
const { selectedElementId, elements } = usePlayerStore.getState();
|
||||
if (selectedElementId) {
|
||||
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
|
||||
if (element) {
|
||||
await handleTimelineElementDelete(element);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const domSelection = domEditSelectionRef.current;
|
||||
if (domSelection) {
|
||||
await handleDomEditElementDelete(domSelection);
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}, [handleCopy, domEditSelectionRef, handleTimelineElementDelete, handleDomEditElementDelete]);
|
||||
|
||||
return { handleCopy, handlePaste, handleCut };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deduplicateIds,
|
||||
serializeClipboardPayload,
|
||||
deserializeClipboardPayload,
|
||||
type ClipboardPayload,
|
||||
} from "./clipboardPayload";
|
||||
|
||||
describe("deduplicateIds", () => {
|
||||
it("renames ids that collide with existing ids", () => {
|
||||
const html = '<div id="hero"><img id="photo" src="a.png" /></div>';
|
||||
const existingIds = ["hero", "other"];
|
||||
const result = deduplicateIds(html, existingIds);
|
||||
expect(result).not.toContain('id="hero"');
|
||||
expect(result).toContain('id="photo"');
|
||||
expect(result).toMatch(/id="hero-\d+"/);
|
||||
});
|
||||
|
||||
it("returns html unchanged when no collisions", () => {
|
||||
const html = '<div id="unique"><p>hello</p></div>';
|
||||
const result = deduplicateIds(html, ["other"]);
|
||||
expect(result).toBe(html);
|
||||
});
|
||||
|
||||
it("does not rewrite data-composition-id or other data-*-id attributes", () => {
|
||||
const html = '<div data-composition-id="hero" data-clip-id="hero" id="hero">content</div>';
|
||||
const result = deduplicateIds(html, ["hero"]);
|
||||
expect(result).toContain('data-composition-id="hero"');
|
||||
expect(result).toContain('data-clip-id="hero"');
|
||||
expect(result).toMatch(/\sid="hero-\d+"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeClipboardPayload / deserializeClipboardPayload", () => {
|
||||
it("round-trips a timeline clip payload", () => {
|
||||
const payload: ClipboardPayload = {
|
||||
kind: "timeline-clip",
|
||||
html: '<img id="photo" src="a.png" data-start="1" data-duration="3" />',
|
||||
sourceFile: "index.html",
|
||||
};
|
||||
const json = serializeClipboardPayload(payload);
|
||||
const parsed = deserializeClipboardPayload(json);
|
||||
expect(parsed).toEqual(payload);
|
||||
});
|
||||
|
||||
it("round-trips a dom-element payload", () => {
|
||||
const payload: ClipboardPayload = {
|
||||
kind: "dom-element",
|
||||
html: '<div class="card"><p>Hello</p></div>',
|
||||
sourceFile: "compositions/scene.html",
|
||||
};
|
||||
const json = serializeClipboardPayload(payload);
|
||||
const parsed = deserializeClipboardPayload(json);
|
||||
expect(parsed).toEqual(payload);
|
||||
});
|
||||
|
||||
it("returns null for invalid JSON", () => {
|
||||
expect(deserializeClipboardPayload("not json")).toBeNull();
|
||||
expect(deserializeClipboardPayload('{"kind":"unknown"}')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
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}"`;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user