mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(sdk): ws-a1 — iframe preview adapter (hit-test + selection) (#1489)
* feat(studio): stage 7 step 3c — sdk cutover for inline-style ops Introduces sdkCutoverPersist(): when STUDIO_SDK_CUTOVER_ENABLED is set, inline-style PatchOps are routed through the SDK session's in-memory document model instead of the server patch-element API. The SDK serialize() result is written back through the same writeProjectFile + editHistory.recordEdit path, so the on-disk output is identical to the legacy route. - packages/studio/src/utils/sdkCutover.ts (new): sdkCutoverPersist() + shouldUseSdkCutover() guard; domEditSaveTimestampRef.current is stamped on each write to suppress the echo file-change reload. - packages/studio/src/components/editor/manualEditingAvailability.ts: adds STUDIO_SDK_CUTOVER_ENABLED flag (default false); changes STUDIO_SDK_SHADOW_ENABLED default to false now that cutover is available. - packages/studio/src/hooks/useSdkSession.ts: adds optional domEditSaveTimestampRef param; self-write suppress window (SELF_WRITE_SUPPRESS_MS) gates file-change reloads so SDK writes don't echo back as external edits. - packages/studio/src/App.tsx: passes domEditSaveTimestampRef to useSdkSession so the suppress window can gate reloads triggered by SDK cutover writes. - Test coverage: sdkCutover.test.ts (new, 141 lines) + useDomEditSession.test.ts (new, 50 lines) — guard function + happy-path assertions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(studio): force-reload sdk session after undo/redo bypasses suppress window writeHistoryFile arms the 2 s self-write suppress window, so the file-change event for an undo/redo write is swallowed and the SDK in-memory doc stays on pre-undo content. Expose forceReload() from useSdkSession (s7.4) and call it in useAppHotkeys after a successful undo/redo that touched the active composition path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(studio): s7.5 — delete shadow scaffolding; keep cutover flag (dark launch) Removes the SDK shadow telemetry: STUDIO_SDK_SHADOW_ENABLED, sdkShadow.ts + sdkShadowGsapFidelity/GsapKeyframe/Numeric and their tests, the runShadow* call-sites across the GSAP/timeline hooks, and the onDomEditPersisted shadow callback in useDomEditSession. Moves patchOpsToSdkEditOps into sdkCutover.ts. KEEPS STUDIO_SDK_CUTOVER_ENABLED as a dark-launch kill-switch — default false, enable per-environment via VITE_STUDIO_SDK_CUTOVER_ENABLED=true. shouldUseSdkCutover stays flag-gated. The stack can merge with zero behavior change; cutover is validated by flipping the flag, not by removing it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(studio): wire onTrySdkPersist to sdkCutoverPersist (cutover was unwired) Stage 7 s7.5 removed the feature flag and declared cutover 'always-on', but onTrySdkPersist was never actually passed to useDomEditCommits — the sdkCutoverPersist function was dead code in production. Thread sdkSession through useDomEditSession params, build the onTrySdkPersist closure there (all CutoverDeps are already in scope), and pass sdkSession from App.tsx. Style/text/attribute/html-attribute commits now route through SDK dispatch instead of the server patch path. Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(studio): route element delete through SDK removeElement (§3.1) Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(studio): route timeline trim/move through SDK setTiming (§3.2) Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * chore(studio): document CSS-path position cut-over, GSAP-path intentionally deferred (§3.3) Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(studio): route GSAP tween add/update/delete through SDK (§3.5 PR1) Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(studio): route GSAP keyframe add through SDK (§3.5 PR2) Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(studio,core): resolve SDK-cutover review findings Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(sdk): ws-a1 — iframe preview adapter (hit-test + selection) Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Miguel Ángel
Claude Sonnet 4.6
parent
377b0368bd
commit
b96e8a3072
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Same-origin iframe PreviewAdapter — WS-A1 (hit-test + selection).
|
||||
*
|
||||
* Requirements:
|
||||
* - The iframe MUST be same-origin (srcdoc / blob URL). Cross-origin access to
|
||||
* contentDocument throws a DOMException; this adapter does not guard that —
|
||||
* the caller is responsible for ensuring same-origin.
|
||||
* - applyDraft / commitPreview / cancelPreview are WS-A2 scope — stubbed here.
|
||||
*/
|
||||
|
||||
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
|
||||
|
||||
// ─── Pure resolver (testable without a browser) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk from `el` upward through parentElement, looking for the nearest node
|
||||
* that carries `[data-hf-id]` and is NOT `[data-hf-root]`.
|
||||
*
|
||||
* Returns null when:
|
||||
* - The walk exits the tree without finding `[data-hf-id]`
|
||||
* - The matching node is `[data-hf-root]` (transparent to hit-testing)
|
||||
* - `isVisible(node)` returns false for the matching node
|
||||
*
|
||||
* Keeping this a pure function (no elementFromPoint, no window access) makes
|
||||
* it unit-testable in a plain Node environment.
|
||||
*/
|
||||
export function resolveNearestHfElement(
|
||||
el: Element | null,
|
||||
isVisible: (el: Element) => boolean,
|
||||
): ElementAtPointResult | null {
|
||||
let node = el;
|
||||
while (node !== null) {
|
||||
const id = node.getAttribute("data-hf-id");
|
||||
if (id !== null) {
|
||||
if (node.hasAttribute("data-hf-root")) return null;
|
||||
if (!isVisible(node)) return null;
|
||||
return { id, tag: node.tagName.toLowerCase() };
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Visibility check ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true when no element in the ancestor chain (inclusive) has
|
||||
* computed opacity === 0. Checks ancestors because a parent at opacity:0
|
||||
* makes the child invisible even if the child's own opacity is 1.
|
||||
*
|
||||
* This reflects the current GSAP timeline state (whatever the player has
|
||||
* seeked to). For atTime values matching the live playhead this is always
|
||||
* accurate. For speculative times this is NOT seeked — WS-A1 does not mutate
|
||||
* the timeline; accurate out-of-band opacity queries are WS-G follow-on.
|
||||
*/
|
||||
function isOpacityVisible(el: Element, win: Window & typeof globalThis): boolean {
|
||||
let node: Element | null = el;
|
||||
while (node !== null) {
|
||||
const style = win.getComputedStyle(node);
|
||||
if (parseFloat(style.opacity) === 0) return false;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── IframePreviewAdapter ─────────────────────────────────────────────────────
|
||||
|
||||
type SelectionHandler = (ids: string[]) => void;
|
||||
|
||||
class IframePreviewAdapter implements PreviewAdapter {
|
||||
private readonly iframe: HTMLIFrameElement;
|
||||
private _selection: string[] = [];
|
||||
private _handlers: SelectionHandler[] = [];
|
||||
|
||||
constructor(iframe: HTMLIFrameElement) {
|
||||
this.iframe = iframe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous hit-test. Returns the nearest `[data-hf-id]` element under
|
||||
* (x, y) in the iframe's coordinate space, or null for a transparent hit
|
||||
* (root, opacity-0, or nothing at all).
|
||||
*
|
||||
* atTime: reflects the GSAP state at the playhead when this is called.
|
||||
* Seeking to a different time to check visibility is WS-G scope.
|
||||
*/
|
||||
elementAtPoint(x: number, y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
|
||||
const doc = this.iframe.contentDocument;
|
||||
if (!doc) return null;
|
||||
const win = this.iframe.contentWindow as (Window & typeof globalThis) | null;
|
||||
if (!win) return null;
|
||||
|
||||
const hit = doc.elementFromPoint(x, y);
|
||||
return resolveNearestHfElement(hit, (el) => isOpacityVisible(el, win));
|
||||
}
|
||||
|
||||
// WS-A2 stubs — commitPreview / applyDraft derive the moveElement op --------
|
||||
|
||||
applyDraft(_id: string, _props: DraftProps): void {}
|
||||
|
||||
commitPreview(): void {}
|
||||
|
||||
cancelPreview(): void {}
|
||||
|
||||
// Selection -----------------------------------------------------------------
|
||||
|
||||
select(ids: string[], opts?: { additive?: boolean }): void {
|
||||
if (opts?.additive) {
|
||||
const merged = new Set([...this._selection, ...ids]);
|
||||
this._selection = [...merged];
|
||||
} else {
|
||||
this._selection = [...ids];
|
||||
}
|
||||
this._emit();
|
||||
}
|
||||
|
||||
on(event: "selection", handler: SelectionHandler): () => void {
|
||||
if (event !== "selection") return () => {};
|
||||
this._handlers.push(handler);
|
||||
return () => {
|
||||
this._handlers = this._handlers.filter((h) => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
private _emit(): void {
|
||||
const ids = [...this._selection];
|
||||
for (const h of this._handlers) h(ids);
|
||||
}
|
||||
}
|
||||
|
||||
export function createIframePreviewAdapter(iframe: HTMLIFrameElement): PreviewAdapter {
|
||||
return new IframePreviewAdapter(iframe);
|
||||
}
|
||||
Reference in New Issue
Block a user