fix(sdk): bridge preview selection into session state (#1362)

This commit is contained in:
Kiyeon Jeon
2026-06-12 18:25:41 -07:00
committed by GitHub
parent b952dc9ce0
commit 84a56986c6
2 changed files with 95 additions and 0 deletions
+83
View File
@@ -4,6 +4,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { openComposition } from "./session.js"; import { openComposition } from "./session.js";
import type { DraftProps, ElementAtPointResult, PreviewAdapter } from "./adapters/types.js";
const BASE_HTML = ` const BASE_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5"> <div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
@@ -13,6 +14,88 @@ const BASE_HTML = `
</div> </div>
`.trim(); `.trim();
class TestPreviewAdapter implements PreviewAdapter {
private selectionHandlers: Array<(ids: string[]) => void> = [];
elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
return null;
}
applyDraft(_id: string, _props: DraftProps): void {
// Test adapter tracks selection only.
}
commitPreview(): void {
// Test adapter tracks selection only.
}
cancelPreview(): void {
// Test adapter tracks selection only.
}
select(ids: string[], _opts?: { additive?: boolean }): void {
this.emitSelection(ids);
}
on(_event: "selection", handler: (ids: string[]) => void): () => void {
this.selectionHandlers.push(handler);
return () => {
this.selectionHandlers = this.selectionHandlers.filter((h) => h !== handler);
};
}
emitSelection(ids: readonly string[]): void {
const snapshot = [...ids];
for (const handler of this.selectionHandlers) {
handler([...snapshot]);
}
}
listenerCount(): number {
return this.selectionHandlers.length;
}
}
// ─── Preview selection bridge ────────────────────────────────────────────────
describe("preview selection bridge", () => {
it("mirrors preview selection into session state and notifies subscribers", async () => {
const preview = new TestPreviewAdapter();
const comp = await openComposition(BASE_HTML, { preview });
const events: string[][] = [];
comp.on("selectionchange", (ids) => events.push([...ids]));
preview.select(["hf-title"]);
expect(comp.getSelection()).toEqual(["hf-title"]);
expect(comp.selection().ids).toEqual(["hf-title"]);
expect(events).toEqual([["hf-title"]]);
});
it("selection proxy applies edits to ids selected by the preview", async () => {
const preview = new TestPreviewAdapter();
const comp = await openComposition(BASE_HTML, { preview });
preview.select(["hf-title", "hf-sub"]);
comp.selection().setStyle({ color: "#123456" });
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#123456");
expect(comp.getElement("hf-sub")?.inlineStyles["color"]).toBe("#123456");
});
it("dispose unsubscribes from preview selection events", async () => {
const preview = new TestPreviewAdapter();
const comp = await openComposition(BASE_HTML, { preview });
expect(preview.listenerCount()).toBe(1);
comp.dispose();
expect(preview.listenerCount()).toBe(0);
preview.select(["hf-title"]);
expect(comp.getSelection()).toEqual([]);
});
});
// ─── History coalescing ─────────────────────────────────────────────────────── // ─── History coalescing ───────────────────────────────────────────────────────
describe("history coalescing", () => { describe("history coalescing", () => {
+12
View File
@@ -66,6 +66,7 @@ class CompositionImpl implements Composition {
private selectionHandlers: Array<(ids: string[]) => void> = []; private selectionHandlers: Array<(ids: string[]) => void> = [];
private patchHandlers: Array<(e: PatchEvent) => void> = []; private patchHandlers: Array<(e: PatchEvent) => void> = [];
private errorHandlers: Array<(e: PersistErrorEvent) => void> = []; private errorHandlers: Array<(e: PersistErrorEvent) => void> = [];
private previewSelectionUnsubscribe: (() => void) | null = null;
/** Attached by openComposition() for standalone mode. */ /** Attached by openComposition() for standalone mode. */
private historyModule: HistoryModule | null = null; private historyModule: HistoryModule | null = null;
@@ -85,6 +86,8 @@ class CompositionImpl implements Composition {
this.persist = opts.persist; this.persist = opts.persist;
this.preview = opts.preview; this.preview = opts.preview;
this.overrides = { ...(opts.overrides ?? {}) }; this.overrides = { ...(opts.overrides ?? {}) };
this.previewSelectionUnsubscribe =
this.preview?.on("selection", (ids) => this.updateSelection(ids)) ?? null;
} }
attachHistory(module: HistoryModule): void { attachHistory(module: HistoryModule): void {
@@ -206,6 +209,13 @@ class CompositionImpl implements Composition {
return [...this.currentSelection]; return [...this.currentSelection];
} }
private updateSelection(ids: readonly string[]): void {
this.currentSelection = [...ids];
for (const handler of this.selectionHandlers) {
handler([...this.currentSelection]);
}
}
// ── Dispatch / batch ───────────────────────────────────────────────────────── // ── Dispatch / batch ─────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
@@ -396,6 +406,8 @@ class CompositionImpl implements Composition {
} }
dispose(): void { dispose(): void {
this.previewSelectionUnsubscribe?.();
this.previewSelectionUnsubscribe = null;
this.persistQueueModule?.dispose(); this.persistQueueModule?.dispose();
this.historyModule?.dispose(); this.historyModule?.dispose();
this.changeHandlers = []; this.changeHandlers = [];