mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(sdk): ws-3 — reorderElements op (batch z-index update) (#1502)
* feat(sdk): ws-3 — reorderElements op (batch z-index update) Adds the reorderElements EditOp: each entry sets inline zIndex on one element. Last-write-wins per target so a duplicated target collapses to a single zIndex patch. Positioning is unchanged — z-index only takes effect on non-static elements, so the caller must ensure the target is positioned. Also fixes single-dispatch undo to reverse the inverse patch list (parity with batch()): an op emitting multiple patches whose undo order matters — a duplicated reorderElements target, an aliased multi-target, or a nested parent+child removeElement — must undo in reverse application order, or undo lands on an intermediate value / drops a subtree. validateOp resolves every entry target (E_TARGET_NOT_FOUND for unknown ids; empty entries is a clean no-op). Tests cover set/inverse/validate/duplicate-target. Rebuilt standalone on main (reorderElements only depends on handleSetStyle). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sdk): remove unused HTTP persist adapter The HTTP PersistAdapter (createHttpAdapter) was dead weight after the studio cutover went single-writer (ws-4): Studio's writeProjectFile is the sole writer and useSdkSession opens with no persist queue, so the adapter's write/flush/ listVersions/loadFrom were never used — only read() was, to fetch the composition source. Replace those two read() calls with a direct optional fetch (GET /files/<path>?optional=1) and drop the adapter + its export-map entries. Saved for later re-introduction (when a non-Studio SDK host needs server-backed persist) at docs/hyperframes/plans/sdk-http-adapter/ (outside the repo). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(studio): resolver shadow for z-index reorder targets The z-index reorder commit takes the server path (no SDK persist), but the resolver-shadow tripwire is decoupled from cutover — so it should still record whether the SDK resolves each reordered element (reorderElements' targets), the same as timing/delete already do before their cutover gate. This gives wild resolver-parity telemetry on z-index targets before z-index reorder is cut over. Threads an onReorderShadow callback (sdkSession-bound, mirrors onTrySdkDelete) from useDomEditSession → useDomEditCommits → useElementLifecycleOps, called with the reordered elements' hf-ids in handleDomZIndexReorderCommit. Read-only, divergence-only, never throws — same contract as recordResolverParity elsewhere. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(studio): guard project-file read path against traversal (CodeQL CSRF) readProjectFileOptional interpolated a user-influenced composition path into the fetch URL, which CodeQL flagged as client-side request forgery. Reject NUL/`..` up front (mirrors the existing guard in timelineEditingHelpers) and encodeURIComponent the projectId too, so both values stay confined to single segments of the same-origin URL. Unsafe path → undefined (graceful for the optional read). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
de87f3932e
commit
28bfe09f21
@@ -1,313 +0,0 @@
|
||||
/**
|
||||
* Unit tests for createHttpAdapter.
|
||||
*
|
||||
* Mocks global `fetch` to verify URL construction, method/headers, error routing,
|
||||
* and flush semantics without a real server.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createHttpAdapter } from "./http.js";
|
||||
|
||||
const BASE = "/api/projects/proj-abc";
|
||||
|
||||
// ── fetch mock helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function stubFetch(
|
||||
handler: (url: string, init?: RequestInit) => { ok: boolean; status?: number; body?: unknown },
|
||||
): ReturnType<typeof vi.fn> {
|
||||
const mock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const r = handler(url, init);
|
||||
return {
|
||||
ok: r.ok,
|
||||
status: r.status ?? (r.ok ? 200 : 500),
|
||||
json: async () => r.body ?? {},
|
||||
};
|
||||
});
|
||||
vi.stubGlobal("fetch", mock);
|
||||
return mock;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubFetch(() => ({ ok: true, body: { content: "" } }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── read() ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("read()", () => {
|
||||
it("fetches the correct URL with ?optional=1", async () => {
|
||||
const mock = stubFetch(() => ({ ok: true, body: { content: "<html/>" } }));
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
await adapter.read("comp.html");
|
||||
expect(mock).toHaveBeenCalledWith(
|
||||
`${BASE}/files/${encodeURIComponent("comp.html")}?optional=1`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns content on success", async () => {
|
||||
stubFetch(() => ({ ok: true, body: { content: "<html>hello</html>" } }));
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
expect(await adapter.read("comp.html")).toBe("<html>hello</html>");
|
||||
});
|
||||
|
||||
it("returns undefined when response body lacks content field", async () => {
|
||||
stubFetch(() => ({ ok: true, body: {} }));
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
expect(await adapter.read("missing.html")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined on non-ok response", async () => {
|
||||
stubFetch(() => ({ ok: false, status: 404 }));
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
expect(await adapter.read("gone.html")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── write() ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("write()", () => {
|
||||
it("PUTs to the correct URL with text/plain body", async () => {
|
||||
const mock = stubFetch(() => ({ ok: true }));
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
await adapter.write("comp.html", "<html>new</html>");
|
||||
expect(mock).toHaveBeenCalledWith(
|
||||
`${BASE}/files/${encodeURIComponent("comp.html")}`,
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: expect.objectContaining({ "Content-Type": "text/plain" }),
|
||||
body: "<html>new</html>",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fires persist:error on non-ok response without throwing", async () => {
|
||||
stubFetch(() => ({ ok: false, status: 503 }));
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
const onError = vi.fn();
|
||||
adapter.on("persist:error", onError);
|
||||
await expect(adapter.write("comp.html", "x")).resolves.toBeUndefined();
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.objectContaining({ message: "HTTP 503" }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fires persist:error on network error without throwing", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("network down")));
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
const onError = vi.fn();
|
||||
adapter.on("persist:error", onError);
|
||||
await expect(adapter.write("comp.html", "x")).resolves.toBeUndefined();
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ message: expect.stringContaining("network down") }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fire persist:error on success", async () => {
|
||||
stubFetch(() => ({ ok: true }));
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
const onError = vi.fn();
|
||||
adapter.on("persist:error", onError);
|
||||
await adapter.write("comp.html", "x");
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── headers option ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("headers option", () => {
|
||||
it("merges static headers into every PUT request", async () => {
|
||||
const mock = stubFetch(() => ({ ok: true }));
|
||||
const adapter = createHttpAdapter({
|
||||
projectFilesUrl: BASE,
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
});
|
||||
await adapter.write("comp.html", "x");
|
||||
expect(mock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "Bearer tok" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls a headers function lazily on each write", async () => {
|
||||
const mock = stubFetch(() => ({ ok: true }));
|
||||
let n = 0;
|
||||
const adapter = createHttpAdapter({
|
||||
projectFilesUrl: BASE,
|
||||
headers: () => ({ Authorization: `Bearer tok${++n}` }),
|
||||
});
|
||||
await adapter.write("comp.html", "a");
|
||||
await adapter.write("comp.html", "b");
|
||||
const calls = mock.mock.calls.filter((c) => c[1]?.method === "PUT");
|
||||
expect((calls[0][1]?.headers as Record<string, string>)?.["Authorization"]).toBe("Bearer tok1");
|
||||
expect((calls[1][1]?.headers as Record<string, string>)?.["Authorization"]).toBe("Bearer tok2");
|
||||
});
|
||||
});
|
||||
|
||||
// ── flush() ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("flush()", () => {
|
||||
it("resolves immediately when no writes are in flight", async () => {
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
await expect(adapter.flush()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("waits for an in-flight write before resolving", async () => {
|
||||
let resolveFetch!: () => void;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
await new Promise<void>((r) => {
|
||||
resolveFetch = r;
|
||||
});
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
}),
|
||||
);
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
void adapter.write("comp.html", "x"); // intentionally not awaited
|
||||
await Promise.resolve(); // let path-queue microtask fire so doWrite starts
|
||||
let flushed = false;
|
||||
const flushDone = adapter.flush().then(() => {
|
||||
flushed = true;
|
||||
});
|
||||
expect(flushed).toBe(false);
|
||||
resolveFetch();
|
||||
await flushDone;
|
||||
expect(flushed).toBe(true);
|
||||
});
|
||||
|
||||
it("waits for two concurrent in-flight writes before resolving", async () => {
|
||||
const resolvers: Array<() => void> = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
await new Promise<void>((r) => resolvers.push(r));
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
}),
|
||||
);
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
void adapter.write("a.html", "1");
|
||||
void adapter.write("b.html", "2");
|
||||
await Promise.resolve(); // let both start
|
||||
await Promise.resolve();
|
||||
let flushed = false;
|
||||
const flushDone = adapter.flush().then(() => {
|
||||
flushed = true;
|
||||
});
|
||||
expect(flushed).toBe(false);
|
||||
resolvers[0]();
|
||||
await Promise.resolve();
|
||||
expect(flushed).toBe(false); // still waiting for second write
|
||||
resolvers[1]();
|
||||
await flushDone;
|
||||
expect(flushed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── listVersions() / loadFrom() ───────────────────────────────────────────────
|
||||
|
||||
describe("listVersions()", () => {
|
||||
it("returns empty array (server versioning not exposed by this adapter)", async () => {
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
expect(await adapter.listVersions("comp.html")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadFrom()", () => {
|
||||
it("returns undefined (server versioning not exposed by this adapter)", async () => {
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
expect(await adapter.loadFrom("comp.html", "v1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── write() — per-path serialization ─────────────────────────────────────────
|
||||
|
||||
describe("write() — per-path serialization", () => {
|
||||
it("serializes concurrent writes to the same path (second waits for first)", async () => {
|
||||
const starts: number[] = [];
|
||||
let resolveFirst!: () => void;
|
||||
let callCount = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
const n = ++callCount;
|
||||
starts.push(n);
|
||||
if (n === 1) await new Promise<void>((r) => (resolveFirst = r));
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
}),
|
||||
);
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
const write1 = adapter.write("comp.html", "v1");
|
||||
await Promise.resolve(); // let write1 start
|
||||
const write2 = adapter.write("comp.html", "v2");
|
||||
await Promise.resolve(); // let write2 attempt to start
|
||||
expect(starts).toEqual([1]); // write2 has NOT started yet
|
||||
resolveFirst();
|
||||
await write1;
|
||||
await write2;
|
||||
expect(starts).toEqual([1, 2]); // write2 started only after write1 finished
|
||||
});
|
||||
|
||||
it("does not block writes to different paths", async () => {
|
||||
const starts: string[] = [];
|
||||
let resolveFirst!: () => void;
|
||||
let callCount = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
const n = ++callCount;
|
||||
starts.push(`${n}:${url.split("/").pop()}`);
|
||||
if (n === 1) await new Promise<void>((r) => (resolveFirst = r));
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
}),
|
||||
);
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
const write1 = adapter.write("a.html", "v1");
|
||||
await Promise.resolve();
|
||||
void adapter.write("b.html", "v2"); // different path — must not wait for write1
|
||||
await Promise.resolve();
|
||||
expect(starts.length).toBe(2); // both started concurrently
|
||||
resolveFirst();
|
||||
await write1;
|
||||
});
|
||||
});
|
||||
|
||||
// ── on() / unsubscribe ────────────────────────────────────────────────────────
|
||||
|
||||
describe("on() / unsubscribe", () => {
|
||||
it("unsubscribe removes the listener", async () => {
|
||||
stubFetch(() => ({ ok: false, status: 500 }));
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
const onError = vi.fn();
|
||||
const unsub = adapter.on("persist:error", onError);
|
||||
unsub();
|
||||
await adapter.write("comp.html", "x");
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("multiple listeners all fire", async () => {
|
||||
stubFetch(() => ({ ok: false, status: 500 }));
|
||||
const adapter = createHttpAdapter({ projectFilesUrl: BASE });
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
adapter.on("persist:error", a);
|
||||
adapter.on("persist:error", b);
|
||||
await adapter.write("comp.html", "x");
|
||||
expect(a).toHaveBeenCalledOnce();
|
||||
expect(b).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import type { PersistAdapter, PersistVersionEntry } from "./types.js";
|
||||
import type { PersistErrorEvent } from "../types.js";
|
||||
|
||||
export interface HttpAdapterOptions {
|
||||
/**
|
||||
* Base URL for the project files REST API, no trailing slash.
|
||||
* E.g. "/api/projects/proj-abc"
|
||||
*/
|
||||
projectFilesUrl: string;
|
||||
/**
|
||||
* Extra headers to include on every PUT write request.
|
||||
* Pass a function to compute them lazily (e.g. to refresh a bearer token on each request).
|
||||
* Useful for cross-origin or CLI contexts where ambient cookies are not available.
|
||||
*/
|
||||
headers?: HeadersInit | (() => HeadersInit);
|
||||
}
|
||||
|
||||
class HttpAdapter implements PersistAdapter {
|
||||
private readonly baseUrl: string;
|
||||
private readonly extraHeaders?: HttpAdapterOptions["headers"];
|
||||
private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = [];
|
||||
private readonly inflightWrites = new Set<Promise<void>>();
|
||||
private readonly pathQueues = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(opts: HttpAdapterOptions) {
|
||||
this.baseUrl = opts.projectFilesUrl;
|
||||
this.extraHeaders = opts.headers;
|
||||
}
|
||||
|
||||
async read(path: string): Promise<string | undefined> {
|
||||
const url = `${this.baseUrl}/files/${encodeURIComponent(path)}?optional=1`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return undefined;
|
||||
const data = (await res.json()) as { content?: string };
|
||||
return typeof data.content === "string" ? data.content : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a write for path. Same-path writes are serialized via pathQueues
|
||||
* so concurrent saves never interleave. Each write is a single-shot PUT —
|
||||
* on network error or non-2xx response, persist:error fires and the write
|
||||
* is not retried. Retry is the caller's responsibility.
|
||||
*/
|
||||
async write(path: string, content: string): Promise<void> {
|
||||
const prev = this.pathQueues.get(path) ?? Promise.resolve();
|
||||
const p = prev.then(() => this.doWrite(path, content));
|
||||
this.pathQueues.set(
|
||||
path,
|
||||
p.catch(() => {}),
|
||||
);
|
||||
this.inflightWrites.add(p);
|
||||
try {
|
||||
await p;
|
||||
} finally {
|
||||
this.inflightWrites.delete(p);
|
||||
}
|
||||
}
|
||||
|
||||
private async doWrite(path: string, content: string): Promise<void> {
|
||||
const url = `${this.baseUrl}/files/${encodeURIComponent(path)}`;
|
||||
let res: Response;
|
||||
try {
|
||||
const extra =
|
||||
typeof this.extraHeaders === "function" ? this.extraHeaders() : this.extraHeaders;
|
||||
res = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "text/plain", ...extra },
|
||||
body: content,
|
||||
});
|
||||
} catch (err) {
|
||||
this.fireError(String(err), err);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
this.fireError(`HTTP ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await Promise.all([...this.inflightWrites]);
|
||||
}
|
||||
|
||||
/** Server-side versioning is not exposed by this adapter; returns [] intentionally. */
|
||||
async listVersions(_path: string): Promise<PersistVersionEntry[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Server-side versioning is not exposed by this adapter; returns undefined intentionally. */
|
||||
async loadFrom(_path: string, _versionKey: string): Promise<string | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void {
|
||||
if (event !== "persist:error") return () => {};
|
||||
this.errorListeners.push(handler);
|
||||
return () => {
|
||||
const idx = this.errorListeners.indexOf(handler);
|
||||
if (idx !== -1) this.errorListeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
private fireError(message: string, cause?: unknown): void {
|
||||
const error: PersistErrorEvent["error"] =
|
||||
cause !== undefined ? { message, cause } : { message };
|
||||
for (const l of this.errorListeners) l({ error });
|
||||
}
|
||||
}
|
||||
|
||||
export function createHttpAdapter(opts: HttpAdapterOptions): PersistAdapter {
|
||||
return new HttpAdapter(opts);
|
||||
}
|
||||
@@ -565,3 +565,69 @@ describe("setCompositionMetadata data-* channel", () => {
|
||||
expect(root?.getAttribute("style")).toContain("width: 1920px");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── reorderElements ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("reorderElements", () => {
|
||||
it("sets zIndex on each entry", () => {
|
||||
const parsed = fresh();
|
||||
applyOp(parsed, {
|
||||
type: "reorderElements",
|
||||
entries: [
|
||||
{ target: "hf-title", zIndex: 2 },
|
||||
{ target: "hf-logo", zIndex: 1 },
|
||||
],
|
||||
});
|
||||
const title = parsed.document.querySelector("[data-hf-id='hf-title']") as HTMLElement | null;
|
||||
const logo = parsed.document.querySelector("[data-hf-id='hf-logo']") as HTMLElement | null;
|
||||
expect(title?.style.zIndex).toBe("2");
|
||||
expect(logo?.style.zIndex).toBe("1");
|
||||
});
|
||||
|
||||
it("inverse restores original zIndex values", () => {
|
||||
const parsed = fresh();
|
||||
const before = serializeDocument(parsed);
|
||||
const { inverse } = applyOp(parsed, {
|
||||
type: "reorderElements",
|
||||
entries: [{ target: "hf-title", zIndex: 5 }],
|
||||
});
|
||||
applyPatchesToDocument(parsed, inverse);
|
||||
expect(serializeDocument(parsed)).toBe(before);
|
||||
});
|
||||
|
||||
it("validateOp returns ok:true for existing targets", () => {
|
||||
const r = validateOp(fresh(), {
|
||||
type: "reorderElements",
|
||||
entries: [{ target: "hf-title", zIndex: 1 }],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("validateOp returns E_TARGET_NOT_FOUND for unknown target", () => {
|
||||
const r = validateOp(fresh(), {
|
||||
type: "reorderElements",
|
||||
entries: [{ target: "hf-unknown", zIndex: 1 }],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.code).toBe("E_TARGET_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("duplicate target collapses to last-wins and inverse restores cleanly", () => {
|
||||
const parsed = fresh();
|
||||
const before = serializeDocument(parsed);
|
||||
const { forward, inverse } = applyOp(parsed, {
|
||||
type: "reorderElements",
|
||||
entries: [
|
||||
{ target: "hf-title", zIndex: 2 },
|
||||
{ target: "hf-title", zIndex: 9 },
|
||||
],
|
||||
});
|
||||
const title = parsed.document.querySelector("[data-hf-id='hf-title']") as HTMLElement | null;
|
||||
expect(title?.style.zIndex).toBe("9"); // last write wins
|
||||
expect(forward.length).toBe(1); // one patch, not two on the same path
|
||||
// Inverse must be applied in reverse order (session reverses single-dispatch
|
||||
// inverse) to land back on the original, not the intermediate "2".
|
||||
applyPatchesToDocument(parsed, [...inverse].reverse());
|
||||
expect(serializeDocument(parsed)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,6 +261,8 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
|
||||
return handleMoveElement(parsed, targets(op.target), op.x, op.y);
|
||||
case "removeElement":
|
||||
return handleRemoveElement(parsed, targets(op.target));
|
||||
case "reorderElements":
|
||||
return handleReorderElements(parsed, op.entries);
|
||||
case "setCompositionMetadata":
|
||||
return handleSetCompositionMetadata(parsed, op);
|
||||
case "setVariableValue":
|
||||
@@ -608,6 +610,23 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul
|
||||
return result;
|
||||
}
|
||||
|
||||
function handleReorderElements(
|
||||
parsed: ParsedDocument,
|
||||
entries: Array<{ target: HfId; zIndex: number }>,
|
||||
): MutationResult {
|
||||
const result: MutationResult = { forward: [], inverse: [] };
|
||||
// Last write wins per target — a duplicated target collapses to one zIndex
|
||||
// patch instead of emitting redundant same-path patches in one dispatch.
|
||||
const lastByTarget = new Map<HfId, number>();
|
||||
for (const { target, zIndex } of entries) lastByTarget.set(target, zIndex);
|
||||
for (const [target, zIndex] of lastByTarget) {
|
||||
const sub = handleSetStyle(parsed, [target], { zIndex: String(zIndex) });
|
||||
result.forward.push(...sub.forward);
|
||||
result.inverse.push(...sub.inverse);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function handleSetCompositionMetadata(
|
||||
parsed: ParsedDocument,
|
||||
@@ -1189,6 +1208,19 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
||||
);
|
||||
return CAN_OK;
|
||||
}
|
||||
case "reorderElements": {
|
||||
if (op.entries.length === 0) return CAN_OK;
|
||||
const missing = op.entries
|
||||
.map((e) => e.target)
|
||||
.filter((id) => resolveScoped(parsed.document, id) === null);
|
||||
if (missing.length > 0)
|
||||
return canErr(
|
||||
"E_TARGET_NOT_FOUND",
|
||||
`Element(s) not found: ${missing.join(", ")}.`,
|
||||
"Verify the id against comp.getElements() or comp.find().",
|
||||
);
|
||||
return CAN_OK;
|
||||
}
|
||||
case "setVariableValue":
|
||||
if (findRoot(parsed.document) === null)
|
||||
return canErr("E_NO_ROOT", "Composition root element not found.");
|
||||
|
||||
@@ -37,6 +37,4 @@ export type { PersistAdapter, PreviewAdapter, PersistVersionEntry } from "./adap
|
||||
// Concrete adapter factories (browser-safe — Node-only fs adapter: @hyperframes/sdk/adapters/fs).
|
||||
export { createMemoryAdapter } from "./adapters/memory.js";
|
||||
export { createHeadlessAdapter } from "./adapters/headless.js";
|
||||
export { createHttpAdapter } from "./adapters/http.js";
|
||||
export type { HttpAdapterOptions } from "./adapters/http.js";
|
||||
export { createIframePreviewAdapter, resolveNearestHfElement } from "./adapters/iframe.js";
|
||||
|
||||
@@ -82,6 +82,11 @@ export type EditOp =
|
||||
| { type: "setHold"; target: HfId | HfId[]; hold: ElasticHold }
|
||||
| { type: "moveElement"; target: HfId | HfId[]; x: number; y: number }
|
||||
| { type: "removeElement"; target: HfId | HfId[] }
|
||||
| {
|
||||
type: "reorderElements";
|
||||
/** Each entry sets inline zIndex on one element. Positioning is unchanged — z-index only takes effect on non-static elements, so the caller must ensure the target is positioned. */
|
||||
entries: Array<{ target: HfId; zIndex: number }>;
|
||||
}
|
||||
| { type: "setClassStyle"; selector: string; styles: Record<string, string | null> }
|
||||
| { type: "setCompositionMetadata"; width?: number; height?: number; duration?: number }
|
||||
| { type: "setVariableValue"; id: string; value: string | number | boolean }
|
||||
|
||||
Reference in New Issue
Block a user