mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
* feat(studio): glue API coexistence layer for the NLE swap What: extends 21 glue files so the OLD timeline/canvas engine and the NEW NLE components type-check side by side: playerStore (multi-select setters, zoom pin, snap toggle, non-reactive scale scratch), drag-state types gain optional NLE fields, timelineLayout/timelineAssetDrop/timelineEditingHelpers/ timelineEditing/timelineElementHelpers/studioHelpers/assetHelpers gain the NLE exports, DomEditOverlay + gestures + AssetContextMenu + Timeline props gain optional callbacks/params, contexts gain *Optional hooks, and TimelineEditCallbacks.onMoveElements becomes a bivariant method accepting both engines' change shapes. patchDocumentRootDuration's test rides along. Why: this is the keystone that dissolves the old "welded glue" problem — every symbol the NLE components need is ADDED next to what the old engine still uses, so the engine components and the swaps can land as separate reviewable PRs. How: 15 authored intermediate files (main content + additive symbols; no behavior changes — new fields optional, new callbacks unused until wired) plus 6 files whose final content is already purely additive. New exports without consumers yet carry TEMP(studio-dnd) ignoreExports entries, removed by the app-shell swap. Test plan: tsc --noEmit in studio + studio-server (verifies BOTH engines compile); bunx vitest run (full suite green incl. the 6 new patchDocumentRootDuration tests); fallow audit clean. * feat(studio): timeline interaction hooks and lanes component (unwired) What: the timeline-side wiring layer, unwired: TimelineLanes (the lane renderer driving drag/resize/marquee), timelineMarquee (+tests), useTimelineStackingSync, useTimelineGeometry, useTimelineEditPinning, useTimelineEditingDrops. Why: everything between the pure drag math and <Timeline> itself; the timeline-glue swap PR then only rewires Timeline/TimelineCanvas onto these. How: new files, tsc-clean against the coexistence layer. Unwired components carry TEMP(studio-dnd) entry registrations, removed at the app-shell swap. Test plan: bunx vitest run timelineMarquee.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): NLE shell assembly (unwired) What: EditorShell (the full editor layout replacing NLELayout + StudioPreviewArea), TimelinePane (timeline host with sub-comp rebasing) and useTimelineEditCallbacks (the callback bag bridging store edits to the timeline), all unwired. Why: the shell that App swaps to in the final step; reviewing it standalone keeps that swap PR small. How: new files against the coexistence layer; TEMP(studio-dnd) entries until App mounts EditorShell in the app-shell swap. Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit clean. * feat(studio): timeline glue swap — Timeline/TimelineCanvas onto the NLE engine What: flips the timeline glue to its final form (23 files): Timeline and TimelineCanvas rebuilt on TimelineLanes/TimelineOverlays, useTimelineClipDrag drives preview/commit through the new drag engine, range selection goes multi-select, playback loop moves to useTimelinePlayerLoop. Deletes the 9 old-engine files this orphans (group drag, marquee selection, snap targets, layer gutter, selection overlays + their suites) — each is compile- or gate-forced by this swap, verified by probe. Why: second swap step; timeline-only, canvas and App untouched. How: modified files to final content + forced deletions. playerStore/timelineEditing/timelineCallbacks stay at their coexistence form until the app swap (the old App still runs on them). Test plan: tsc --noEmit; bunx vitest run (full suite); fallow audit clean. * feat(studio): clip thumbnail modules What: ImageThumbnail (+tests) and thumbnailUtils (+tests) — frame decode with SVG/AVIF format fallbacks and rounded-corner clipping — plus VideoThumbnail updates. Why: the decode layer for timeline clip thumbnails, ahead of the visual refresh that renders them. How: new modules + one modified file; purely presentational. Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit clean. * feat(studio): assets/blocks panel behaviors + preview helpers What: blocks tab install flow, right-panel and global drag-overlay polish, music beat analysis and clip-content rendering hooks, and the preview-helper utilities backing asset preview. Why: completes the studio NLE stack on top of the visual refresh. How: modified files only (kept as one PR: splitting further would produce sub-150-LOC fragments of interdependent panel glue). Test plan: bunx vitest run studioPreviewHelpers/studioUrlState suites; tsc --noEmit; fallow audit clean. * fix(studio): restore timeline playback loop * fix(studio): restore missing GSAP helpers module * refactor(studio): split timeline GSAP helpers * style(studio): keep timeline helper under size limit * fix(studio): restore timeline overlays module * fix(studio): remove stale GSAP import * fix(studio): restore canonical timeline dependencies * style(studio): format restored timeline helpers * style(studio): satisfy helper line limit * fix(studio): repair rebuilt timeline integration * feat(studio): complete rebuilt NLE cutover * fix(studio): guard project and timeline race boundaries * fix(studio): preserve graded resize and crop geometry * fix(studio): log resize/rotate commit failures, move anchor accumulator to resize-local * fix(studio): treat duration-0 tweens as static holds and settle resize position before persist Instant holds (to()/fromTo() with duration 0) were classified as animated tweens by every commit route, so resizing or rotating them converted the hold into a corrupt duration-0 keyframes tween (new value at 0%, old at 100%) that GSAP drops; panel edits appended a losing set. A shared isInstantHold() now routes them through the static replace-in-place path, and percentage math guards zero-duration windows. Separately, anchored-corner resizes painted 3-5 frames at the new size but old position while the offset persist round-tripped the server. The commit path now applies the corrected GSAP position synchronously before awaiting the offset persist, mirroring the scale route's settle. * feat(studio): gesture-transaction seam with commit observability Introduce runGestureTransaction — one owner for a gesture commit's settle -> persist -> record lifecycle. It settles the live DOM synchronously before any async persist, folds every mutation into one undo entry via a per-transaction coalesceKey, restores pre-gesture state exactly once on failure, and asserts (dev console) + reports (PostHog: commit_transaction / commit_invariant_violation / commit_transaction_failed) that a persist never changes pixels. The box-size resize path is migrated onto it; the ad hoc per-route coalesceKey/reload handling is removed. Extract the resize draft-rect math into resizeDraft.ts to keep the gesture-handler file under the size cap. Also: keep url_hash telemetry to the route slug only (drop the query string, which carried the user's selected element id/selector), and gate the [hf-resize] diagnostics behind localStorage hf-resize-debug so they ship as opt-in tracing rather than console noise. * fix(studio): transaction owns the undo label The coalesced history entry took the last sub-mutation's label, so a resize surfaced as "Move layer" (the offset persist) in undo/redo. The seam now stamps tx.label on every wrapped mutation, so the folded entry reads as the gesture. * fix(studio): atomic static size/position commits (no data loss) Static resize/position holds updated an existing set via delete+add — two undo entries, and a delete that succeeded before a failed add lost the hold on disk. Use one in-place update-properties mutation when a set exists (one undo entry, no partial-failure window). The keyframed-hold heal that can't be expressed as a property update now adds before it deletes, so any single failure leaves a recoverable duplicate, never a lost hold. Transaction-owned commits are tracked via a WeakSet so the heal path never double-wraps an already-wrapped gesture. * fix(core): restore timed-clip visibility after a forced timeline rebind __hfForceTimelineRebind force-rendered the re-registered timeline but never re-ran the per-[data-start] visibility pass, so after undo or soft reload every clip rendered regardless of its time window until a full page reload. Extract the visibility loop into syncTimedElementVisibility and call it from both syncMediaForCurrentState (unchanged) and the rebind. * fix(studio): atomic z-order/keyframe/split commits, one undo entry each Three edit-commit paths hardened onto the one-transaction invariant: - Z-order reorder (useElementLifecycleOps): N per-element writes now fold into one undo entry (coalesceMs Infinity) and, on a failed persist, restore already-written files to disk so no partial reorder survives. - Enable-keyframes (useEnableKeyframes/useGsapKeyframeOps): the intermediate convert phase no longer full-reloads the preview (skipReload), killing the black-flash remount; convert + edit share one coalesce key = one undo entry. - Razor split-all (useRazorSplit): snapshot before the batch and restore on any failure, so a mid-batch error never leaves un-revertable partial splits. Shared file-history helpers (RecordEditInput, DomEditCommitBaseParams, readProjectFileContent, restoreFilesToOriginal) dedupe the rollback/commit logic across these paths. Commit options thread as one partial object rather than field-by-field. Test setup extracted into colocated helpers. * fix(studio): fold multi-step edits into one undo entry; guard text revert - Gesture recording (useGestureCommit): the per-property-group commits now share one coalesce key and only the last reloads, so a recording is one undo entry and one preview reload instead of up to four. - Delete selected keyframes (deleteSelectedKeyframes, split out of timelineEditingHelpers): N removals fold into one coalesced undo entry with a single reload. - Text-field commit (useDomEditTextCommits): commitDomTextFields now uses the same version-guarded revert as handleDomTextCommit, so a stale failed commit can no longer stomp a newer successful one. * feat(studio): batch a gesture's mutations into one atomic server write A transaction that emits N mutations previously did N sequential POSTs, each rewriting the file and soft-reloading — the root of the multi-phase persist window. Add a gsap-mutations-batch endpoint that validates every mutation up front, applies them in one in-memory rewrite chain, and writes the file once (all-or-nothing: an invalid entry rejects the whole batch, no partial write). The seam buffers a transaction's commits and, when more than one targets the same file, dispatches a single batch — one write, one history entry, one reload. The batch capability rides on the existing commit-function reference; no option fields are threaded through callers. * fix(studio): soften off-canvas indicator outline to 30% opacity The dashed off-canvas selection outline at 60% was noisy with many protruding elements on screen; drop the resting opacity to 30% (hover still restores full opacity so it stays discoverable). * fix(studio): drop off-canvas indicator outline to 10% opacity Follow-up to the 30% softening — 10% resting opacity reads much calmer with many protruding elements; hover still restores full opacity. * fix(studio): gate [hf-commit] console traces to dev only The start/settled/persisted/restore lifecycle traces logged on every gesture commit in all environments — console noise for end users. Route them through a dev-only traceCommit helper (matching the pixel-violation error's existing DEV gate). The commit_* PostHog events stay always on; they are the production observability, the console lines are a dev aid. * fix(studio): count actual reloads, not softReload requests, in commit telemetry A resize's size and offset persists both request softReload; the seam counted each request, so a batched gesture reported reload_count 2 even though the batch is one write and one reload. Compute the count from what dispatchBufferedCommits actually did — one for a batch, the request count for the sequential fallback. * fix(studio): rotate hover + off-canvas overlays with the element; flicker-free crop - Hover overlay applied the element's rotation only to the selection chrome, not the hover box; it now rotates about center like the selection, via a shared orientedGroupAwareOverlayRect router (one owner for rotation-aware overlay geometry across hover/selection/off-canvas). - Off-canvas indicator was axis-aligned; it now rotates with the element and inverse-rotates the canvas-exclusion clip into the element's local frame, so the protruding-sliver clip stays correct for rotated elements. - Crop commit re-lifted the element only in the commit's .then(), so one frame painted the cropped state (the flicker). Re-lift synchronously right after onStyleCommit (which applies the clip before its first await), so the cropped state never paints; the persisted file value is unchanged. * fix(studio): address code-review findings across the commit-hardening campaign Correctness (would ship green, bite under latency): - Enable-keyframes phase 2 now carries coalesceMs: Infinity, so the convert folds into one undo entry instead of splitting past the 300ms default. - The SDK keyframe persist path forwards coalesceMs (CutoverOptions gains the field); multi-keyframe delete and convert coalesce correctly when SDK-routed. - Razor split-all's rollback is guarded so a failing restore can't swallow the error toast that tells the user the split failed. Simplification (single source of truth / no dead flexibility): - Decompose resolveResizeDraftRect (drops a fallow-ignore suppression). - Delegate the third readProjectFileContent copy to the shared helper. - Inline setPatchFromUpdateProperties (its only caller passes one mutation). - One toSdkPersistOptions translates gesture overrides to SDK options. - Bundle the reorder-rollback deps into one object (was 7-9 positional args). - Dedupe the 'last group reloads' ternary; type gesture options as CommitMutationOptions; drop a Map+array wrapper around a single write. * feat(studio): atomic z-order reorder via batch patch-element endpoint Z-order reorder issued N per-element inline-style patches (one server write each), so a mid-chain failure could leave a partial reorder on disk. Add a patch-elements-batch endpoint that validates every patch, folds them over the file in one in-memory rewrite, and writes once (all-or-nothing; unsafe input rejects with no write). The reorder now sends one batch per source file and records one undo entry. Because a failed atomic write persists nothing, the interim disk-write-back rollback (restoreReorderedFile / restoreFulfilledReorderFiles / ReorderRollbackDeps) is deleted — failure rolls back only live DOM/store state. Closes the last disk-atomicity gap. * fix(studio): razor-split undo no longer silently no-ops The split clone was written to disk without a data-hf-id, so the split endpoint recorded that unstamped HTML as the undo entry's afterHash. The next reloadPreview() ran the preview route's ensureHfIds write-back, which minted a fresh id and persisted DIFFERENT bytes — so at undo time the disk hash no longer matched afterHash and editHistory's content-mismatch guard silently refused the undo (no write, no network, no error). Stamp the split output via ensureHfIds in splitElementInHtml before it is written/returned, so the preview write-back is a no-op and the recorded afterHash always equals the final on-disk bytes. Fixes at the source rather than relaxing the mismatch guard. Corrects the stale comment that credited forceReloadSdkSession. * feat(studio): closed-hand grab cursor on the rotate handle The rotate handle used the default arrow cursor; show a grabbing (closed-hand) cursor on hover to signal it's grabbed and dragged to rotate. * fix(studio): dropping a dragged element over another no longer selects it A moved drag's release fired the box click, which re-selected whatever now sat under the pointer via the hover cache — so dropping an element over a higher-z one selected the drop target instead of keeping the dragged element selected. The drag-move branch now suppresses the next box click, mirroring the resize branch. * fix(studio): group drag is one undo entry, not one per element Dragging a multi-selected group committed each member's position write as its own undo entry, so reverting took N Cmd+Z presses. Force a shared coalesceKey (infinite window) across every member's commit so they fold into a single undo entry, like the other multi-step commit paths. * fix(studio): undo of a split no longer leaves a ghost clip in the timeline The file and the composition iframe revert correctly on undo, but the timeline panel kept a ghost node for the split clone. The element-merge that repopulates the timeline preserves elements the fresh scan dropped — intended for enriched sub-composition children a bare DOM re-scan misses, but it also preserved a genuinely-removed TOP-LEVEL element (the split clone after undo), leaving a phantom clip. Restrict the preserve to elements with a compositionSrc (the enriched sub-comp children); a top-level element missing from the fresh scan was truly removed. --------- Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
573 lines
21 KiB
TypeScript
573 lines
21 KiB
TypeScript
import { parseHTML } from "linkedom";
|
|
import postcss from "postcss";
|
|
import selectorParser from "postcss-selector-parser";
|
|
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "@hyperframes/core/html-attr-safety";
|
|
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
|
import { parseStyleDecls, patchStyleAttrString } from "./sourceStyleMutation.js";
|
|
|
|
export interface SourceMutationTarget {
|
|
id?: string | null;
|
|
hfId?: string;
|
|
selector?: string;
|
|
selectorIndex?: number;
|
|
}
|
|
|
|
function parseSourceDocument(source: string): { document: Document; wrappedFragment: boolean } {
|
|
const hasDocumentShell = /<!doctype|<html[\s>]/i.test(source);
|
|
if (hasDocumentShell) {
|
|
return { document: parseHTML(source).document, wrappedFragment: false };
|
|
}
|
|
return {
|
|
document: parseHTML(`<!DOCTYPE html><html><head></head><body>${source}</body></html>`).document,
|
|
wrappedFragment: true,
|
|
};
|
|
}
|
|
|
|
function duplicateCssRulesForId(document: Document, originalId: string, newId: string): void {
|
|
const idToken = `#${originalId}`;
|
|
const transform = selectorParser((selectors) => {
|
|
selectors.walkIds((node) => {
|
|
if (node.value === originalId) node.value = newId;
|
|
});
|
|
});
|
|
for (const styleEl of document.querySelectorAll("style")) {
|
|
const css = styleEl.textContent ?? "";
|
|
let root: postcss.Root;
|
|
try {
|
|
root = postcss.parse(css);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const clones: postcss.Rule[] = [];
|
|
root.walkRules((rule) => {
|
|
if (!rule.selector.includes(idToken)) return;
|
|
const newSelector = transform.processSync(rule.selector);
|
|
if (newSelector === rule.selector) return;
|
|
const clone = rule.clone({ selector: newSelector });
|
|
clones.push(clone);
|
|
});
|
|
if (clones.length > 0) {
|
|
for (const c of clones) root.append(c);
|
|
styleEl.textContent = root.toString();
|
|
}
|
|
}
|
|
}
|
|
|
|
function querySelectorAllWithTemplates(root: Document | Element, selector: string): Element[] {
|
|
const matches = Array.from(root.querySelectorAll(selector));
|
|
if (matches.length > 0) return matches;
|
|
// querySelectorAll doesn't traverse <template> content in linkedom.
|
|
// Search directly on each template element (NOT .content — removing from
|
|
// .content's DocumentFragment doesn't update the serialized output).
|
|
// Recurse so NESTED templates resolve too — ensureHfIds and the SDK's
|
|
// querySelectorAllDeep descend nested composition templates, so ids exist at
|
|
// any template depth; a one-level search here would silently no-op
|
|
// server-side ops on those ids while the SDK resolves them.
|
|
const templates = Array.from(root.querySelectorAll("template"));
|
|
for (const tmpl of templates) {
|
|
const inner = querySelectorAllWithTemplates(tmpl, selector);
|
|
if (inner.length > 0) return inner;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// Prevent CSS attribute-selector injection via a crafted hfId: escape
|
|
// backslashes first, then double-quotes. Keeps a malformed/hostile value from
|
|
// breaking out of the `[data-hf-id="…"]` selector once callers beyond the
|
|
// internal mint contract (R2+ user flows) pass values here.
|
|
function escapeCssAttrValue(value: string): string {
|
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
}
|
|
|
|
function findByHfId(document: Document, hfId: string): Element | null {
|
|
try {
|
|
const matches = querySelectorAllWithTemplates(
|
|
document,
|
|
`[data-hf-id="${escapeCssAttrValue(hfId)}"]`,
|
|
);
|
|
if (matches.length > 1) {
|
|
// The mint contract guarantees uniqueness; a duplicate means upstream
|
|
// id drift. Don't silently patch an arbitrary one — surface it.
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
`sourceMutation: data-hf-id "${hfId}" matched ${matches.length} elements; using the first. ids must be unique per document.`,
|
|
);
|
|
}
|
|
return matches[0] ?? null;
|
|
} catch {
|
|
// Malformed selector despite escaping — let the caller fall back.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function findTargetElement(document: Document, target: SourceMutationTarget): Element | null {
|
|
if (target.hfId) {
|
|
const el = findByHfId(document, target.hfId);
|
|
if (el) return el;
|
|
}
|
|
|
|
if (target.id) {
|
|
const byId = document.getElementById(target.id);
|
|
if (byId) return byId;
|
|
}
|
|
|
|
if (!target.selector) return null;
|
|
try {
|
|
const matches = querySelectorAllWithTemplates(document, target.selector);
|
|
return matches[target.selectorIndex ?? 0] ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function removeElementFromHtml(source: string, target: SourceMutationTarget): string {
|
|
const { document, wrappedFragment } = parseSourceDocument(source);
|
|
const element = findTargetElement(document, target);
|
|
if (!element) return source;
|
|
|
|
element.remove();
|
|
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
|
|
}
|
|
|
|
export function isHTMLElement(el: Node): el is HTMLElement {
|
|
const HTMLEl = el.ownerDocument?.defaultView?.HTMLElement;
|
|
return HTMLEl ? el instanceof HTMLEl : el.nodeType === 1 && "style" in el;
|
|
}
|
|
|
|
export interface PatchOperation {
|
|
type: "inline-style" | "attribute" | "html-attribute" | "text-content";
|
|
property: string;
|
|
value: string | null;
|
|
childSelector?: string;
|
|
childIndex?: number;
|
|
}
|
|
|
|
interface ResolvedPatchOperation {
|
|
op: PatchOperation;
|
|
target: HTMLElement;
|
|
}
|
|
|
|
function resolveOperationTarget(parent: HTMLElement, op: PatchOperation): HTMLElement | null {
|
|
if (op.childSelector === undefined) return parent;
|
|
try {
|
|
const child = parent.querySelectorAll(op.childSelector)[op.childIndex ?? 0] ?? null;
|
|
return child && isHTMLElement(child) ? child : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
export function patchElementInHtml(
|
|
source: string,
|
|
target: SourceMutationTarget,
|
|
operations: PatchOperation[],
|
|
): { html: string; matched: boolean } {
|
|
const { document, wrappedFragment } = parseSourceDocument(source);
|
|
const el = findTargetElement(document, target);
|
|
if (!el || !isHTMLElement(el)) return { html: source, matched: false };
|
|
const htmlEl = el;
|
|
|
|
const resolved: ResolvedPatchOperation[] = [];
|
|
for (const op of operations) {
|
|
const opTarget = resolveOperationTarget(htmlEl, op);
|
|
if (!opTarget) return { html: source, matched: false };
|
|
resolved.push({ op, target: opTarget });
|
|
}
|
|
|
|
for (const { op, target: opTarget } of resolved) {
|
|
switch (op.type) {
|
|
case "inline-style":
|
|
// linkedom's CSSStyleDeclaration does not support CSS custom properties
|
|
// (--foo) or newer individual transform properties (translate, rotate,
|
|
// scale) via style.setProperty(). Manipulate the style attribute string
|
|
// directly so all property names survive the round-trip.
|
|
{
|
|
const raw = opTarget.getAttribute("style") ?? "";
|
|
const patched = patchStyleAttrString(raw, op.property, op.value);
|
|
opTarget.setAttribute("style", patched);
|
|
}
|
|
break;
|
|
case "attribute":
|
|
{
|
|
const fullAttr = op.property.startsWith("data-") ? op.property : `data-${op.property}`;
|
|
if (op.value != null) {
|
|
opTarget.setAttribute(fullAttr, op.value);
|
|
} else {
|
|
opTarget.removeAttribute(fullAttr);
|
|
}
|
|
}
|
|
break;
|
|
case "html-attribute":
|
|
if (!isAllowedHtmlAttribute(op.property)) break;
|
|
if (op.value != null) {
|
|
if (!isSafeAttributeValue(op.property, op.value)) break;
|
|
opTarget.setAttribute(op.property, op.value);
|
|
} else {
|
|
opTarget.removeAttribute(op.property);
|
|
}
|
|
break;
|
|
case "text-content":
|
|
if (op.value != null) {
|
|
const inner = opTarget.children.length === 1 ? opTarget.firstElementChild : null;
|
|
const textTarget = inner && isHTMLElement(inner) ? inner : opTarget;
|
|
textTarget.textContent = op.value;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {
|
|
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
|
|
matched: true,
|
|
};
|
|
}
|
|
|
|
export function probeElementInSource(source: string, target: SourceMutationTarget): boolean {
|
|
if (!target.id && !target.hfId && !target.selector) return false;
|
|
const { document } = parseSourceDocument(source);
|
|
const el = findTargetElement(document, target);
|
|
return el != null && isHTMLElement(el);
|
|
}
|
|
|
|
export interface SplitElementResult {
|
|
html: string;
|
|
matched: boolean;
|
|
newId: string | null;
|
|
}
|
|
|
|
function resolveElementTiming(el: Element): {
|
|
start: number;
|
|
duration: number;
|
|
usesDataEnd: boolean;
|
|
} {
|
|
const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
|
|
const usesDataEnd = el.hasAttribute("data-end");
|
|
const duration = usesDataEnd
|
|
? parseFloat(el.getAttribute("data-end") ?? "") - start || 0
|
|
: parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
|
|
return { start, duration, usesDataEnd };
|
|
}
|
|
|
|
function setElementDuration(
|
|
el: Element,
|
|
start: number,
|
|
duration: number,
|
|
usesDataEnd: boolean,
|
|
): void {
|
|
if (usesDataEnd) {
|
|
const endTime = String(Math.round((start + duration) * 1000) / 1000);
|
|
el.setAttribute("data-end", endTime);
|
|
el.removeAttribute("data-duration");
|
|
} else {
|
|
el.setAttribute("data-duration", String(Math.round(duration * 1000) / 1000));
|
|
el.removeAttribute("data-end");
|
|
}
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
export function splitElementInHtml(
|
|
source: string,
|
|
target: SourceMutationTarget,
|
|
splitTime: number,
|
|
newId: string,
|
|
fallbackTiming?: { start: number; duration: number },
|
|
): SplitElementResult {
|
|
const { document, wrappedFragment } = parseSourceDocument(source);
|
|
const el = findTargetElement(document, target);
|
|
if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null };
|
|
|
|
const timing = resolveElementTiming(el);
|
|
const { usesDataEnd } = timing;
|
|
let { start, duration } = timing;
|
|
// GSAP-animated elements carry their timing in the script, not in data-* attrs,
|
|
// so the source has no authored duration. Fall back to the store's (GSAP-derived)
|
|
// range — the runtime windows visibility off data-start/data-duration regardless
|
|
// of class, so stamping both halves below makes each half show only in its window.
|
|
if (duration <= 0 && fallbackTiming && fallbackTiming.duration > 0) {
|
|
start = fallbackTiming.start;
|
|
duration = fallbackTiming.duration;
|
|
}
|
|
if (duration <= 0 || splitTime <= start || splitTime >= start + duration) {
|
|
return { html: source, matched: false, newId: null };
|
|
}
|
|
|
|
if (document.getElementById(newId)) {
|
|
let suffix = 2;
|
|
const base = newId;
|
|
while (document.getElementById(newId)) {
|
|
newId = `${base}-${suffix++}`;
|
|
}
|
|
}
|
|
|
|
const firstDuration = splitTime - start;
|
|
const secondDuration = duration - firstDuration;
|
|
|
|
const clone = el.cloneNode(true);
|
|
if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null };
|
|
clone.setAttribute("id", newId);
|
|
clone.removeAttribute("data-hf-id");
|
|
// Descendants carry their own data-hf-id; leaving them duplicates the id of
|
|
// every nested node (e.g. an inner <span>), so strip them on the clone too.
|
|
for (const node of clone.querySelectorAll("[data-hf-id]")) node.removeAttribute("data-hf-id");
|
|
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
|
|
setElementDuration(clone, splitTime, secondDuration, usesDataEnd);
|
|
|
|
// Keep the "clip" class — the runtime uses it to control visibility
|
|
// based on data-start/data-duration timing.
|
|
|
|
// Adjust media trim offset for the second half
|
|
const playbackStartAttr = el.hasAttribute("data-playback-start")
|
|
? "data-playback-start"
|
|
: el.hasAttribute("data-media-start")
|
|
? "data-media-start"
|
|
: null;
|
|
if (playbackStartAttr) {
|
|
const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0;
|
|
const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? "");
|
|
const rate = Number.isFinite(rateRaw) ? rateRaw : 1;
|
|
clone.setAttribute(
|
|
playbackStartAttr,
|
|
String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000),
|
|
);
|
|
}
|
|
|
|
// Duplicate CSS rules targeting the original ID so the clone inherits the same styles.
|
|
const originalId = el.getAttribute("id");
|
|
if (originalId) {
|
|
duplicateCssRulesForId(document, originalId, newId);
|
|
}
|
|
|
|
// Trim the original element's duration. A GSAP element had no data-start; stamp
|
|
// it so the runtime windows the first half (visibility selects on [data-start]).
|
|
el.setAttribute("data-start", String(Math.round(start * 1000) / 1000));
|
|
setElementDuration(el, start, firstDuration, usesDataEnd);
|
|
|
|
// Insert clone after original
|
|
if (el.nextSibling) {
|
|
el.parentElement!.insertBefore(clone, el.nextSibling);
|
|
} else {
|
|
el.parentElement!.appendChild(clone);
|
|
}
|
|
|
|
const html = wrappedFragment ? document.body.innerHTML || "" : document.toString();
|
|
return {
|
|
// The split owns its new nodes' stable ids. Leaving the clone unstamped makes
|
|
// the next preview request persist different bytes after history is recorded.
|
|
html: ensureHfIds(html),
|
|
matched: true,
|
|
newId,
|
|
};
|
|
}
|
|
|
|
// --- Element grouping -------------------------------------------------------
|
|
// A group is a real `<div data-hf-group="…">` wrapping its members in the DOM.
|
|
// Wrapping rebases each member's left/top so its absolute position is unchanged:
|
|
// the wrapper sits at the selection bbox top-left, and each child's new left/top
|
|
// is its old left/top minus the wrapper origin (computed client-side, where live
|
|
// layout is available, and passed in via `rebases`). GSAP x/y, CSS translate and
|
|
// --hf-studio-offset vars are deltas relative to flow position and stay untouched.
|
|
|
|
export interface WrapElementsResult {
|
|
html: string;
|
|
matched: boolean;
|
|
groupId: string | null;
|
|
error?: string;
|
|
}
|
|
|
|
export interface UnwrapElementsResult {
|
|
html: string;
|
|
unwrapped: boolean;
|
|
/** The unwrapped wrapper's id, so callers can strip GSAP that targeted it
|
|
* (the wrapper is gone; a leftover `gsap.set("#id")` would throw at runtime). */
|
|
unwrappedGroupId?: string;
|
|
/** Members (id'd children) with their absolute layout centres (post un-rebase),
|
|
* so the caller can BAKE the group's GSAP transform into each member before
|
|
* stripping it — otherwise the group's moves are lost on ungroup. */
|
|
members?: Array<{ id: string; cx: number; cy: number }>;
|
|
/** The wrapper's layout centre — the pivot for baking the group's rotation/scale. */
|
|
groupCenter?: { cx: number; cy: number };
|
|
}
|
|
|
|
export interface ElementRebase {
|
|
target: SourceMutationTarget;
|
|
left: number;
|
|
top: number;
|
|
}
|
|
|
|
function getInlineStylePx(el: Element, property: string): number {
|
|
const style = (isHTMLElement(el) ? el.getAttribute("style") : null) ?? "";
|
|
const { props } = parseStyleDecls(style);
|
|
const raw = props.get(property);
|
|
if (!raw) return 0;
|
|
const n = parseFloat(raw);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
function setInlineLeftTop(el: HTMLElement, left: number, top: number): void {
|
|
let style = el.getAttribute("style") ?? "";
|
|
style = patchStyleAttrString(style, "left", `${left}px`);
|
|
style = patchStyleAttrString(style, "top", `${top}px`);
|
|
el.setAttribute("style", style);
|
|
}
|
|
|
|
// Slug the group name ("Group 1" → "group-1") into a unique, valid element id.
|
|
function uniqueGroupDomId(document: Document, groupId: string): string {
|
|
const base =
|
|
groupId
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "") || "group";
|
|
let id = base;
|
|
let n = 2;
|
|
while (document.getElementById(id)) {
|
|
id = `${base}-${n}`;
|
|
n += 1;
|
|
}
|
|
return id;
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
export function wrapElementsInHtml(
|
|
source: string,
|
|
targets: SourceMutationTarget[],
|
|
groupId: string,
|
|
bbox: { left: number; top: number; width: number; height: number },
|
|
rebases: ElementRebase[],
|
|
): WrapElementsResult {
|
|
const { document, wrappedFragment } = parseSourceDocument(source);
|
|
if (targets.length === 0) {
|
|
return { html: source, matched: false, groupId: null, error: "no targets" };
|
|
}
|
|
|
|
// Resolve + dedupe by element ref (two targets may point at the same node).
|
|
const els: HTMLElement[] = [];
|
|
const seen = new Set<Element>();
|
|
for (const target of targets) {
|
|
const el = findTargetElement(document, target);
|
|
if (!el || !isHTMLElement(el) || seen.has(el)) continue;
|
|
seen.add(el);
|
|
els.push(el);
|
|
}
|
|
if (els.length === 0) {
|
|
return { html: source, matched: false, groupId: null, error: "no targets matched" };
|
|
}
|
|
|
|
// P1: require a single common parent (LCA multi-parent wrapping is P2).
|
|
const parent = els[0]?.parentElement;
|
|
if (!parent || els.some((el) => el.parentElement !== parent)) {
|
|
return {
|
|
html: source,
|
|
matched: false,
|
|
groupId: null,
|
|
error: "grouped elements must share a single parent",
|
|
};
|
|
}
|
|
|
|
// Order members by their position in the parent (= z-order / stacking order).
|
|
const memberSet = new Set<Element>(els);
|
|
const ordered = Array.from(parent.children).filter((c): c is HTMLElement => memberSet.has(c));
|
|
|
|
// Map each member to its rebased left/top (resolved against the same document).
|
|
const rebaseByEl = new Map<Element, { left: number; top: number }>();
|
|
for (const rebase of rebases) {
|
|
const el = findTargetElement(document, rebase.target);
|
|
if (el) rebaseByEl.set(el, { left: rebase.left, top: rebase.top });
|
|
}
|
|
|
|
const wrapper = document.createElement("div");
|
|
wrapper.setAttribute("data-hf-group", groupId);
|
|
// A real `id` (slug of the group name) makes the wrapper a first-class node in the
|
|
// clip manifest / timeline parent-map (both keyed by id) and a clean GSAP target —
|
|
// without it the wrapper is invisible to the timeline and breaks child enumeration.
|
|
wrapper.setAttribute("id", uniqueGroupDomId(document, groupId));
|
|
// Adopt the topmost member's stacking level. A group is one stacking unit, so a
|
|
// non-member interleaved between two selected members can't stay "between" them
|
|
// once they unify. Matching Figma/Sketch, the group lifts to the topmost selected
|
|
// layer: the wrapper goes at the LAST member's slot and carries the max member
|
|
// z-index — so an interleaved non-member falls below the group instead of hoisting
|
|
// above it, and explicit member z-indexes are honored.
|
|
const memberZIndexes = ordered
|
|
.map((el) =>
|
|
Number.parseInt(
|
|
parseStyleDecls(el.getAttribute("style") ?? "").props.get("z-index") ?? "",
|
|
10,
|
|
),
|
|
)
|
|
.filter((z) => Number.isFinite(z));
|
|
const maxZ = memberZIndexes.length > 0 ? Math.max(...memberZIndexes) : null;
|
|
wrapper.setAttribute(
|
|
"style",
|
|
`position: absolute; left: ${bbox.left}px; top: ${bbox.top}px; width: ${bbox.width}px; height: ${bbox.height}px` +
|
|
(maxZ !== null ? `; z-index: ${maxZ}` : ""),
|
|
);
|
|
|
|
// Insert the wrapper at the topmost member's slot, then move members into it.
|
|
parent.insertBefore(wrapper, ordered[ordered.length - 1] ?? null);
|
|
for (const el of ordered) {
|
|
const rebase = rebaseByEl.get(el);
|
|
if (rebase) setInlineLeftTop(el, rebase.left, rebase.top);
|
|
wrapper.appendChild(el); // appendChild moves the node, preserving order
|
|
}
|
|
|
|
return {
|
|
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
|
|
matched: true,
|
|
groupId,
|
|
};
|
|
}
|
|
|
|
export function unwrapElementsFromHtml(
|
|
source: string,
|
|
groupTarget: SourceMutationTarget,
|
|
): UnwrapElementsResult {
|
|
const { document, wrappedFragment } = parseSourceDocument(source);
|
|
const group = findTargetElement(document, groupTarget);
|
|
if (!group || !isHTMLElement(group)) return { html: source, unwrapped: false };
|
|
// Shape guard mirroring the wrap-side contract: only ever dissolve an actual
|
|
// group wrapper. A stale/desynced selection that resolves to a plain <div>
|
|
// would otherwise be unwrapped — rebasing its children by the parent's origin
|
|
// (silent corruption). Wrap enforces invariants; unwrap must too.
|
|
if (!group.hasAttribute("data-hf-group")) return { html: source, unwrapped: false };
|
|
|
|
const parent = group.parentElement;
|
|
if (!parent) return { html: source, unwrapped: false };
|
|
|
|
// Undo the rebase: child absolute position = child (rebased) + wrapper origin.
|
|
const wLeft = getInlineStylePx(group, "left");
|
|
const wTop = getInlineStylePx(group, "top");
|
|
const groupCenter = {
|
|
cx: wLeft + getInlineStylePx(group, "width") / 2,
|
|
cy: wTop + getInlineStylePx(group, "height") / 2,
|
|
};
|
|
|
|
// Move children back to the wrapper's slot, preserving order.
|
|
const members: Array<{ id: string; cx: number; cy: number }> = [];
|
|
for (const child of Array.from(group.children)) {
|
|
if (isHTMLElement(child)) {
|
|
const newLeft = getInlineStylePx(child, "left") + wLeft;
|
|
const newTop = getInlineStylePx(child, "top") + wTop;
|
|
setInlineLeftTop(child, newLeft, newTop);
|
|
if (child.id) {
|
|
members.push({
|
|
id: child.id,
|
|
cx: newLeft + getInlineStylePx(child, "width") / 2,
|
|
cy: newTop + getInlineStylePx(child, "height") / 2,
|
|
});
|
|
}
|
|
}
|
|
parent.insertBefore(child, group);
|
|
}
|
|
const groupId = group.id || undefined;
|
|
group.remove();
|
|
|
|
return {
|
|
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
|
|
unwrapped: true,
|
|
unwrappedGroupId: groupId,
|
|
members,
|
|
groupCenter,
|
|
};
|
|
}
|