Expand render graph playground editing
Amp-Thread-ID: https://ampcode.com/threads/T-01a033d4-0e44-708e-af0e-6166283416ff Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
@@ -6,6 +6,8 @@ fxnode creates one module worker per root and never creates a resize observer, m
|
||||
|
||||
Use root methods for composition, state, persistence, subscriptions, and context-free commands. Use view methods for input, viewport changes, selection actions, resource responses, and render checkpoints. A canvas can have only one live view. On teardown, remove application listeners and observers first, detach each view, then destroy the root.
|
||||
|
||||
`view.getHostSnapshot().selection.nodeIds` exposes the worker-authoritative selected node IDs for application-owned inspectors and editors. Subscribe with `view.subscribeHost()` when those controls need to follow selection changes.
|
||||
|
||||
The repository example host defaults to `lifecycle: "explicit"`, so `host.destroy()` removes only host-owned policy
|
||||
and never detaches its view. Opt in with `lifecycle: "detach-on-disconnect"` for component-style examples. That mode
|
||||
requires an initially connected canvas and `MutationObserver`; hosts share one observer per document. A removal is
|
||||
|
||||
Vendored
+12
-2
@@ -206,7 +206,13 @@ type RpcRequest = WorkerRequest extends infer R
|
||||
: never;
|
||||
const STARTUP_TIMEOUT_MS = 5_000;
|
||||
const emptySelection = (): FxNodeSelectionSnapshot =>
|
||||
Object.freeze({ nodeCount: 0, linkCount: 0, canRemove: false, mute: Object.freeze({ enabled: false as const }) });
|
||||
Object.freeze({
|
||||
nodeIds: Object.freeze([]),
|
||||
nodeCount: 0,
|
||||
linkCount: 0,
|
||||
canRemove: false,
|
||||
mute: Object.freeze({ enabled: false as const }),
|
||||
});
|
||||
|
||||
const reservedCanvases = new WeakSet<HTMLCanvasElement>();
|
||||
|
||||
@@ -750,7 +756,11 @@ class FxNodeViewClient implements FxNodeView {
|
||||
if (this.detachedError) return;
|
||||
if (data.type === "view.frame") this.consumeFrame(data);
|
||||
else if (data.type === "view.selection.host") {
|
||||
this.selection = Object.freeze({ ...data.projection, mute: Object.freeze({ ...data.projection.mute }) });
|
||||
this.selection = Object.freeze({
|
||||
...data.projection,
|
||||
nodeIds: Object.freeze([...data.projection.nodeIds]),
|
||||
mute: Object.freeze({ ...data.projection.mute }),
|
||||
});
|
||||
this.selectionReady();
|
||||
this.publishHost(this.hostSnapshot.colorPickerOpen);
|
||||
} else if (data.type === "view.node-menu.result") {
|
||||
|
||||
+2
@@ -53,6 +53,8 @@ export interface FxNodeActionOptions {
|
||||
readonly expectedVersion?: number;
|
||||
}
|
||||
export interface FxNodeSelectionSnapshot {
|
||||
/** Worker-authoritative IDs of the selected nodes in this view. */
|
||||
readonly nodeIds: readonly string[];
|
||||
readonly nodeCount: number;
|
||||
readonly linkCount: number;
|
||||
readonly canRemove: boolean;
|
||||
|
||||
+7
-2
@@ -11,7 +11,7 @@ import { validCommand } from "../commands/validate.js";
|
||||
import type { FxNodeCamera, FxNodeSelectionSnapshot, FxNodeViewport } from "./host-types.js";
|
||||
import { fxNodeDevicePixels, FXNODE_VIEW_LIMITS } from "./view-limits.js";
|
||||
|
||||
export const PROTOCOL_VERSION = 3 as const;
|
||||
export const PROTOCOL_VERSION = 4 as const;
|
||||
export type ViewId = string;
|
||||
export type Camera = FxNodeCamera;
|
||||
export type VersionExpectation = { readonly kind: "current" } | { readonly kind: "exact"; readonly version: number };
|
||||
@@ -818,8 +818,13 @@ function validHostSnapshot(v: unknown): v is HostInteractionSnapshot {
|
||||
function validSelectionProjection(v: unknown): v is FxNodeSelectionSnapshot {
|
||||
if (
|
||||
!record(v) ||
|
||||
!exact(v, ["nodeCount", "linkCount", "canRemove", "mute"]) ||
|
||||
!exact(v, ["nodeIds", "nodeCount", "linkCount", "canRemove", "mute"]) ||
|
||||
!Array.isArray(v.nodeIds) ||
|
||||
v.nodeIds.length > 1024 ||
|
||||
v.nodeIds.some((id) => typeof id !== "string" || id.length === 0 || id.length > 512) ||
|
||||
new Set(v.nodeIds).size !== v.nodeIds.length ||
|
||||
!nonnegativeSafeInteger(v.nodeCount) ||
|
||||
v.nodeCount !== v.nodeIds.length ||
|
||||
!nonnegativeSafeInteger(v.linkCount) ||
|
||||
v.canRemove !== (v.nodeCount > 0 || v.linkCount > 0) ||
|
||||
!record(v.mute)
|
||||
|
||||
+10
-5
@@ -483,9 +483,8 @@ function isStandardNode(id: import("../core/types.js").NodeId): boolean {
|
||||
}
|
||||
function publishSelection(view: WorkerView, force = false): void {
|
||||
if (!state || !application) return;
|
||||
const nodes = [...view.session.selectedNodes]
|
||||
.map((id) => state!.document.nodes[id])
|
||||
.filter((node): node is NonNullable<typeof node> => !!node),
|
||||
const nodeIds = [...view.session.selectedNodes].filter((id) => !!state!.document.nodes[id]),
|
||||
nodes = nodeIds.map((id) => state!.document.nodes[id]).filter((node): node is NonNullable<typeof node> => !!node),
|
||||
linkCount = [...view.session.selectedLinks].filter((id) => state!.document.links[id]).length,
|
||||
eligible = nodes.filter((node) => isStandardNode(node.id)),
|
||||
mute = eligible.length
|
||||
@@ -498,8 +497,14 @@ function publishSelection(view: WorkerView, force = false): void {
|
||||
: "mixed") as "all-muted" | "all-unmuted" | "mixed",
|
||||
}
|
||||
: { enabled: false as const },
|
||||
projection = { nodeCount: nodes.length, linkCount, canRemove: nodes.length + linkCount > 0, mute },
|
||||
signature = `${projection.nodeCount}|${projection.linkCount}|${+projection.canRemove}|${mute.enabled ? mute.state : "disabled"}`;
|
||||
projection = {
|
||||
nodeIds,
|
||||
nodeCount: nodes.length,
|
||||
linkCount,
|
||||
canRemove: nodes.length + linkCount > 0,
|
||||
mute,
|
||||
},
|
||||
signature = `${JSON.stringify(nodeIds)}|${projection.linkCount}|${+projection.canRemove}|${mute.enabled ? mute.state : "disabled"}`;
|
||||
if (!force && signature === view.selectionSignature) return;
|
||||
view.selectionSignature = signature;
|
||||
post({ protocol: PROTOCOL_VERSION, type: "view.selection.host", viewId: view.id, projection });
|
||||
|
||||
+13
-6
@@ -25,10 +25,16 @@ test("canvas-free authority owns independent attachable views", async ({ page })
|
||||
else if (message.type === "view.attach") {
|
||||
// Exercise the valid event-before-acknowledgement ordering.
|
||||
this.emit({
|
||||
protocol: 3,
|
||||
protocol: 4,
|
||||
type: "view.selection.host",
|
||||
viewId: message.viewId,
|
||||
projection: { nodeCount: 0, linkCount: 0, canRemove: false, mute: { enabled: false } },
|
||||
projection: {
|
||||
nodeIds: [],
|
||||
nodeCount: 0,
|
||||
linkCount: 0,
|
||||
canRemove: false,
|
||||
mute: { enabled: false },
|
||||
},
|
||||
});
|
||||
this.respond(message.id);
|
||||
} else if (message.type === "view.detach") this.respond(message.id);
|
||||
@@ -50,7 +56,7 @@ test("canvas-free authority owns independent attachable views", async ({ page })
|
||||
settleViewport(ok: boolean): void {
|
||||
const id = this.heldViewportIds.shift()!;
|
||||
this.emit({
|
||||
protocol: 3,
|
||||
protocol: 4,
|
||||
type: "response",
|
||||
id,
|
||||
ok,
|
||||
@@ -60,7 +66,7 @@ test("canvas-free authority owns independent attachable views", async ({ page })
|
||||
|
||||
private respond(id: string, value?: unknown): void {
|
||||
this.emit({
|
||||
protocol: 3,
|
||||
protocol: 4,
|
||||
type: "response",
|
||||
id,
|
||||
ok: true,
|
||||
@@ -100,10 +106,11 @@ test("canvas-free authority owns independent attachable views", async ({ page })
|
||||
duplicateCode = (error as { code?: string }).code ?? "";
|
||||
}
|
||||
worker.emit({
|
||||
protocol: 3,
|
||||
protocol: 4,
|
||||
type: "view.selection.host",
|
||||
viewId: first.id,
|
||||
projection: {
|
||||
nodeIds: ["selected-node"],
|
||||
nodeCount: 1,
|
||||
linkCount: 0,
|
||||
canRemove: true,
|
||||
@@ -171,7 +178,7 @@ test("canvas-free authority owns independent attachable views", async ({ page })
|
||||
modifiers,
|
||||
});
|
||||
worker.emit({
|
||||
protocol: 3,
|
||||
protocol: 4,
|
||||
type: "view.resource.open",
|
||||
viewId: first.id,
|
||||
requestId: resourceOpenRequestId,
|
||||
|
||||
+13
-2
@@ -7,7 +7,11 @@ test("host snapshots are stable immutable projections with isolated subscription
|
||||
view = window.view,
|
||||
first = view.getHostSnapshot(),
|
||||
same = first === view.getHostSnapshot(),
|
||||
frozen = Object.isFrozen(first) && Object.isFrozen(first.selection) && Object.isFrozen(first.selection.mute);
|
||||
frozen =
|
||||
Object.isFrozen(first) &&
|
||||
Object.isFrozen(first.selection) &&
|
||||
Object.isFrozen(first.selection.nodeIds) &&
|
||||
Object.isFrozen(first.selection.mute);
|
||||
let good = 0,
|
||||
bad = 0;
|
||||
const offBad = view.subscribeHost(() => {
|
||||
@@ -172,6 +176,7 @@ test("explicit node and selection actions preserve worker authority and structur
|
||||
expect(result.duplicate).toBe("node.duplicate");
|
||||
expect(result.added).toEqual({ status: "committed", version: 2 });
|
||||
expect(result.selected).toEqual({
|
||||
nodeIds: ["supplied-id"],
|
||||
nodeCount: 1,
|
||||
linkCount: 0,
|
||||
canRemove: true,
|
||||
@@ -182,7 +187,13 @@ test("explicit node and selection actions preserve worker authority and structur
|
||||
expect(result.mutedSelection.mute).toEqual({ enabled: true, state: "all-muted" });
|
||||
expect(result.removed).toEqual({ status: "committed", version: 4 });
|
||||
expect(result.remaining).toBe(0);
|
||||
expect(result.empty).toEqual({ nodeCount: 0, linkCount: 0, canRemove: false, mute: { enabled: false } });
|
||||
expect(result.empty).toEqual({
|
||||
nodeIds: [],
|
||||
nodeCount: 0,
|
||||
linkCount: 0,
|
||||
canRemove: false,
|
||||
mute: { enabled: false },
|
||||
});
|
||||
expect(result.notifications).toBe(3);
|
||||
});
|
||||
test("dedicated worker renders, commits FIFO, fans out and destroys", async ({ page }) => {
|
||||
|
||||
+7
-1
@@ -336,7 +336,13 @@ test("selection publication does not wait for frame consumption", async ({ page
|
||||
expect(result).toEqual({
|
||||
receipt: { status: "committed", version: 2 },
|
||||
changed: true,
|
||||
selection: { nodeCount: 1, linkCount: 0, canRemove: true, mute: { enabled: true, state: "all-unmuted" } },
|
||||
selection: {
|
||||
nodeIds: ["blocked-frame"],
|
||||
nodeCount: 1,
|
||||
linkCount: 0,
|
||||
canRemove: true,
|
||||
mute: { enabled: true, state: "all-unmuted" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -334,6 +334,7 @@ test("worker resource requests close delayed add-menu UI without intercepting in
|
||||
compositionRevision: 0,
|
||||
colorPickerOpen: false,
|
||||
selection: Object.freeze({
|
||||
nodeIds: Object.freeze([]),
|
||||
nodeCount: 0,
|
||||
linkCount: 0,
|
||||
canRemove: false,
|
||||
@@ -424,6 +425,7 @@ test("default resource picker ignores obsolete asynchronous reads", async ({ pag
|
||||
compositionRevision: 0,
|
||||
colorPickerOpen: false,
|
||||
selection: Object.freeze({
|
||||
nodeIds: Object.freeze([]),
|
||||
nodeCount: 0,
|
||||
linkCount: 0,
|
||||
canRemove: false,
|
||||
|
||||
+1
-1
@@ -399,7 +399,7 @@ test("live composition instance API keeps one worker-authoritative revisioned ha
|
||||
};
|
||||
worker.addEventListener("message", listener);
|
||||
worker.postMessage({
|
||||
protocol: 3,
|
||||
protocol: 4,
|
||||
type: "composition.update",
|
||||
id,
|
||||
expected: { kind: "exact", revision: -1 },
|
||||
|
||||
Vendored
+15
-1
@@ -599,12 +599,26 @@ test("action requests, selection projections, menu results, and receipts are exa
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.selection.host",
|
||||
viewId,
|
||||
projection: { nodeCount, linkCount, canRemove, mute: { enabled: false } },
|
||||
projection: {
|
||||
nodeIds: Array.from({ length: nodeCount }, (_, index) => `node-${index}`),
|
||||
nodeCount,
|
||||
linkCount,
|
||||
canRemove,
|
||||
mute: { enabled: false },
|
||||
},
|
||||
});
|
||||
assert.equal(validWorkerMessage(selection(0, 0, false)), true);
|
||||
assert.equal(validWorkerMessage(selection(1, 0, true)), true);
|
||||
assert.equal(validWorkerMessage(selection(0, 0, true)), false);
|
||||
assert.equal(validWorkerMessage(selection(1, 0, false)), false);
|
||||
const mismatchedSelection = selection(1, 0, true);
|
||||
assert.equal(
|
||||
validWorkerMessage({
|
||||
...mismatchedSelection,
|
||||
projection: { ...mismatchedSelection.projection, nodeCount: 0 },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
validWorkerMessage({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
|
||||
Reference in New Issue
Block a user