85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
170 lines
5.5 KiB
TypeScript
170 lines
5.5 KiB
TypeScript
import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
|
|
|
|
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 rootMatch = COMPOSITION_ROOT_OPEN_TAG_RE.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}"`;
|
|
});
|
|
}
|