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:
Vance Ingalls
2026-06-17 16:44:56 -07:00
committed by GitHub
co-authored by Miguel Ángel Claude Sonnet 4.6
parent 377b0368bd
commit b96e8a3072
3 changed files with 306 additions and 0 deletions
+133
View File
@@ -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);
}