Files
hyperframes/packages/studio-server/src/helpers/previewAdapter.ts
T
Miguel Ángel 7a4853dfe6 refactor: extract @hyperframes/studio-server from core (#1757)
* refactor: extract @hyperframes/studio-server package from core

Moves all studio-api routes, helpers, and Hono server wiring from
packages/core/src/studio-api/ into a new standalone packages/studio-server
package (@hyperframes/studio-server).

Core keeps thin re-export stubs at @hyperframes/core/studio-api and the
subpath helpers (screenshot-clip, draft-markers, etc.) for backward
compatibility. Consumer imports (cli studioServer, vite adapter/config,
producer htmlCompiler, studio manualEditsTypes) are updated to import from
@hyperframes/studio-server directly.

Also exports rewriteInlineStyleAssetUrls from @hyperframes/core root (was
in compiler/rewriteSubCompPaths.ts but not re-exported), required by
@hyperframes/studio-server/helpers/subComposition.

Removes postcss-selector-parser from @hyperframes/core dependencies (moved
to @hyperframes/studio-server which owns the routes that used it).

Depends on @hyperframes/parsers (PR #1755).

* fix(ci): add parsers+studio-server to Dockerfile and build before preview tests

* fix(ci): build @hyperframes/studio-server before Test and studio load smoke

Studio's vite.config.ts imports @hyperframes/studio-server, which resolves
via its "node" export condition to built dist. The Test and studio-load-smoke
jobs only built parsers + core, so esbuild's config load failed to resolve the
package entry. Build studio-server too.

* fix(studio): repoint sdkCutoverParity test import to studio-server

sourceMutation moved from core's studio-api to @hyperframes/studio-server;
the test still imported the deleted core path. This was masked while studio's
vite.config failed to load (couldn't resolve studio-server); now that the
config loads, the test runs and the stale import surfaced.
2026-06-27 01:24:01 -04:00

158 lines
5.4 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";
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() {
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;
const s = el.getAttribute("data-start");
const e = el.getAttribute("data-end");
const sv = s !== null ? parseFloat(s) : NaN;
const ev = e !== null ? parseFloat(e) : NaN;
result[hfId] = {
start: Number.isFinite(sv) ? sv : undefined,
end: Number.isFinite(ev) ? ev : undefined,
};
}
return result;
},
};
}