Files
hyperframes/packages/studio-server/src/helpers/previewAdapter.ts
T
Vance Ingalls e4c4d2e15d fix(studio-server): address PR #2097 review feedback on relative-timing resolver
Documents the shared-pattern context (3rd copy of "resolve relative
data-start", after runtime startResolver.ts and the SDK's own
getElementTimings) and explains when the raw parseFloat fallback in
resolveStart's else branch can actually fire (a malformed grammar string
with a leading number). Adds a test pinning the "reference target exists
but its own timing is unresolvable" branch, which existing tests didn't
cover (only "target doesn't exist" was tested).

Cross-checked the negative-offset clamp concern raised in review: the
SDK's own resolveReferenceStart (session.ts) also clamps to
Math.max(0, ...), so this stays consistent with its sibling — no code
change needed there.
2026-07-09 11:27:53 -07:00

218 lines
8.6 KiB
TypeScript

import {
STUDIO_OFFSET_X_PROP,
STUDIO_OFFSET_Y_PROP,
STUDIO_WIDTH_PROP,
STUDIO_HEIGHT_PROP,
STUDIO_MANUAL_EDIT_GESTURE_ATTR,
} from "./draftMarkers.js";
import { parseStartExpression } from "@hyperframes/core/runtime/start-expression";
export type DraftPayload =
| { type: "move"; hfId: string; dx: number; dy: number }
| { type: "resize"; hfId: string; w: number; h: number };
export type CommitPatch =
| { type: "moveElement"; hfId: string; dx: number; dy: number }
| { type: "resize"; hfId: string; width: number; height: number };
export interface PreviewAdapter {
/**
* @param atTime - Caller hint only. The adapter reads current computed styles;
* the caller must seek the GSAP timeline to `atTime` before invoking so that
* GSAP-driven inline styles reflect the desired playhead position.
*/
elementAtPoint(x: number, y: number, opts?: { atTime?: number }): Element | null;
applyDraft(payload: DraftPayload): void;
revertDraft(): void;
commitPreview(): CommitPatch | null;
getElementTimings(): Record<string, { start?: number; end?: number }>;
}
interface GestureState {
payload: DraftPayload;
originalTranslate: string | undefined;
}
export function createPreviewAdapter(
doc: Document,
opts?: { resolvePoint?: (x: number, y: number) => Element | null },
): PreviewAdapter {
let gesture: GestureState | null = null;
function findById(hfId: string): HTMLElement | null {
// CSS.escape is available in browsers; hf-ids are always hf-[a-z0-9]+ so
// no escaping is strictly needed, but be safe in non-browser environments.
const escaped =
typeof CSS !== "undefined" && typeof CSS.escape === "function"
? CSS.escape(hfId)
: hfId.replace(/([^\w-])/g, "\\$1");
return doc.querySelector(`[data-hf-id="${escaped}"]`) as HTMLElement | null;
}
function isVisible(el: Element): boolean {
const view = doc.defaultView;
if (!view) return true;
const style = view.getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") return false;
const op = parseFloat(style.opacity);
// NaN (empty string from environments with no CSS cascade) → treat as visible.
// 0.01 threshold: sub-1% opacity is not user-targetable in drag gestures.
return Number.isNaN(op) || op >= 0.01;
}
function clearDraftProps(target: HTMLElement): void {
target.style.removeProperty(STUDIO_OFFSET_X_PROP);
target.style.removeProperty(STUDIO_OFFSET_Y_PROP);
target.style.removeProperty(STUDIO_WIDTH_PROP);
target.style.removeProperty(STUDIO_HEIGHT_PROP);
target.removeAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
}
function revertGesture(target: HTMLElement, state: GestureState): void {
clearDraftProps(target);
if (state.originalTranslate !== undefined) {
target.style.setProperty("translate", state.originalTranslate);
}
}
return {
elementAtPoint(x, y, _perCallOpts) {
const hit = opts?.resolvePoint?.(x, y) ?? null;
if (!hit) return null;
let el: Element | null = hit;
while (el && el !== doc.body) {
if (el.hasAttribute("data-hf-id")) {
return isVisible(el) ? (el as HTMLElement) : null;
}
// data-hf-root without data-hf-id = outermost stage root — stop
if (el.hasAttribute("data-hf-root")) return null;
el = el.parentElement;
}
return null;
},
applyDraft(payload) {
// Auto-revert any in-flight gesture before starting a new one so no
// element is left with orphaned draft CSS props or the gesture marker.
if (gesture) {
const prev = findById(gesture.payload.hfId);
if (prev) revertGesture(prev, gesture);
gesture = null;
}
const target = findById(payload.hfId);
if (!target) return;
const originalTranslate = target.style.getPropertyValue("translate") || undefined;
gesture = { payload, originalTranslate };
target.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, "true");
if (payload.type === "move") {
target.style.setProperty(STUDIO_OFFSET_X_PROP, `${payload.dx}px`);
target.style.setProperty(STUDIO_OFFSET_Y_PROP, `${payload.dy}px`);
} else {
target.style.setProperty(STUDIO_WIDTH_PROP, `${payload.w}px`);
target.style.setProperty(STUDIO_HEIGHT_PROP, `${payload.h}px`);
}
},
revertDraft() {
if (!gesture) return;
const target = findById(gesture.payload.hfId);
if (target) revertGesture(target, gesture);
gesture = null;
},
commitPreview() {
if (!gesture) return null;
const { payload } = gesture;
const target = findById(payload.hfId);
if (target) clearDraftProps(target);
gesture = null;
if (payload.type === "move") {
return { type: "moveElement", hfId: payload.hfId, dx: payload.dx, dy: payload.dy };
}
return { type: "resize", hfId: payload.hfId, width: payload.w, height: payload.h };
},
getElementTimings() {
// data-start can be a relative-reference expression ("intro", "intro + 2" —
// parseStartExpression's grammar), not just an absolute number. A raw
// parseFloat on it (the old behavior) silently resolves any reference to
// undefined. Resolve references recursively against the target's own
// resolved end (data-end, or data-start + data-duration when the target
// has no data-end) — this function never read data-duration before either,
// so a reference to a duration-authored (not end-authored) clip used to be
// unresolvable regardless of the parseFloat bug.
//
// This is the third copy of "resolve relative data-start" (runtime
// startResolver.ts; the SDK's own getElementTimings in session.ts; this
// one). The runtime version is substantially more complex (host offsets,
// media, live timelines) so a shared extraction isn't a straightforward
// win — this one and the SDK's stay hand-kept in sync instead.
const startCache = new Map<Element, number | undefined>();
const visiting = new Set<Element>();
const resolveEnd = (el: Element): number | undefined => {
const endAttr = el.getAttribute("data-end");
if (endAttr !== null) {
const ev = parseFloat(endAttr);
if (Number.isFinite(ev)) return ev;
}
const durationAttr = el.getAttribute("data-duration");
const dv = durationAttr !== null ? parseFloat(durationAttr) : NaN;
const sv = resolveStart(el);
if (Number.isFinite(dv) && sv !== undefined) return sv + dv;
return undefined;
};
// Split out of resolveStart so its own branching stays low — mirrors the
// SDK's getElementTimings resolver split (resolveReferenceStart).
const resolveReferenceStart = (refId: string, offset: number): number | undefined => {
const target = findById(refId);
const targetEnd = target ? resolveEnd(target) : undefined;
return targetEnd !== undefined ? Math.max(0, targetEnd + offset) : undefined;
};
const resolveStart = (el: Element): number | undefined => {
if (startCache.has(el)) return startCache.get(el);
if (visiting.has(el)) return undefined; // reference cycle — fail safe, don't loop
visiting.add(el);
let resolved: number | undefined;
try {
const startStr = el.getAttribute("data-start");
const expr = parseStartExpression(startStr);
if (expr?.kind === "reference") {
resolved = resolveReferenceStart(expr.refId, expr.offset);
} else if (expr?.kind === "absolute") {
resolved = expr.value;
} else {
// parseStartExpression returns null for empty/absent data-start, and
// also for a malformed grammar string (e.g. "3 abc" — a leading
// number followed by content the reference regex rejects). The
// parseFloat below only ever succeeds on that second, malformed
// case (a clean number or a clean reference already matched above).
const sv = startStr !== null ? parseFloat(startStr) : NaN;
resolved = Number.isFinite(sv) ? sv : undefined;
}
} finally {
visiting.delete(el);
}
startCache.set(el, resolved);
return resolved;
};
const result: Record<string, { start?: number; end?: number }> = {};
for (const el of doc.querySelectorAll("[data-hf-id]")) {
const hfId = el.getAttribute("data-hf-id");
if (!hfId) continue;
result[hfId] = { start: resolveStart(el), end: resolveEnd(el) };
}
return result;
},
};
}