///
import {
validRequest,
PROTOCOL_VERSION,
type CompositionChange,
type CompositionChangeEnvelope,
type CompositionReceipt,
type CompositionUpdateWire,
type Viewport,
type WorkerMessage,
type WorkerRequest,
} from "../browser/protocol.js";
import { commandId, linkId, nodeId } from "../core/types.js";
import {
createInitialFxNodeComposition,
compileFxNodeComposition,
FxNodeCompositionError,
} from "../composition/compile.js";
import { bindFxNodeHeadless } from "../headless-runtime.js";
import type { CompiledFxNodeComposition, FxNodeCompositionData } from "../composition/types.js";
import {
rebindBoundEngineAuthority,
type EngineState as BoundEngineState,
type LoadResult as BoundLoadResult,
type MutationEnvelope as BoundMutationEnvelope,
type ReplayResult as BoundReplayResult,
type SnapshotEnvelope as BoundSnapshotEnvelope,
type TransitionResult as BoundTransitionResult,
} from "../composition/bound-engine.js";
import { applyNodeOrder, layoutGraph } from "../layout/layout-graph.js";
import { IndexedLayoutStore } from "../layout/indexed-layout-store.js";
import { viewToWorld, worldToView } from "../layout/geometry.js";
import type { LayoutSnapshot } from "../layout/types.js";
import { layoutColorPicker } from "../layout/color-picker-layout.js";
import { renderCanvas } from "../render/canvas-renderer.js";
import { paintColorPicker } from "../render/color-picker-renderer.js";
import { DirtyReason, RenderScheduler, requestViewInvalidations } from "./render-scheduler.js";
import { createSession, resetSessionForCompositionRebind, resetSessionForGraphReplacement } from "./session.js";
import { cycleEnum, numericStep, scrubValue, setNumericComponent } from "./control-edit.js";
import {
boxNodes,
clampResize,
compatibleTargets,
frameDropCandidate,
groupRoots,
hitTest,
planLink,
zoomAt,
} from "./interaction.js";
import {
addRampMidpoint,
addRampStop,
distributeColorRamp,
flipColorRamp,
isColorRamp,
moveRampStop,
removeRampStop,
setRampColor,
type ColorRamp,
} from "../widgets/color-ramp.js";
import { appendKnifePoint, crossedLinks } from "./knife-path.js";
import { pointerLaneFence, readPointerMove } from "../browser/pointer-lane.js";
import type { PointerFence } from "../browser/protocol.js";
import { mapOklchToSrgb, maxSrgbChroma, oklabToOklch, srgbToOklab, type Rgba } from "../color/oklab.js";
import { canonicalJsonEqual, nullRecord } from "../core/json.js";
import { advanceJournal, checkpointJournal, importJournal, journalSaveData, type WorkerJournal } from "./journal.js";
import type { Command } from "../commands/types.js";
import { planSelectionMute, planSelectionRemoval } from "./selection-actions.js";
import { FXNODE_VIEW_LIMITS, fxNodeDevicePixels } from "../browser/view-limits.js";
import type { WorkerSession } from "./session.js";
import { ViewAtlasError, ViewAtlasManager } from "./view-atlas.js";
const scope = self as unknown as DedicatedWorkerGlobalScope;
let state: BoundEngineState | undefined;
let journal: WorkerJournal | undefined;
let application:
| {
compiled: CompiledFxNodeComposition;
runtime: ReturnType>;
}
| undefined;
let layoutStore: IndexedLayoutStore | undefined;
interface WorkerView {
readonly id: string;
readonly session: WorkerSession;
viewport: Viewport;
deviceWidth: number;
deviceHeight: number;
surfaceGeneration: number;
resizePending: boolean;
currentLayout: LayoutSnapshot | undefined;
gestureLayout: LayoutSnapshot | undefined;
readonly scheduler: RenderScheduler;
readonly pointerLane: SharedArrayBuffer | undefined;
handledPointerFence: number;
consumedPointerSequence: number;
hostGeneration: number;
selectionSignature: string;
inFlightPixels: { readonly frameId: number; readonly pixels: number } | undefined;
}
const views = new Map();
let inFlightDevicePixels = 0;
const atlas = new ViewAtlasManager(undefined, (error) => fatal(error, error.code));
const usedViewIds = new Set();
const resourceImages = new Map();
const resourceOperations = new Map();
let resourceSerial = 0;
let compositionRevision = 0;
const post = (message: WorkerMessage, transfer: Transferable[] = []): void => scope.postMessage(message, transfer);
function releaseFramePixels(view: WorkerView, frameId?: number): boolean {
const ticket = view.inFlightPixels;
if (!ticket || (frameId !== undefined && ticket.frameId !== frameId)) return false;
view.inFlightPixels = undefined;
inFlightDevicePixels -= ticket.pixels;
return true;
}
type WireIssue = { readonly code: string; readonly path: string; readonly message: string };
type WireError = {
readonly code: string;
readonly message: string;
readonly path?: string;
readonly issues?: readonly WireIssue[];
};
const bounded = (value: unknown, limit: number, fallback: string) => String(value ?? fallback).slice(0, limit);
function toWireIssues(value: unknown): readonly WireIssue[] | undefined {
if (!Array.isArray(value)) return;
return value.slice(0, 100).map((issue) => {
const item =
typeof issue === "object" && issue !== null
? (issue as { code?: unknown; path?: unknown; message?: unknown })
: {};
return {
code: bounded(item.code, 128, "error"),
path: bounded(item.path, 512, "/"),
message: bounded(item.message, 1024, "Request failed"),
};
});
}
function toWireError(
value: { readonly code?: unknown; readonly message?: unknown; readonly path?: unknown; readonly issues?: unknown },
fallbackCode = "worker.error",
fallbackMessage = "Request failed",
): WireError {
const path = value.path === undefined ? undefined : bounded(value.path, 512, "/");
const issues = toWireIssues(value.issues);
return {
code: bounded(value.code, 128, fallbackCode),
message: bounded(value.message, 2048, fallbackMessage),
...(path === undefined ? {} : { path }),
...(issues === undefined ? {} : { issues }),
};
}
function reject(
id: string,
error: { readonly code?: unknown; readonly message?: unknown; readonly path?: unknown; readonly issues?: unknown },
): void {
post({ protocol: PROTOCOL_VERSION, type: "response", id, ok: false, error: toWireError(error) });
}
async function drawView(view: WorkerView, frameId: number, renderId: number, reasons: number): Promise {
if (!state || views.get(view.id) !== view || view.resizePending) {
if (views.get(view.id) === view) view.scheduler.defer(frameId, reasons);
return;
}
const pixels = view.deviceWidth * view.deviceHeight;
if (view.inFlightPixels || inFlightDevicePixels + pixels > FXNODE_VIEW_LIMITS.maxInFlightDevicePixels) {
view.scheduler.defer(frameId, reasons);
return;
}
view.inFlightPixels = { frameId, pixels };
inFlightDevicePixels += pixels;
advanceCollapseAnimations(view, performance.now());
const expectedWidth = view.deviceWidth,
expectedHeight = view.deviceHeight;
const preview =
view.session.previewPositions.size || view.session.previewSizes.size || view.session.previewValues.size;
const nodes = preview
? Object.fromEntries(
Object.entries(state.document.nodes).map(([id, node]) => {
const values = [...view.session.previewValues].filter(([controlId]) => controlId.startsWith(`${id}:`));
let next = {
...node,
...(view.session.previewPositions.has(node.id)
? { position: view.session.previewPositions.get(node.id)! }
: {}),
...(view.session.previewSizes.has(node.id) ? { size: view.session.previewSizes.get(node.id)! } : {}),
};
for (const [controlId, value] of values) {
const control = view.currentLayout?.controls.get(controlId);
if (control?.source === "parameter" && next.known)
next = { ...next, parameters: { ...next.parameters, [control.key]: value } };
else if (control?.source === "socket-default")
next = {
...next,
sockets: next.sockets.map((socket) =>
socket.id === control.key ? { ...socket, defaultValue: value } : socket,
),
};
}
return [id, next];
}),
)
: state.document.nodes;
const transform = currentTransform(view);
const layout = applyNodeOrder(
nodes === state.document.nodes && layoutStore
? layoutStore.view(transform)
: layoutGraph(application!.compiled, { ...state.document, nodes }, transform),
view.session.uiOrder,
);
view.currentLayout = layout;
publishSelection(view);
if (view.session.collapseAnimations.size) view.scheduler.request(renderId, DirtyReason.Preview);
let frameHostGeneration = 0,
frameSurfaceGeneration = 0,
frameDeviceWidth = 0,
frameDeviceHeight = 0,
frameHost = { colorPickerOpen: false };
const bitmap = await atlas.renderAndCrop(view.id, { width: expectedWidth, height: expectedHeight }, (target) => {
frameHostGeneration = view.hostGeneration;
frameSurfaceGeneration = view.surfaceGeneration;
frameDeviceWidth = view.deviceWidth;
frameDeviceHeight = view.deviceHeight;
frameHost = { colorPickerOpen: !!view.session.colorPicker };
const deviceTarget = {
x: target.deviceX,
y: target.deviceY,
width: target.deviceWidth,
height: target.deviceHeight,
};
renderCanvas(target.context, layout, application!.compiled.theme, view.session, resourceImages, deviceTarget);
if (view.session.colorPicker)
paintColorPicker(
target.context,
view.session.colorPicker.layout,
view.session.colorPicker.model,
view.session.colorPicker.rgba,
view.session.colorPicker.hsv,
view.session.colorPicker.edit,
view.viewport.dpr,
deviceTarget,
);
});
if (!bitmap) {
releaseFramePixels(view, frameId);
if (views.get(view.id) === view) view.scheduler.defer(frameId, reasons);
return;
}
if (views.get(view.id) !== view) {
bitmap.close();
releaseFramePixels(view, frameId);
return;
}
try {
post(
{
protocol: PROTOCOL_VERSION,
type: "view.frame",
viewId: view.id,
bitmap,
renderId,
frameId,
hostGeneration: frameHostGeneration,
surfaceGeneration: frameSurfaceGeneration,
deviceWidth: frameDeviceWidth,
deviceHeight: frameDeviceHeight,
host: frameHost,
},
[bitmap],
);
} catch (error) {
bitmap.close();
releaseFramePixels(view, frameId);
throw error;
}
}
function fatal(error: unknown, code = "worker.fatal"): void {
if (fataled) return;
fataled = true;
for (const view of views.values()) {
view.scheduler.stop();
releaseFramePixels(view);
}
views.clear();
atlas.dispose();
resourceOperations.clear();
closeResourceImages();
post({
protocol: PROTOCOL_VERSION,
type: "fatal",
error: { code, message: error instanceof Error ? error.message : "Worker failure" },
});
scope.close();
}
let fataled = false;
type CommitPresentationPolicy = { readonly kind: "none" } | { readonly kind: "select-added"; readonly nodeId: string };
const noPresentation: CommitPresentationPolicy = { kind: "none" };
function commit(
result: Extract,
command: Command,
sourceView?: WorkerView,
presentation: CommitPresentationPolicy = noPresentation,
): void {
for (const view of views.values()) cancelGesture(view);
const nextJournal = advanceJournal(
journal!,
command,
application!.runtime.save(result.state.document),
(baseline, commands) => application!.runtime.validateReplayJournal(baseline, commands, result.state.historyLimit),
);
if (result.mutationEnvelope.mutations.some((mutation) => mutation.kind === "document.replaced")) {
installDocumentReplacement(result, nextJournal);
return;
}
state = result.state;
journal = nextJournal;
const collapseTimestamp = performance.now();
for (const view of views.values()) seedCollapseAnimations(view, result.mutationEnvelope.mutations, collapseTimestamp);
layoutStore?.rebuild(result.state.document);
for (const view of views.values()) pruneSession(view);
if (
presentation.kind === "select-added" &&
sourceView &&
views.get(sourceView.id) === sourceView &&
state.document.nodes[presentation.nodeId]
) {
const id = nodeId(presentation.nodeId);
sourceView.session.selectedNodes = new Set([id]);
sourceView.session.selectedLinks.clear();
sourceView.session.activeNode = id;
raiseNode(sourceView, id);
}
post({ protocol: PROTOCOL_VERSION, type: "mutation", envelope: result.mutationEnvelope });
post({ protocol: PROTOCOL_VERSION, type: "snapshot.event", envelope: result.snapshotEnvelope });
for (const view of views.values()) {
refreshLayout(view);
publishSelection(view);
view.scheduler.request();
}
}
function installDocumentReplacement(
result:
| Extract
| Extract
| Extract,
nextJournal: WorkerJournal,
): void {
const nextStore = new IndexedLayoutStore(application!.compiled, result.state.document);
for (const view of views.values()) cancelGesture(view);
state = result.state;
journal = nextJournal;
layoutStore = nextStore;
for (const view of views.values()) {
view.gestureLayout = undefined;
resetSessionForGraphReplacement(view.session);
view.currentLayout = applyNodeOrder(nextStore.view(currentTransform(view)), []);
}
closeResourceImages();
post({ protocol: PROTOCOL_VERSION, type: "mutation", envelope: result.mutationEnvelope });
post({ protocol: PROTOCOL_VERSION, type: "snapshot.event", envelope: result.snapshotEnvelope });
for (const view of views.values()) {
publishSelection(view);
view.scheduler.request();
}
}
function collapseValue(animation: import("./session.js").CollapseAnimation, now: number): number {
const t = Math.max(0, Math.min(1, (now - animation.startedAt) / animation.durationMs)),
eased = 1 - (1 - t) ** 3;
return animation.from + (animation.to - animation.from) * eased;
}
function advanceCollapseAnimations(view: WorkerView, now: number): void {
for (const [id, animation] of view.session.collapseAnimations) {
animation.value = collapseValue(animation, now);
if (now - animation.startedAt >= animation.durationMs) view.session.collapseAnimations.delete(id);
}
}
function seedCollapseAnimations(
view: WorkerView,
mutations: readonly import("../engine/mutations.js").Mutation[],
now: number,
): void {
const changes = new Map<
import("../core/types.js").NodeId,
{ before: import("../core/types.js").GraphNode | null; after: import("../core/types.js").GraphNode | null }
>();
for (const mutation of mutations)
if (mutation.kind === "node.set") {
const prior = changes.get(mutation.id);
changes.set(mutation.id, { before: prior ? prior.before : mutation.before, after: mutation.after });
}
for (const [id, change] of changes) {
if (!change.after) {
view.session.collapseAnimations.delete(id);
continue;
}
if (!change.before || change.before.collapsed === change.after.collapsed) continue;
const existing = view.session.collapseAnimations.get(id),
from = existing ? collapseValue(existing, now) : change.before.collapsed ? 1 : 0,
to = change.after.collapsed ? 1 : 0,
distance = Math.abs(to - from);
if (distance < 0.001) {
view.session.collapseAnimations.delete(id);
continue;
}
view.session.collapseAnimations.set(id, { from, to, value: from, startedAt: now, durationMs: 120 * distance });
}
}
function cancelGesture(view: WorkerView): boolean {
const active =
!!view.session.knife ||
!!view.session.drag ||
!!view.session.scrub ||
!!view.session.rampDrag ||
!!view.session.reroutePress ||
!!view.session.modalMove ||
!!view.session.box ||
!!view.session.linkDrag ||
!!view.session.resize ||
!!view.session.pan ||
!!view.session.controlEdit ||
!!view.session.colorPicker ||
!!view.session.colorWheel ||
!!view.gestureLayout ||
view.session.previewValues.size > 0 ||
view.session.previewPositions.size > 0 ||
view.session.previewSizes.size > 0;
if (view.session.box) view.session.selectedNodes = new Set(view.session.box.checkpoint);
delete view.session.knife;
delete view.session.drag;
delete view.session.scrub;
delete view.session.rampDrag;
delete view.session.reroutePress;
delete view.session.modalMove;
delete view.session.box;
delete view.session.linkDrag;
delete view.session.resize;
delete view.session.parentHighlight;
delete view.session.pan;
delete view.session.controlEdit;
delete view.session.colorPicker;
delete view.session.colorWheel;
view.session.previewValues.clear();
view.gestureLayout = undefined;
view.session.previewPositions.clear();
view.session.previewSizes.clear();
return active;
}
const currentTransform = (view: WorkerView) => ({
center: view.session.cameraCenter,
zoom: view.session.zoom,
viewport: { x: view.viewport.width, y: view.viewport.height },
dpr: view.viewport.dpr,
});
function raiseNode(view: WorkerView, id: import("../core/types.js").NodeId): void {
view.session.uiOrder = [...view.session.uiOrder.filter((value) => value !== id), id];
}
function selectNode(view: WorkerView, id: import("../core/types.js").NodeId, add = false): void {
if (add) {
view.session.selectedNodes.has(id) ? view.session.selectedNodes.delete(id) : view.session.selectedNodes.add(id);
} else {
view.session.selectedNodes.clear();
view.session.selectedNodes.add(id);
}
view.session.selectedLinks.clear();
view.session.activeNode = id;
raiseNode(view, id);
}
function refreshLayout(view: WorkerView): void {
if (layoutStore) view.currentLayout = applyNodeOrder(layoutStore.view(currentTransform(view)), view.session.uiOrder);
}
function pruneSession(view: WorkerView): void {
if (!state) return;
const ids = new Set(Object.keys(state.document.nodes)),
links = new Set(Object.keys(state.document.links));
view.session.selectedNodes = new Set([...view.session.selectedNodes].filter((id) => ids.has(id)));
view.session.selectedLinks = new Set([...view.session.selectedLinks].filter((id) => links.has(id)));
view.session.uiOrder = view.session.uiOrder.filter((id) => ids.has(id));
if (view.session.activeNode && !ids.has(view.session.activeNode)) delete view.session.activeNode;
}
function isStandardNode(id: import("../core/types.js").NodeId): boolean {
const node = state?.document.nodes[id];
return !!node?.known && application?.compiled.nodes.get(node.typeId)?.behavior === "standard";
}
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 => !!node),
linkCount = [...view.session.selectedLinks].filter((id) => state!.document.links[id]).length,
eligible = nodes.filter((node) => isStandardNode(node.id)),
mute = eligible.length
? {
enabled: true as const,
state: (eligible.every((node) => node.muted)
? "all-muted"
: eligible.every((node) => !node.muted)
? "all-unmuted"
: "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"}`;
if (!force && signature === view.selectionSignature) return;
view.selectionSignature = signature;
post({ protocol: PROTOCOL_VERSION, type: "view.selection.host", viewId: view.id, projection });
}
function controlCommand(
view: WorkerView,
id: string,
value?: import("../core/types.js").ParameterValue,
reset = false,
): import("../commands/types.js").Command | undefined {
const control = view.currentLayout?.controls.get(id);
if (!control || control.source === "unknown") return;
if (control.source === "parameter")
return reset
? { type: "node.parameter-reset", id: control.nodeId, key: control.key }
: { type: "node.parameter", id: control.nodeId, key: control.key, value: value! };
return reset
? {
type: "node.socket-default-reset",
id: control.nodeId,
socketId: control.key as import("../core/types.js").SocketId,
}
: {
type: "node.socket-default",
id: control.nodeId,
socketId: control.key as import("../core/types.js").SocketId,
value: value!,
};
}
function gestureCommand(view: WorkerView, command: import("../commands/types.js").Command): void {
if (!state) return;
const result = application!.runtime.transition(state, {
commandId: commandId(`gesture-${state.version + 1}`),
expectedVersion: state.version,
source: "gesture",
command,
});
if (result.status === "committed") commit(result, command, view);
}
const rampValue = (view: WorkerView, id: string, layout = view.currentLayout) => {
const c = layout?.controls.get(id),
v = c?.value as { kind?: unknown; value?: unknown };
return c?.kind === "color-ramp" && c.rampBounds && v?.kind === "json" && isColorRamp(v.value)
? { control: c, ramp: v.value }
: undefined;
};
const activeStop = (view: WorkerView, id: string, ramp: ColorRamp) => {
const stored = view.session.activeRampStopByControl.get(id);
return ramp.stops.find((s) => s.id === stored) ?? ramp.stops[0]!;
};
function commitRamp(view: WorkerView, id: string, ramp: ColorRamp, active?: string) {
const c = view.currentLayout?.controls.get(id);
if (!c) return;
if (active) view.session.activeRampStopByControl.set(id, active);
const command = controlCommand(view, id, {
kind: "json",
value: ramp as unknown as import("../core/types.js").JsonValue,
});
if (command) gestureCommand(view, command);
}
function rampAction(view: WorkerView, id: string, target: string, position?: number): void {
const found = rampValue(view, id);
if (!found) return;
let { ramp } = found;
const active = activeStop(view, id, ramp);
if (target === "add") {
const newId = `stop-${state!.version + 1}-${ramp.stops.length}`,
next = addRampMidpoint(ramp, active.id, newId);
commitRamp(view, id, next, next === ramp ? active.id : newId);
return;
}
if (target === "remove") {
const next = removeRampStop(ramp, active.id),
survivor = next.stops.reduce((a, b) =>
Math.abs(b.position - active.position) < Math.abs(a.position - active.position) ? b : a,
);
commitRamp(view, id, next, survivor.id);
return;
}
if (target === "flip") ramp = flipColorRamp(ramp);
else if (target === "distribute") ramp = distributeColorRamp(ramp);
else if (target === "mode") {
const options = ["rgb", "hsv", "hsl"] as const;
ramp = { ...ramp, colorMode: options[(options.indexOf(ramp.colorMode) + 1) % options.length]! };
} else if (target === "interpolation") {
const options = ["linear", "ease", "constant", "cardinal", "b-spline"] as const;
ramp = { ...ramp, interpolation: options[(options.indexOf(ramp.interpolation) + 1) % options.length]! };
} else if (target === "hue") {
const options = ["near", "far", "clockwise", "counter-clockwise"] as const;
ramp = { ...ramp, hueInterpolation: options[(options.indexOf(ramp.hueInterpolation) + 1) % options.length]! };
} else if (target === "gradient" && position !== undefined) {
const newId = `stop-${state!.version + 1}-${ramp.stops.length}`,
next = addRampStop(ramp, position, newId);
commitRamp(view, id, next, next === ramp ? active.id : newId);
return;
}
commitRamp(view, id, ramp, active.id);
}
const containsView = (point: { x: number; y: number }, rect: { x: number; y: number; width: number; height: number }) =>
point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height;
function rgbToHsv(rgba: Rgba, oldHue = 0): readonly [number, number, number] {
const [r, g, b] = rgba,
max = Math.max(r, g, b),
min = Math.min(r, g, b),
d = max - min,
s = max ? d / max : 0;
let h = oldHue;
if (d) h = 60 * (max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4);
return [((h % 360) + 360) % 360, s, max];
}
function hsvToRgb(h: number, s: number, v: number, a: number): Rgba {
h = ((h % 360) + 360) % 360;
s = Math.max(0, Math.min(1, s));
v = Math.max(0, Math.min(1, v));
const c = v * s,
x = c * (1 - Math.abs(((h / 60) % 2) - 1)),
m = v - c,
[r, g, b] =
h < 60
? [c, x, 0]
: h < 120
? [x, c, 0]
: h < 180
? [0, c, x]
: h < 240
? [0, x, c]
: h < 300
? [x, 0, c]
: [c, 0, x];
return [r + m, g + m, b + m, a];
}
function beginColorPicker(
view: WorkerView,
id: string,
anchor: { x: number; y: number; width: number; height: number },
rgba: Rgba,
target: { kind: "control" } | { kind: "ramp-stop"; stopId: string; original: ColorRamp },
): void {
const model = oklabToOklch(srgbToOklab([rgba[0], rgba[1], rgba[2]]));
view.session.colorPicker = {
layout: layoutColorPicker(anchor, { x: view.viewport.width, y: view.viewport.height }),
controlId: id,
target,
model,
rgba,
hsv: rgbToHsv(rgba),
};
publishPicker(view);
view.scheduler.request();
}
function publishPicker(view: WorkerView): void {
const p = view.session.colorPicker;
if (!p) return;
p.model = oklabToOklch(srgbToOklab([p.rgba[0], p.rgba[1], p.rgba[2]]), p.model.h);
if (p.target.kind === "control") view.session.previewValues.set(p.controlId, { kind: "color", value: p.rgba });
else
view.session.previewValues.set(p.controlId, {
kind: "json",
value: setRampColor(
p.target.original,
p.target.stopId,
p.rgba,
) as unknown as import("../core/types.js").JsonValue,
});
view.scheduler.request();
}
function applyPickerEdit(view: WorkerView, keepInvalid = true): boolean {
const p = view.session.colorPicker,
e = p?.edit;
if (!p || !e) return true;
let rgba: Rgba | undefined;
if (e.field === "hex") {
const match = e.buffer.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i),
raw = match?.[1];
if (raw) {
const expanded = raw.length < 5 ? [...raw].map((value) => value + value).join("") : raw,
full = expanded.length === 6 ? expanded + "ff" : expanded;
rgba = [0, 2, 4, 6].map((index) => parseInt(full.slice(index, index + 2), 16) / 255) as unknown as Rgba;
}
} else {
const value = Number(e.buffer);
if (Number.isFinite(value)) {
if (e.field === "rgba") {
const next = [...p.rgba] as number[];
next[e.index] = Math.max(0, Math.min(1, value));
rgba = next as unknown as Rgba;
} else {
const next = [...p.hsv] as number[];
next[e.index] = e.index ? Math.max(0, Math.min(1, value)) : ((value % 360) + 360) % 360;
p.hsv = next as unknown as typeof p.hsv;
rgba = hsvToRgb(next[0]!, next[1]!, next[2]!, p.rgba[3]);
}
}
}
if (!rgba) {
if (keepInvalid) {
p.edit = { ...e, invalid: true };
view.scheduler.request();
} else delete p.edit;
return false;
}
p.rgba = rgba;
p.hsv = rgbToHsv(rgba, p.hsv[0]);
delete p.edit;
publishPicker(view);
return true;
}
function openRampPicker(view: WorkerView, id: string, ramp: ColorRamp): void {
const control = view.currentLayout?.controls.get(id),
bounds = control?.rampBounds;
if (!control || !bounds) return;
const stop = activeStop(view, id, ramp),
anchor = viewRectFromWorld(bounds.color, view.currentLayout!);
beginColorPicker(view, id, anchor, stop.color as Rgba, { kind: "ramp-stop", stopId: stop.id, original: ramp });
}
function openControlPicker(view: WorkerView, id: string): void {
const control = view.currentLayout?.controls.get(id),
value = control?.value as import("../core/types.js").ParameterValue | undefined;
if (!control || control.kind !== "color" || control.linked || control.source === "unknown" || value?.kind !== "color")
return;
beginColorPicker(view, id, viewRectFromWorld(control.bounds, view.currentLayout!), value.value as Rgba, {
kind: "control",
});
}
function updatePicker(view: WorkerView, position: { x: number; y: number }): void {
const picker = view.session.colorPicker;
if (!picker?.drag) return;
const rect = picker.layout[picker.drag.region];
if (picker.drag.region === "plane") {
const radius = rect.width / 2,
dx = (position.x - (rect.x + radius)) / radius,
dy = (rect.y + radius - position.y) / radius,
length = Math.min(1, Math.hypot(dx, dy)),
h = Math.atan2(dy, dx),
c = length * maxSrgbChroma(picker.model.l, h);
picker.model = { ...picker.model, c, h };
} else if (picker.drag.region === "lightness") {
const l = 1 - Math.max(0, Math.min(1, (position.y - rect.y) / rect.height));
picker.model = { ...picker.model, l, c: Math.min(picker.model.c, maxSrgbChroma(l, picker.model.h)) };
} else
picker.rgba = [
picker.rgba[0],
picker.rgba[1],
picker.rgba[2],
1 - Math.max(0, Math.min(1, (position.y - rect.y) / rect.height)),
];
const rgb = mapOklchToSrgb(picker.model);
picker.rgba = [rgb[0], rgb[1], rgb[2], picker.rgba[3]];
picker.hsv = rgbToHsv(picker.rgba, picker.hsv[0]);
publishPicker(view);
}
function finishPicker(view: WorkerView, commitValue: boolean): void {
const picker = view.session.colorPicker;
if (!picker) return;
if (commitValue) applyPickerEdit(view, false);
const preview = view.session.previewValues.get(picker.controlId);
view.session.previewValues.delete(picker.controlId);
delete view.session.colorPicker;
if (commitValue && preview) {
if (picker.target.kind === "control") {
const command = controlCommand(view, picker.controlId, preview);
if (command) gestureCommand(view, command);
} else {
const value = preview as { kind?: unknown; value?: unknown };
if (value.kind === "json" && isColorRamp(value.value))
commitRamp(view, picker.controlId, value.value, picker.target.stopId);
}
}
view.scheduler.request();
}
const viewRectFromWorld = (rect: { x: number; y: number; width: number; height: number }, layout: LayoutSnapshot) => {
const p = worldToView({ x: rect.x, y: rect.y }, layout.transform);
return { x: p.x, y: p.y, width: rect.width * layout.transform.zoom, height: rect.height * layout.transform.zoom };
};
function updateInlineWheel(view: WorkerView, position: { x: number; y: number }): void {
const wheel = view.session.colorWheel;
if (!wheel) return;
const { bounds } = wheel;
if (wheel.region === "plane") {
const radius = bounds.width / 2,
dx = (position.x - (bounds.x + radius)) / radius,
dy = (bounds.y + radius - position.y) / radius,
length = Math.min(1, Math.hypot(dx, dy)),
h = Math.atan2(dy, dx),
l = Math.min(0.9, Math.max(0.1, wheel.model.l));
wheel.model = { l, h, c: length * maxSrgbChroma(l, h) };
} else {
const l = 1 - Math.max(0, Math.min(1, (position.y - bounds.y) / bounds.height));
wheel.model = { ...wheel.model, l, c: Math.min(wheel.model.c, maxSrgbChroma(l, wheel.model.h)) };
}
const rgb = mapOklchToSrgb(wheel.model);
wheel.rgba = [rgb[0], rgb[1], rgb[2], wheel.rgba[3]];
view.session.previewValues.set(wheel.controlId, { kind: "color", value: wheel.rgba });
view.scheduler.request();
}
function finishInlineWheel(view: WorkerView, commitValue: boolean): void {
const wheel = view.session.colorWheel;
if (!wheel) return;
const preview = view.session.previewValues.get(wheel.controlId);
view.session.previewValues.delete(wheel.controlId);
delete view.session.colorWheel;
view.gestureLayout = undefined;
if (commitValue && preview) {
const command = controlCommand(view, wheel.controlId, preview);
if (command) gestureCommand(view, command);
} else view.scheduler.request();
}
function processInput(
view: WorkerView,
event: Extract["event"],
nodeMenuRequestId?: string,
resourceOpenRequestId?: string,
): void {
if (!state) return;
if (!view.currentLayout && layoutStore) view.currentLayout = layoutStore.view(currentTransform(view));
if (!view.currentLayout) return;
if (event.kind === "outside-pointer") {
if (event.button === 0 && view.session.colorPicker) finishPicker(view, true);
return;
}
if (event.kind === "focus" && event.phase === "blur") delete view.session.knife;
if (event.kind === "key" && event.phase === "down" && event.key === "Escape") delete view.session.knife;
if (event.kind === "focus") {
if (event.phase === "blur") {
cancelGesture(view);
view.scheduler.request();
}
return;
}
if (event.kind === "wheel") {
if (view.session.colorPicker) return;
const next = zoomAt(currentTransform(view), event.position, event.delta.y);
view.session.zoom = next.zoom;
view.session.cameraCenter = next.center;
refreshLayout(view);
view.scheduler.request();
return;
}
if (event.kind === "key" && event.phase === "down") {
const modifier = (event.modifiers & 6) !== 0;
if (event.key === "Escape") {
cancelGesture(view);
view.scheduler.request();
return;
}
if (view.session.colorPicker) {
const p = view.session.colorPicker,
e = p.edit;
if (e) {
if (event.key === "Enter") {
applyPickerEdit(view);
return;
}
if (event.key === "Backspace") {
p.edit = { ...e, buffer: e.selectAll ? "" : e.buffer.slice(0, -1), selectAll: false, invalid: false };
view.scheduler.request();
return;
}
if (event.key.length === 1 && !modifier) {
p.edit = { ...e, buffer: e.selectAll ? event.key : e.buffer + event.key, selectAll: false, invalid: false };
view.scheduler.request();
}
return;
}
return;
}
if (event.key === "Backspace" && view.session.controlEdit) {
view.session.controlEdit = {
...view.session.controlEdit,
buffer:
view.session.controlEdit.kind === "number" && view.session.controlEdit.selectAll
? ""
: view.session.controlEdit.buffer.slice(0, -1),
...(view.session.controlEdit.kind === "number" ? { selectAll: false } : {}),
};
view.scheduler.request();
return;
}
if (event.key === "Backspace") {
if (view.session.colorPicker) finishPicker(view, false);
const id = view.session.focusedControl ?? view.session.hoveredControl;
if (id) {
const rv = rampValue(view, id);
if (rv) {
const stop = activeStop(view, id, rv.ramp),
target = view.session.focusedRampTarget;
if (target === "swatch") commitRamp(view, id, setRampColor(rv.ramp, stop.id, [0, 0, 0, 1]), stop.id);
else {
const command = controlCommand(view, id, undefined, true);
if (command) gestureCommand(view, command);
}
return;
}
const command = controlCommand(view, id, undefined, true);
if (command) gestureCommand(view, command);
}
return;
}
if (view.session.focusedControl && view.session.focusedRampTarget) {
const rv = rampValue(view, view.session.focusedControl);
if (rv) {
const stop = activeStop(view, view.session.focusedControl, rv.ramp);
if (event.key === "Delete" || event.key.toLowerCase() === "x") {
rampAction(view, view.session.focusedControl, "remove");
return;
}
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
const step = (event.modifiers & 8) !== 0 ? 0.001 : 0.01;
commitRamp(
view,
view.session.focusedControl,
moveRampStop(rv.ramp, stop.id, stop.position + (event.key === "ArrowRight" ? step : -step)),
stop.id,
);
return;
}
return;
}
}
if (view.session.focusedControl && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
const control = view.currentLayout.controls.get(view.session.focusedControl);
const value = control?.value;
if (
control?.kind === "enum" &&
control.schema?.type === "string" &&
control.schema.enum &&
value &&
typeof value === "object" &&
"kind" in value &&
value.kind === "string" &&
"value" in value &&
typeof value.value === "string"
) {
const next = cycleEnum(control.schema.enum, value.value, event.key === "ArrowDown" ? 1 : -1);
const command = controlCommand(view, control.id, { kind: "string", value: next });
if (command) gestureCommand(view, command);
}
return;
}
if (view.session.controlEdit?.kind === "string") {
if (event.key === "Enter") {
const edit = view.session.controlEdit;
delete view.session.controlEdit;
const command = controlCommand(view, edit.controlId, { kind: "string", value: edit.buffer });
if (command) gestureCommand(view, command);
return;
}
if (event.key.length === 1 && !modifier) {
view.session.controlEdit = { ...view.session.controlEdit, buffer: view.session.controlEdit.buffer + event.key };
view.scheduler.request();
return;
}
}
if (view.session.controlEdit?.kind === "number") {
if (event.key === "Enter") {
const edit = view.session.controlEdit,
value = Number(edit.buffer),
control = view.currentLayout.controls.get(edit.controlId),
original = control?.value as import("../core/types.js").ParameterValue | undefined;
if (Number.isFinite(value) && control && original) {
delete view.session.controlEdit;
const command = controlCommand(
view,
edit.controlId,
setNumericComponent(control, original, edit.component, value),
);
if (command) gestureCommand(view, command);
}
return;
}
if (event.key.length === 1 && !modifier && /[0-9eE+.-]/.test(event.key)) {
view.session.controlEdit = {
...view.session.controlEdit,
buffer: view.session.controlEdit.selectAll ? event.key : view.session.controlEdit.buffer + event.key,
selectAll: false,
};
view.scheduler.request();
return;
}
}
if (modifier && event.key.toLowerCase() === "z") {
gestureCommand(view, { type: (event.modifiers & 8) !== 0 ? "redo" : "undo" });
return;
}
if (event.key === "Home") {
const b = view.currentLayout.graphBounds;
if (b.width || b.height) {
view.session.cameraCenter = { x: b.x + b.width / 2, y: b.y - b.height / 2 };
view.session.zoom = Math.min(
2,
Math.max(0.1, Math.min(view.viewport.width / (b.width + 80), view.viewport.height / (b.height + 80))),
);
view.scheduler.request();
}
return;
}
const ids = [...view.session.selectedNodes];
if (event.key.toLowerCase() === "g" && ids.length && !view.session.modalMove) {
const startView = view.session.pointer ?? { x: view.viewport.width / 2, y: view.viewport.height / 2 },
roots = groupRoots(view.session.selectedNodes, view.currentLayout);
view.gestureLayout = view.currentLayout;
view.session.modalMove = {
startView,
startWorld: viewToWorld(startView, view.currentLayout.transform),
origins: new Map(roots.map((id) => [id, state!.document.nodes[id]!.position])),
moved: true,
};
return;
}
if (event.key === "Enter" && view.session.modalMove) {
finishMove(view);
return;
}
if (event.key === "Delete" || event.key.toLowerCase() === "x") {
const command = planSelectionRemoval(state.document, view.session.selectedNodes, view.session.selectedLinks);
if (command) gestureCommand(view, command);
} else if (event.key.toLowerCase() === "m" && ids.length) {
const command = planSelectionMute(state.document, view.session.selectedNodes, isStandardNode);
if (command) gestureCommand(view, command);
} else if (event.key.toLowerCase() === "h" && ids.length)
gestureCommand(view, {
type: "batch",
commands: ids.map((id) => ({ type: "node.collapse", id, value: !state!.document.nodes[id]?.collapsed })),
});
return;
}
if (event.kind !== "pointer") return;
view.session.pointer = event.position;
if (view.session.colorPicker) {
const picker = view.session.colorPicker;
if (event.phase === "down" && event.button === 2) {
finishPicker(view, false);
return;
}
if (picker.drag?.pointerId === event.pointerId) {
if (event.phase === "move") updatePicker(view, event.position);
else {
delete picker.drag;
view.scheduler.request();
}
return;
}
if (event.phase === "down" && event.button === 0) {
if (containsView(event.position, picker.layout.confirm)) {
finishPicker(view, true);
return;
}
if (picker.edit) applyPickerEdit(view, false);
const region = (["plane", "lightness", "alpha"] as const).find((name) =>
containsView(event.position, picker.layout[name]),
);
if (region) {
picker.drag = { pointerId: event.pointerId, region };
updatePicker(view, event.position);
return;
}
for (const name of ["rgba", "hsv"] as const) {
const index = picker.layout[name].findIndex((r) => containsView(event.position, r));
if (index >= 0) {
const values = name === "rgba" ? picker.rgba : picker.hsv;
picker.edit = {
field: name,
index,
buffer: name === "hsv" && index === 0 ? values[index]!.toFixed(1) : values[index]!.toFixed(3),
selectAll: true,
invalid: false,
};
view.scheduler.request();
return;
}
}
if (containsView(event.position, picker.layout.hex)) {
picker.edit = {
field: "hex",
index: 0,
buffer:
"#" +
picker.rgba
.map((v) =>
Math.round(v * 255)
.toString(16)
.padStart(2, "0"),
)
.join("")
.toUpperCase(),
selectAll: true,
invalid: false,
};
view.scheduler.request();
return;
}
finishPicker(view, true);
return;
}
return;
}
if (view.session.colorWheel?.pointerId === event.pointerId) {
if (event.phase === "move") updateInlineWheel(view, event.position);
else finishInlineWheel(view, event.phase === "up");
return;
}
if (event.phase === "cancel") delete view.session.knife;
if (event.phase === "down" && event.button === 2 && (event.modifiers & 2) !== 0) {
cancelGesture(view);
view.gestureLayout = view.currentLayout;
view.session.knife = {
pointerId: event.pointerId,
points: [event.position],
crossed: new Set(),
mode: (event.modifiers & 1) !== 0 ? "mute" : "remove",
};
view.scheduler.request();
return;
}
if (view.session.knife?.pointerId === event.pointerId) {
if (event.phase === "move") {
view.session.knife.points = appendKnifePoint(view.session.knife.points, event.position);
view.session.knife.crossed = crossedLinks(
view.gestureLayout!,
view.session.knife.points,
view.session.knife.mode === "mute",
);
view.scheduler.request();
return;
}
if (event.phase === "up") {
const knife = view.session.knife;
delete view.session.knife;
view.gestureLayout = undefined;
const commands = [...knife.crossed].map((id) =>
knife.mode === "remove"
? { type: "link.remove" as const, id }
: { type: "link.mute" as const, id, value: !state!.document.links[id]!.muted },
);
if (commands.length) gestureCommand(view, { type: "batch", commands });
else view.scheduler.request();
return;
}
}
if (event.phase === "down" && event.button === 2) {
const canceled = cancelGesture(view),
menuLayout = layoutStore?.view(currentTransform(view)),
open = !!nodeMenuRequestId && !canceled && !!menuLayout && hitTest(menuLayout, event.position).kind === "canvas";
if (nodeMenuRequestId)
post(
open
? {
protocol: PROTOCOL_VERSION,
type: "view.node-menu.result",
viewId: view.id,
requestId: nodeMenuRequestId,
open: true,
compositionRevision,
viewPosition: event.position,
}
: {
protocol: PROTOCOL_VERSION,
type: "view.node-menu.result",
viewId: view.id,
requestId: nodeMenuRequestId,
open: false,
},
);
view.scheduler.request();
return;
}
if (event.phase === "move") {
const hover = hitTest(view.currentLayout, event.position);
if (hover.kind === "control" || hover.kind === "control-step" || hover.kind === "ramp") {
view.session.hoveredControl = hover.id;
if (hover.kind === "ramp") view.session.hoveredRampTarget = hover.target;
else delete view.session.hoveredRampTarget;
} else {
delete view.session.hoveredControl;
delete view.session.hoveredRampTarget;
if (!view.session.controlEdit) delete view.session.focusedControl;
}
}
if (event.phase === "cancel") {
cancelGesture(view);
view.scheduler.request();
return;
}
if (view.session.reroutePress?.pointerId === event.pointerId) {
const press = view.session.reroutePress;
if (event.phase === "move" && Math.hypot(event.position.x - press.start.x, event.position.y - press.start.y) >= 4) {
delete view.session.reroutePress;
view.session.linkDrag = { pointerId: event.pointerId, from: press.socketId, current: event.position };
const hit = hitTest(view.gestureLayout!, event.position, "input");
if (
hit.kind === "socket" &&
compatibleTargets(view.gestureLayout!, press.socketId).some((socket) => socket.id === hit.id)
)
view.session.linkDrag.candidate = hit.id;
view.scheduler.request();
return;
}
if (event.phase === "up") {
delete view.session.reroutePress;
view.gestureLayout = undefined;
selectNode(view, press.nodeId, (event.modifiers & 8) !== 0);
refreshLayout(view);
view.scheduler.request();
return;
}
return;
}
if (view.session.scrub?.pointerId === event.pointerId) {
if (event.phase === "down" && event.button === 2) {
cancelGesture(view);
view.scheduler.request();
return;
}
if (event.phase === "move") {
const control = view.gestureLayout?.controls.get(view.session.scrub.controlId);
if (Math.abs(event.position.x - view.session.scrub.startX) >= 2) view.session.scrub.moved = true;
if (!view.session.scrub.moved) return;
const raw = view.session.scrub.original as { kind?: unknown; value?: unknown };
if (control && raw.kind === "json" && isColorRamp(raw.value)) {
const stop = activeStop(view, control.id, raw.value),
delta = (event.position.x - view.session.scrub.startX) * ((event.modifiers & 8) !== 0 ? 0.001 : 0.01);
const ramp =
view.session.scrub.component < 0
? moveRampStop(raw.value, stop.id, stop.position + delta)
: setRampColor(
raw.value,
stop.id,
stop.color.map((c, i) => (i === view.session.scrub!.component ? c + delta : c)) as unknown as readonly [
number,
number,
number,
number,
],
);
view.session.previewValues.set(control.id, {
kind: "json",
value: ramp as unknown as import("../core/types.js").JsonValue,
});
view.scheduler.request();
} else if (control) {
const value = scrubValue(
control,
view.session.scrub.original,
view.session.scrub.component,
event.position.x - view.session.scrub.startX,
(event.modifiers & 8) !== 0,
(event.modifiers & 2) !== 0,
);
view.session.previewValues.set(control.id, value);
view.scheduler.request();
}
} else {
const scrub = view.session.scrub,
id = scrub.controlId,
value = view.session.previewValues.get(id),
moved = scrub.moved;
cancelGesture(view);
if (!moved) {
view.session.focusedControl = id;
if (scrub.original.kind !== "json") {
const original = scrub.original,
component =
original.kind === "number"
? original.value
: original.kind === "vector" || original.kind === "color"
? (original.value[scrub.component] ?? 0)
: 0;
view.session.controlEdit = {
kind: "number",
controlId: id,
component: scrub.component,
buffer: component.toFixed(3),
selectAll: true,
};
}
view.scheduler.request();
return;
}
const command = value ? controlCommand(view, id, value) : undefined;
if (command) gestureCommand(view, command);
else view.scheduler.request();
}
return;
}
if (view.session.rampDrag?.pointerId === event.pointerId) {
if (event.phase === "move") {
const found = rampValue(view, view.session.rampDrag.controlId, view.gestureLayout),
b = found?.control.rampBounds;
if (found && b) {
const world = viewToWorld(event.position, view.gestureLayout!.transform),
p = Math.max(0, Math.min(1, (world.x - b.handles.x) / b.handles.width));
view.session.previewValues.set(found.control.id, {
kind: "json",
value: moveRampStop(
found.ramp,
view.session.rampDrag.stopId,
p,
) as unknown as import("../core/types.js").JsonValue,
});
view.scheduler.request();
}
} else {
const drag = view.session.rampDrag,
value = view.session.previewValues.get(drag.controlId) as { kind?: unknown; value?: unknown } | undefined;
cancelGesture(view);
if (value?.kind === "json" && isColorRamp(value.value))
commitRamp(view, drag.controlId, value.value, drag.stopId);
else view.scheduler.request();
}
return;
}
if (event.phase === "down" && event.button === 1) {
view.session.pan = { pointerId: event.pointerId, last: event.position };
return;
}
if (view.session.pan?.pointerId === event.pointerId) {
if (event.phase === "move") {
view.session.cameraCenter = {
x: view.session.cameraCenter.x - (event.position.x - view.session.pan.last.x) / view.session.zoom,
y: view.session.cameraCenter.y + (event.position.y - view.session.pan.last.y) / view.session.zoom,
};
view.session.pan = { ...view.session.pan, last: event.position };
view.scheduler.request();
} else delete view.session.pan;
return;
}
if (view.session.modalMove) {
if (event.phase === "move") {
previewMove(view, view.session.modalMove, event.position);
return;
}
if (event.phase === "down" && event.button === 0) {
finishMove(view);
return;
}
}
if (event.phase === "down" && event.button === 0) {
const previousEdit = view.session.controlEdit;
if (previousEdit) {
delete view.session.controlEdit;
view.scheduler.request();
}
const interactionLayout = layoutStore
? applyNodeOrder(layoutStore.view(currentTransform(view)), view.session.uiOrder)
: view.currentLayout;
let hit = hitTest(interactionLayout, event.position, "output");
if (
previousEdit?.kind === "number" &&
hit.kind === "control-step" &&
hit.id === previousEdit.controlId &&
hit.component === previousEdit.component
)
hit = { kind: "control", id: hit.id, component: hit.component };
if (hit.kind === "resource") {
const control = interactionLayout.controls.get(hit.id),
policy = control?.resourceId && application?.compiled.resources.get(control.resourceId as never);
if (resourceOpenRequestId && control?.kind === "resource" && control.source === "parameter" && policy)
post({
protocol: PROTOCOL_VERSION,
type: "view.resource.open",
viewId: view.id,
requestId: resourceOpenRequestId,
authorization: {
viewId: view.id,
token: control.id,
graphVersion: state.version,
compositionRevision,
},
resource: {
id: policy.id,
kind: "image",
title: control.label,
openTitle: control.openTitle ?? policy.openTitle ?? "Open",
accept: [...policy.accept],
maxBytes: policy.maxBytes,
maxWidth: policy.maxWidth,
maxHeight: policy.maxHeight,
maxPixels: policy.maxPixels,
},
});
return;
}
view.gestureLayout = interactionLayout;
if (hit.kind === "ramp") {
const found = rampValue(view, hit.id);
if (!found) return;
view.session.focusedControl = hit.id;
view.session.focusedRampTarget = hit.target;
const active = activeStop(view, hit.id, found.ramp);
if (hit.target === "handle" && hit.stopId) {
const same = found.ramp.stops.filter(
(s) =>
Math.abs(s.position - (hit.position ?? s.position)) <
7 / view.currentLayout!.transform.zoom / found.control.rampBounds!.handles.width,
);
let id = hit.stopId;
if (same.length > 1) {
const old = view.session.activeRampStopByControl.get(found.control.id),
i = same.findIndex((s) => s.id === old);
id = same[(i + 1) % same.length]!.id;
}
view.session.activeRampStopByControl.set(found.control.id, id);
view.session.rampDrag = {
pointerId: event.pointerId,
controlId: hit.id,
stopId: id,
original: found.control.value as import("../core/types.js").ParameterValue,
};
view.scheduler.request();
return;
}
if (hit.target === "gradient") {
rampAction(view, hit.id, "gradient", hit.position);
return;
}
if (hit.target === "position") {
view.session.scrub = {
pointerId: event.pointerId,
controlId: hit.id,
component: -1,
startX: event.position.x,
original: found.control.value as import("../core/types.js").ParameterValue,
moved: false,
};
return;
}
if (hit.target === "selector") {
const i = found.ramp.stops.findIndex((s) => s.id === active.id);
view.session.activeRampStopByControl.set(
found.control.id,
found.ramp.stops[(i + 1) % found.ramp.stops.length]!.id,
);
view.scheduler.request();
return;
}
if (hit.target === "swatch") {
openRampPicker(view, hit.id, found.ramp);
return;
}
rampAction(view, hit.id, hit.target);
view.scheduler.request();
return;
}
if (hit.kind === "color-wheel") {
const control = view.currentLayout.controls.get(hit.id),
value = control?.value as import("../core/types.js").ParameterValue | undefined,
bounds = control?.colorWheelBounds?.[hit.region];
if (!control || value?.kind !== "color" || !bounds) return;
const rgba = value.value as Rgba;
view.gestureLayout = view.currentLayout;
view.session.colorWheel = {
controlId: hit.id,
original: value,
model: oklabToOklch(srgbToOklab([rgba[0], rgba[1], rgba[2]])),
rgba,
pointerId: event.pointerId,
region: hit.region,
bounds: viewRectFromWorld(bounds, view.currentLayout),
};
updateInlineWheel(view, event.position);
return;
}
if (hit.kind === "control-step") {
const control = view.currentLayout.controls.get(hit.id),
original = control?.value as import("../core/types.js").ParameterValue | undefined;
if (control && original) {
view.session.focusedControl = hit.id;
const current =
original.kind === "number"
? original.value
: original.kind === "vector" || original.kind === "color"
? (original.value[hit.component] ?? 0)
: 0,
next = setNumericComponent(
control,
original,
hit.component,
current + hit.direction * numericStep(control, (event.modifiers & 8) !== 0),
),
command = controlCommand(view, hit.id, next);
if (command) gestureCommand(view, command);
}
return;
}
if (hit.kind === "control") {
const control = view.currentLayout.controls.get(hit.id);
view.session.focusedControl = hit.id;
if (control && !control.linked && control.source !== "unknown") {
const current = control.value as import("../core/types.js").ParameterValue;
if (control.kind === "boolean" && current.kind === "boolean") {
const command = controlCommand(view, hit.id, { kind: "boolean", value: !current.value });
if (command) gestureCommand(view, command);
} else if (
control.kind === "enum" &&
control.schema?.type === "string" &&
control.schema.enum &&
current.kind === "string"
) {
const command = controlCommand(view, hit.id, {
kind: "string",
value: cycleEnum(control.schema.enum, current.value, 1),
});
if (command) gestureCommand(view, command);
} else if ((control.kind === "string" || control.kind === "resource") && current.kind === "string")
view.session.controlEdit = { kind: "string", controlId: hit.id, buffer: current.value };
else if (control.kind === "color" && current.kind === "color") openControlPicker(view, hit.id);
else if (control.kind === "number" || control.kind === "vector")
view.session.scrub = {
pointerId: event.pointerId,
controlId: hit.id,
component: hit.component,
startX: event.position.x,
original: current,
moved: false,
};
}
view.scheduler.request();
return;
}
if (hit.kind === "socket") {
const socket = view.currentLayout.sockets.get(hit.id),
node = socket && view.currentLayout.nodes.get(socket.nodeId);
if (socket?.direction === "output" && node?.kind === "reroute")
view.session.reroutePress = {
pointerId: event.pointerId,
nodeId: node.id,
socketId: hit.id,
start: event.position,
};
else if (socket?.direction === "output")
view.session.linkDrag = { pointerId: event.pointerId, from: hit.id, current: event.position };
view.scheduler.request();
return;
}
if (hit.kind === "collapse") {
const n = state.document.nodes[hit.id];
if (n) gestureCommand(view, { type: "node.collapse", id: hit.id, value: !n.collapsed });
return;
}
if (hit.kind === "resize") {
view.session.resize = { pointerId: event.pointerId, id: hit.id };
return;
}
if (hit.kind === "link") {
view.session.selectedNodes.clear();
if ((event.modifiers & 8) === 0) view.session.selectedLinks.clear();
view.session.selectedLinks.add(hit.id);
view.scheduler.request();
return;
}
if (hit.kind === "canvas") {
view.session.box = {
pointerId: event.pointerId,
start: event.position,
current: event.position,
checkpoint: new Set(view.session.selectedNodes),
add: (event.modifiers & 8) !== 0,
};
if (!view.session.box.add) view.session.selectedNodes.clear();
view.scheduler.request();
return;
}
const id = hit.id;
if ((event.modifiers & 8) !== 0) selectNode(view, id, true);
else if (!view.session.selectedNodes.has(id)) selectNode(view, id);
else {
view.session.activeNode = id;
raiseNode(view, id);
}
refreshLayout(view);
view.gestureLayout = view.currentLayout;
const roots = groupRoots(view.session.selectedNodes, view.currentLayout);
view.session.drag = {
pointerId: event.pointerId,
startView: event.position,
startWorld: viewToWorld(event.position, view.currentLayout.transform),
origins: new Map(roots.map((root) => [root, state!.document.nodes[root]!.position])),
moved: false,
};
view.scheduler.request();
return;
}
if (view.session.box?.pointerId === event.pointerId) {
if (event.phase === "move") {
view.session.box.current = event.position;
const found = boxNodes(view.gestureLayout!, view.session.box.start, event.position);
view.session.selectedNodes = new Set(view.session.box.add ? [...view.session.box.checkpoint, ...found] : found);
view.scheduler.request();
} else {
for (const id of view.gestureLayout!.drawOrder) if (view.session.selectedNodes.has(id)) raiseNode(view, id);
delete view.session.box;
view.gestureLayout = undefined;
refreshLayout(view);
view.scheduler.request();
}
return;
}
if (view.session.linkDrag?.pointerId === event.pointerId) {
if (event.phase === "move") {
view.session.linkDrag.current = event.position;
const hit = hitTest(view.gestureLayout!, event.position, "input");
if (
hit.kind === "socket" &&
compatibleTargets(view.gestureLayout!, view.session.linkDrag.from).some((s) => s.id === hit.id)
)
view.session.linkDrag.candidate = hit.id;
else delete view.session.linkDrag.candidate;
view.scheduler.request();
} else {
const command = view.session.linkDrag.candidate
? planLink(
view.gestureLayout!,
view.session.linkDrag.from,
view.session.linkDrag.candidate,
linkId(`gesture-link-${state.version + 1}`),
)
: undefined;
cancelGesture(view);
if (command) gestureCommand(view, command);
else view.scheduler.request();
}
return;
}
if (view.session.resize?.pointerId === event.pointerId) {
if (event.phase === "move") {
const size = clampResize(
view.gestureLayout!,
view.session.resize.id,
viewToWorld(event.position, view.gestureLayout!.transform),
);
if (size) view.session.previewSizes.set(view.session.resize.id, size);
view.scheduler.request();
} else {
const id = view.session.resize.id,
size = view.session.previewSizes.get(id);
cancelGesture(view);
if (size) gestureCommand(view, { type: "node.resize", id, size });
else view.scheduler.request();
}
return;
}
if (view.session.drag?.pointerId === event.pointerId) {
if (event.phase === "move") {
const distance = Math.hypot(
event.position.x - view.session.drag.startView.x,
event.position.y - view.session.drag.startView.y,
);
if (distance >= 4) view.session.drag.moved = true;
if (view.session.drag.moved) previewMove(view, view.session.drag, event.position);
} else if (event.phase === "up") finishMove(view);
return;
}
}
function input(
view: WorkerView,
event: Extract["event"],
nodeMenuRequestId?: string,
resourceOpenRequestId?: string,
): void {
try {
processInput(view, event, nodeMenuRequestId, resourceOpenRequestId);
} finally {
publishSelection(view);
}
}
function previewMove(
view: WorkerView,
move: {
startWorld: { x: number; y: number };
origins: ReadonlyMap;
},
position: { x: number; y: number },
): void {
const layout = view.gestureLayout ?? view.currentLayout!;
const now = viewToWorld(position, layout.transform);
for (const [id, p] of move.origins)
view.session.previewPositions.set(id, { x: p.x + now.x - move.startWorld.x, y: p.y + now.y - move.startWorld.y });
const first = [...move.origins][0];
if (first) {
const n = layout.nodes.get(first[0]);
const preview = view.session.previewPositions.get(first[0]);
const target =
n && preview
? frameDropCandidate(layout, first[0], {
x: n.worldPosition.x + preview.x - n.localPosition.x + n.bounds.width / 2,
y: n.worldPosition.y + preview.y - n.localPosition.y - n.bounds.height / 2,
})
: undefined;
if (target) view.session.parentHighlight = target;
else delete view.session.parentHighlight;
}
view.scheduler.request();
}
function finishMove(view: WorkerView): void {
const layout = view.gestureLayout ?? view.currentLayout!;
const commands: import("../commands/types.js").BatchCommand[] = [...view.session.previewPositions].map(
([id, position]) => ({ type: "node.move", id, position }),
);
for (const [id] of view.session.previewPositions) {
const n = layout.nodes.get(id);
if (!n) continue;
const target = view.session.parentHighlight;
if ((target ?? undefined) !== (n.parentId ?? undefined))
commands.push({ type: "node.parent", id, parentId: target ?? null });
}
cancelGesture(view);
if (commands.length) gestureCommand(view, { type: "batch", commands });
else view.scheduler.request();
}
function resourceTarget(view: WorkerView, token: string, fresh = false) {
const control = (fresh && layoutStore ? layoutStore.view(currentTransform(view)) : view.currentLayout)?.controls.get(
token,
);
return control?.kind === "resource" && control.source === "parameter" ? control : undefined;
}
function closeResourceImages(): void {
for (const image of resourceImages.values()) image.bitmap.close();
resourceImages.clear();
resourceOperations.clear();
}
function trimResourceImages(): void {
let bytes = [...resourceImages.values()].reduce((sum, image) => sum + image.bytes, 0);
while (resourceImages.size > 16 || bytes > 128 * 1024 * 1024) {
const oldest = [...resourceImages].sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
if (!oldest) break;
oldest[1].bitmap.close();
bytes -= oldest[1].bytes;
resourceImages.delete(oldest[0]);
}
}
async function setResource(
view: WorkerView,
data: Extract,
): Promise {
applyPointerFence(view, data.pointerFence);
const { authorization, resource } = data;
if (authorization.viewId !== view.id) {
reject(data.id, { code: "resource.view-mismatch", message: "Resource authorization belongs to another view" });
return;
}
if (
!state ||
authorization.graphVersion !== state.version ||
authorization.compositionRevision !== compositionRevision
) {
reject(data.id, { code: "resource.stale", message: "The image authorization is stale" });
return;
}
if (data.expected.kind === "exact" && data.expected.version !== state.version) {
reject(data.id, { code: "version.stale", message: "Expected version does not match" });
return;
}
const decodeRevision = compositionRevision,
decodeGraphVersion = state.version,
target = resourceTarget(view, authorization.token, true),
targetNode = target && state.document.nodes[target.nodeId];
if (!target || !targetNode) {
reject(data.id, { code: "resource.stale", message: "The image target is no longer available" });
return;
}
const policy = target.resourceId && application?.compiled.resources.get(target.resourceId as never);
if (!policy) {
reject(data.id, { code: "resource.policy", message: "The image resource policy is unavailable" });
return;
}
if (resource.bytes.byteLength > Math.min(policy.maxBytes, 32 * 1024 * 1024)) {
reject(data.id, { code: "resource.bytes", message: "The selected file is too large" });
return;
}
const operation = { serial: ++resourceSerial, view };
resourceOperations.set(authorization.token, operation);
try {
await setResourceRegistered(
view,
data,
operation,
target,
targetNode,
policy,
state,
decodeRevision,
decodeGraphVersion,
);
} finally {
if (resourceOperations.get(authorization.token) === operation) resourceOperations.delete(authorization.token);
}
}
async function setResourceRegistered(
view: WorkerView,
data: Extract,
operation: { serial: number; view: WorkerView },
target: NonNullable>,
targetNode: BoundEngineState["document"]["nodes"][string],
policy: {
readonly maxWidth: number;
readonly maxHeight: number;
readonly maxPixels: number;
readonly referencePrefix: string;
},
decodeState: BoundEngineState,
decodeRevision: number,
decodeGraphVersion: number,
): Promise {
const { authorization, resource } = data,
sourceView = view;
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(new Blob([resource.bytes], { type: resource.mime || "application/octet-stream" }));
} catch {
reject(data.id, { code: "resource.decode", message: "The selected file is not a supported image" });
return;
}
const current = resourceTarget(view, authorization.token, true);
if (
views.get(sourceView.id) !== sourceView ||
state !== decodeState ||
decodeState.version !== decodeGraphVersion ||
decodeState.document.nodes[target.nodeId] !== targetNode ||
compositionRevision !== decodeRevision ||
resourceOperations.get(authorization.token) !== operation ||
!current ||
current.nodeId !== target.nodeId ||
current.key !== target.key ||
current.resourceId !== target.resourceId
) {
bitmap.close();
reject(data.id, { code: "resource.stale", message: "The image target changed while decoding" });
return;
}
if (
bitmap.width > Math.min(policy.maxWidth, 8192) ||
bitmap.height > Math.min(policy.maxHeight, 8192) ||
bitmap.width * bitmap.height > Math.min(policy.maxPixels, 16_777_216)
) {
bitmap.close();
reject(data.id, { code: "resource.dimensions", message: "The decoded image is too large" });
return;
}
const cancelled = cancelGesture(view);
publishSelection(view);
if (cancelled) view.scheduler.request();
const finalTarget = resourceTarget(view, authorization.token, true);
if (
!finalTarget ||
finalTarget.nodeId !== target.nodeId ||
finalTarget.key !== target.key ||
finalTarget.resourceId !== target.resourceId
) {
bitmap.close();
reject(data.id, { code: "resource.stale", message: "The image target changed before commit" });
return;
}
const reference = `${policy.referencePrefix}${decodeState.document.graphId}:${++resourceSerial}:${encodeURIComponent(resource.name)}`,
result = application!.runtime.transition(decodeState, {
commandId: commandId(data.id),
expectedVersion: decodeState.version,
source: "gesture",
command: {
type: "node.parameter",
id: target.nodeId,
key: target.key,
value: { kind: "string", value: reference },
},
});
if (result.status === "rejected") {
bitmap.close();
reject(data.id, result.error);
return;
}
if (result.status === "noop") {
bitmap.close();
post({
protocol: PROTOCOL_VERSION,
type: "response",
id: data.id,
ok: true,
value: { status: "noop", version: decodeState.version },
});
return;
}
commit(result, {
type: "node.parameter",
id: target.nodeId,
key: target.key,
value: { kind: "string", value: reference },
});
resourceImages.set(reference, {
bitmap,
name: resource.name,
bytes: bitmap.width * bitmap.height * 4,
lastUsed: performance.now(),
});
trimResourceImages();
post({
protocol: PROTOCOL_VERSION,
type: "response",
id: data.id,
ok: true,
value: { status: "committed", version: state.version },
});
}
function adoptHostGeneration(view: WorkerView, next: number): boolean {
if (next < view.hostGeneration) return false;
const advanced = next > view.hostGeneration;
view.hostGeneration = next;
if (advanced) view.scheduler.request(undefined, DirtyReason.HostInteraction);
return true;
}
function applyPointerSnapshot(
view: WorkerView,
snapshot: import("../browser/pointer-lane.js").PointerLaneSnapshot | undefined,
): void {
if (snapshot && snapshot.sequence !== view.consumedPointerSequence) {
view.consumedPointerSequence = snapshot.sequence;
if (!adoptHostGeneration(view, snapshot.hostGeneration)) return;
input(view, snapshot.event);
}
}
function applyPointerFence(view: WorkerView, fence: PointerFence | undefined): void {
if (!fence) return;
applyPointerSnapshot(view, fence.before);
view.handledPointerFence = fence.generation;
}
function pollPointerLane(view: WorkerView): void {
if (!view.pointerLane) return;
const generation = pointerLaneFence(view.pointerLane);
if (generation !== view.handledPointerFence) return;
const move = readPointerMove(view.pointerLane, view.consumedPointerSequence);
if (pointerLaneFence(view.pointerLane) !== generation) return;
applyPointerSnapshot(view, move);
}
type ApplicationAuthority = NonNullable;
interface StagedCompositionUpdate {
readonly baseRevision: number;
readonly revision: number;
readonly change: CompositionChange;
readonly baseGraphVersion: number;
readonly graphVersion: number;
readonly graphChanged: boolean;
readonly application: ApplicationAuthority;
readonly state: BoundEngineState;
readonly layoutStore: IndexedLayoutStore;
readonly mutationEnvelope?: BoundMutationEnvelope;
readonly snapshotEnvelope?: BoundSnapshotEnvelope;
}
function putCompositionEntry(
record: Readonly>,
id: string,
value: unknown,
): Readonly> {
const entries: Array = [];
let replaced = false;
for (const [key, current] of Object.entries(record)) {
if (key === id) {
entries.push([key, value]);
replaced = true;
} else entries.push([key, current]);
}
if (!replaced) entries.push([id, value]);
return nullRecord(entries);
}
function removeCompositionEntry(
record: Readonly>,
id: string,
): Readonly> {
return nullRecord(Object.entries(record).filter(([key]) => key !== id));
}
function patchCompositionSource(source: FxNodeCompositionData, update: CompositionUpdateWire): unknown {
switch (update.kind) {
case "composition.load":
return update.composition;
case "theme.set":
return { ...source, theme: update.theme };
case "header-styles.set":
return { ...source, nodeStyles: update.styles };
case "compatibility.set":
return { ...source, compatibility: update.compatibility };
case "socket.compose":
return { ...source, socketTypes: putCompositionEntry(source.socketTypes, update.id, update.definition) };
case "socket.remove":
return { ...source, socketTypes: removeCompositionEntry(source.socketTypes, update.id) };
case "node.compose":
return { ...source, nodes: putCompositionEntry(source.nodes, update.id, update.definition) };
case "node.remove":
return { ...source, nodes: removeCompositionEntry(source.nodes, update.id) };
}
}
const compositionChange = (update: CompositionUpdateWire): CompositionChange =>
update.kind === "theme.set" ||
update.kind === "header-styles.set" ||
update.kind === "composition.load" ||
update.kind === "compatibility.set"
? { kind: update.kind }
: { kind: update.kind, id: update.id };
type StageResult =
| { readonly status: "noop"; readonly receipt: CompositionReceipt }
| { readonly status: "rejected"; readonly error: WireError }
| { readonly status: "committed"; readonly staged: StagedCompositionUpdate };
function stageCompositionUpdate(data: Extract): StageResult {
const currentApplication = application!,
currentState = state!;
if (data.expected.kind === "exact" && data.expected.revision !== compositionRevision)
return {
status: "rejected",
error: toWireError({
code: "composition.revision.stale",
message: "Expected composition revision does not match",
}),
};
let candidateCompiled: CompiledFxNodeComposition;
try {
candidateCompiled = compileFxNodeComposition(
patchCompositionSource(currentApplication.compiled.source, data.update) as FxNodeCompositionData,
);
} catch (error) {
if (error instanceof FxNodeCompositionError)
return {
status: "rejected",
error: toWireError({ code: "composition.invalid", message: error.message, issues: error.issues }),
};
throw error;
}
if (canonicalJsonEqual(candidateCompiled.source, currentApplication.compiled.source))
return {
status: "noop",
receipt: {
status: "noop",
revision: compositionRevision,
graphVersion: currentState.version,
graphChanged: false,
historyReset: false,
},
};
if (compositionRevision >= Number.MAX_SAFE_INTEGER)
return {
status: "rejected",
error: toWireError({ code: "composition.revision.overflow", message: "Composition revision exhausted" }),
};
const removedNodeTypes =
data.update.kind === "composition.load"
? new Set(
Object.keys(currentApplication.compiled.source.nodes).filter(
(id) => !Object.hasOwn(candidateCompiled.source.nodes, id),
),
)
: data.update.kind === "node.remove"
? new Set([data.update.id])
: undefined;
const candidateRuntime = bindFxNodeHeadless(candidateCompiled),
rebound = rebindBoundEngineAuthority(currentState, currentApplication.runtime, candidateRuntime, {
commandId: commandId(data.id),
...(removedNodeTypes ? { removedNodeTypes } : {}),
});
if (!rebound.ok) {
const demotion = rebound.issues.some((issue) => issue.code === "composition.node-demotion"),
overflow = rebound.issues.some((issue) => issue.code === "version.overflow");
return {
status: "rejected",
error: toWireError({
code: demotion ? "composition.node-demotion" : overflow ? "version.overflow" : "composition.rebind.invalid",
message: demotion
? "Composition update would demote a known node"
: overflow
? "Graph version exhausted"
: "Current graph is incompatible with the composition update",
issues: rebound.issues,
}),
};
}
const candidateStore = new IndexedLayoutStore(candidateCompiled, rebound.state.document);
const revision = compositionRevision + 1;
return {
status: "committed",
staged: {
baseRevision: compositionRevision,
revision,
change: compositionChange(data.update),
baseGraphVersion: currentState.version,
graphVersion: rebound.state.version,
graphChanged: rebound.graphChanged,
application: { compiled: candidateCompiled, runtime: candidateRuntime },
state: rebound.state,
layoutStore: candidateStore,
...(rebound.graphChanged
? { mutationEnvelope: rebound.mutationEnvelope, snapshotEnvelope: rebound.snapshotEnvelope }
: {}),
},
};
}
function publishCompositionUpdate(id: string, staged: StagedCompositionUpdate): void {
for (const view of views.values()) cancelGesture(view);
application = staged.application;
state = staged.state;
layoutStore = staged.layoutStore;
compositionRevision = staged.revision;
const nodeIds = new Set(Object.keys(state.document.nodes) as import("../core/types.js").NodeId[]),
linkIds = new Set(Object.keys(state.document.links) as import("../core/types.js").LinkId[]);
for (const view of views.values()) {
const order = view.session.uiOrder.filter((nodeId) => nodeIds.has(nodeId));
resetSessionForCompositionRebind(view.session, nodeIds, linkIds, order);
view.gestureLayout = undefined;
view.currentLayout = applyNodeOrder(layoutStore.view(currentTransform(view)), order);
}
journal = checkpointJournal(application.runtime.save(state.document));
const envelope: CompositionChangeEnvelope = {
baseRevision: staged.baseRevision,
revision: staged.revision,
change: staged.change,
baseGraphVersion: staged.baseGraphVersion,
graphVersion: staged.graphVersion,
graphChanged: staged.graphChanged,
historyReset: true,
};
post({ protocol: PROTOCOL_VERSION, type: "composition.event", envelope });
if (staged.graphChanged) {
post({ protocol: PROTOCOL_VERSION, type: "mutation", envelope: staged.mutationEnvelope! });
post({ protocol: PROTOCOL_VERSION, type: "snapshot.event", envelope: staged.snapshotEnvelope! });
}
for (const view of views.values()) publishSelection(view);
for (const view of views.values()) view.scheduler.request();
post({
protocol: PROTOCOL_VERSION,
type: "response",
id,
ok: true,
value: {
status: "committed",
revision: staged.revision,
graphVersion: staged.graphVersion,
graphChanged: staged.graphChanged,
historyReset: true,
},
});
}
function recognizableInvalidRpc(value: unknown):
| {
readonly type:
| "init"
| "command"
| "load"
| "save.data"
| "state.set"
| "composition.update"
| "view.attach"
| "view.detach"
| "view.viewport"
| "view.node.add"
| "view.selection.remove"
| "view.selection.mute"
| "view.resource.set";
readonly id: string;
}
| undefined {
try {
if (typeof value !== "object" || value === null || (value as { protocol?: unknown }).protocol !== PROTOCOL_VERSION)
return;
const type = (value as { type?: unknown }).type,
id = (value as { id?: unknown }).id;
if (
(type !== "init" &&
type !== "command" &&
type !== "load" &&
type !== "save.data" &&
type !== "state.set" &&
type !== "composition.update" &&
type !== "view.attach" &&
type !== "view.detach" &&
type !== "view.viewport" &&
type !== "view.node.add" &&
type !== "view.selection.remove" &&
type !== "view.selection.mute" &&
type !== "view.resource.set") ||
typeof id !== "string" ||
!id.length ||
id.length > 512
)
return;
return { type, id };
} catch {
return;
}
}
async function handleMessage({ data }: MessageEvent): Promise {
if (!validRequest(data)) {
const recognizable = recognizableInvalidRpc(data);
if (recognizable) {
const candidate = data as { viewId?: unknown };
if (
recognizable.type.startsWith("view.") &&
recognizable.type !== "view.attach" &&
(typeof candidate.viewId !== "string" || !views.has(candidate.viewId))
) {
reject(recognizable.id, { code: "view.missing", message: "View is not attached" });
return;
}
reject(recognizable.id, {
code:
recognizable.type === "init"
? "init.invalid"
: recognizable.type === "command"
? "command.invalid"
: recognizable.type === "load"
? "layout.request.invalid"
: recognizable.type === "save.data"
? "save-data.request.invalid"
: recognizable.type === "state.set"
? "state.request.invalid"
: recognizable.type === "composition.update"
? "composition.request.invalid"
: recognizable.type === "view.viewport"
? "viewport.request.invalid"
: recognizable.type === "view.resource.set"
? "resource.request.invalid"
: "action.request.invalid",
message: `Invalid ${recognizable.type} request`,
});
return;
}
fatal(new Error("Invalid worker protocol message"), "protocol.invalid");
return;
}
try {
if (data.type === "init") {
if (application) {
reject(data.id, { code: "init.duplicate", message: "Worker is already initialized" });
return;
}
const compiled = createInitialFxNodeComposition(
data.applicationId as string,
data.applicationVersion as number,
data.resources as never,
),
runtime = bindFxNodeHeadless(compiled);
const nextState = runtime.createEngine(runtime.emptyDocument(), data.historyLimit),
nextStore = new IndexedLayoutStore(compiled, nextState.document);
application = { compiled, runtime };
state = nextState;
journal = checkpointJournal(runtime.save(nextState.document));
layoutStore = nextStore;
compositionRevision = 0;
post({ protocol: PROTOCOL_VERSION, type: "response", id: data.id, ok: true });
return;
}
if (data.type === "view.attach") {
if (!state) {
reject(data.id, { code: "init.required", message: "Worker is not initialized" });
return;
}
if (usedViewIds.has(data.viewId))
return reject(data.id, { code: "view.id.used", message: "View ID has already been used" });
usedViewIds.add(data.viewId);
let allocated = false;
try {
const device = fxNodeDevicePixels(data.viewport.width, data.viewport.height, data.viewport.dpr),
aggregate = [...views.values()].reduce((sum, item) => sum + item.deviceWidth * item.deviceHeight, 0);
if (
!device ||
views.size >= FXNODE_VIEW_LIMITS.maxViews ||
aggregate + device.width * device.height > FXNODE_VIEW_LIMITS.maxActiveDevicePixels
)
return reject(data.id, { code: "view.limit", message: "View rendering limits exceeded" });
if (
typeof OffscreenCanvas !== "function" ||
typeof ImageBitmap !== "function" ||
typeof ImageBitmap.prototype.close !== "function" ||
(data.pointerLane !== undefined && data.pointerLane.byteLength !== Int32Array.BYTES_PER_ELEMENT * 16)
)
return reject(data.id, {
code: "view.render.unavailable",
message: "Required view rendering capabilities are unavailable",
});
const mutation = await atlas.attach(data.viewId, device);
allocated = true;
requestViewInvalidations(mutation.invalidatedViewIds, views);
const session = createSession(data.camera);
let view!: WorkerView;
const scheduler = new RenderScheduler((frameId, renderId, reasons) => {
void drawView(view, frameId, renderId, reasons).catch(fatal);
});
view = {
id: data.viewId,
session,
viewport: data.viewport,
deviceWidth: device.width,
deviceHeight: device.height,
surfaceGeneration: 0,
resizePending: false,
currentLayout: undefined,
gestureLayout: undefined,
scheduler,
pointerLane: data.pointerLane,
handledPointerFence: 0,
consumedPointerSequence: 0,
hostGeneration: 0,
selectionSignature: "",
inFlightPixels: undefined,
};
view.currentLayout = layoutStore!.view(currentTransform(view));
views.set(view.id, view);
post({ protocol: PROTOCOL_VERSION, type: "response", id: data.id, ok: true });
publishSelection(view, true);
scheduler.request(1);
scheduler.start(() => pollPointerLane(view));
} catch (error) {
if (error instanceof ViewAtlasError) {
if (error.fatal) {
fatal(error, error.code);
return;
}
for (const item of views.values()) item.scheduler.request(0, DirtyReason.Viewport);
reject(data.id, { code: error.code, message: error.message });
return;
}
if (allocated)
try {
const cleanup = await atlas.detach(data.viewId);
requestViewInvalidations(cleanup.invalidatedViewIds, views);
} catch (cleanupError) {
fatal(cleanupError);
return;
}
reject(data.id, {
code: "view.attach.rejected",
message: error instanceof Error ? error.message : "Unable to attach view",
});
}
return;
}
if (!state) {
fatal(new Error("Worker is not initialized"));
return;
}
if (data.type === "view.detach") {
const view = views.get(data.viewId);
if (!view) return reject(data.id, { code: "view.missing", message: "View is not attached" });
cancelGesture(view);
view.scheduler.stop();
views.delete(data.viewId);
for (const [token, operation] of resourceOperations)
if (operation.view === view && resourceOperations.get(token) === operation) resourceOperations.delete(token);
let detach: Awaited>;
try {
detach = await atlas.detach(data.viewId);
} finally {
releaseFramePixels(view);
}
for (const id of detach.invalidatedViewIds) views.get(id)?.scheduler.request(0, DirtyReason.Viewport);
post({ protocol: PROTOCOL_VERSION, type: "response", id: data.id, ok: true });
return;
}
const view = "viewId" in data ? views.get(data.viewId) : undefined;
if ("viewId" in data && !view) {
if ("id" in data) reject(data.id, { code: "view.missing", message: "View is not attached" });
return;
}
if (data.type === "composition.update") {
const result = stageCompositionUpdate(data);
if (result.status === "rejected")
post({ protocol: PROTOCOL_VERSION, type: "response", id: data.id, ok: false, error: result.error });
else if (result.status === "noop")
post({ protocol: PROTOCOL_VERSION, type: "response", id: data.id, ok: true, value: result.receipt });
else publishCompositionUpdate(data.id, result.staged);
return;
}
if (data.type === "command") {
for (const item of views.values()) {
if (cancelGesture(item)) item.scheduler.request();
publishSelection(item);
}
const result = application!.runtime.transition(state, {
commandId: commandId(data.id),
expectedVersion: data.expected.kind === "current" ? state.version : data.expected.version,
source: "api",
command: data.command,
});
if (result.status === "rejected") reject(data.id, result.error);
else {
if (result.status === "committed") commit(result, data.command);
post({
protocol: PROTOCOL_VERSION,
type: "response",
id: data.id,
ok: true,
value: { status: result.status, version: state.version },
});
}
return;
}
if (data.type === "state.set") {
const result = application!.runtime.replaceState(state, {
commandId: commandId(data.id),
expectedVersion: data.expected.kind === "current" ? state.version : data.expected.version,
target: data.state as never,
});
if (result.status === "rejected") reject(data.id, result.error);
else {
if (result.status === "committed")
installDocumentReplacement(result, checkpointJournal(application!.runtime.save(result.state.document)));
post({
protocol: PROTOCOL_VERSION,
type: "response",
id: data.id,
ok: true,
value: { status: result.status, version: state.version },
});
}
return;
}
if (data.type === "view.node.add") {
applyPointerFence(view!, data.pointerFence);
const cancelled = cancelGesture(view!);
publishSelection(view!);
if (cancelled) view!.scheduler.request();
const command: Command = {
type: "node.add",
nodeId: nodeId(data.nodeId),
nodeType: data.typeId,
position: viewToWorld(data.viewPosition, currentTransform(view!)),
},
result = application!.runtime.transition(state, {
commandId: commandId(data.id),
expectedVersion: data.expected.kind === "current" ? state.version : data.expected.version,
source: "api",
command,
});
if (result.status === "rejected") reject(data.id, result.error);
else {
if (result.status === "committed")
commit(result, command, view!, { kind: "select-added", nodeId: data.nodeId });
post({
protocol: PROTOCOL_VERSION,
type: "response",
id: data.id,
ok: true,
value: { status: result.status, version: state.version },
});
}
return;
}
if (data.type === "view.selection.remove" || data.type === "view.selection.mute") {
applyPointerFence(view!, data.pointerFence);
const cancelled = cancelGesture(view!);
publishSelection(view!);
if (cancelled) view!.scheduler.request();
const command =
data.type === "view.selection.remove"
? planSelectionRemoval(state.document, view!.session.selectedNodes, view!.session.selectedLinks)
: planSelectionMute(state.document, view!.session.selectedNodes, isStandardNode, data.value);
if (!command) {
post({
protocol: PROTOCOL_VERSION,
type: "response",
id: data.id,
ok: true,
value: { status: "noop", version: state.version },
});
return;
}
const result = application!.runtime.transition(state, {
commandId: commandId(data.id),
expectedVersion: data.expected.kind === "current" ? state.version : data.expected.version,
source: "api",
command,
});
if (result.status === "rejected") reject(data.id, result.error);
else {
if (result.status === "committed") commit(result, command);
post({
protocol: PROTOCOL_VERSION,
type: "response",
id: data.id,
ok: true,
value: { status: result.status, version: state.version },
});
}
return;
}
if (data.type === "view.resource.set") {
void setResource(view!, data).catch((error) =>
reject(data.id, {
code: "resource.error",
message: error instanceof Error ? error.message : "Unable to load image",
}),
);
return;
}
if (data.type === "load") {
const expected = data.expected.kind === "current" ? state.version : data.expected.version,
isSaveData =
typeof data.data === "object" &&
data.data !== null &&
(data.data as { kind?: unknown }).kind === "fxnode.command-log",
result = isSaveData
? application!.runtime.replaySaveData(state, data.data, expected, commandId(data.id))
: application!.runtime.load(state, data.data, expected, commandId(data.id));
if (!result.ok)
reject(data.id, {
code: result.issues[0]?.code ?? "layout.invalid",
message: result.issues[0]?.message ?? "Invalid layout",
path: result.issues[0]?.path,
issues: result.issues,
});
else if (isSaveData && "status" in result && result.status === "noop") {
let cancelled = false;
for (const item of views.values()) cancelled = cancelGesture(item) || cancelled;
state = result.state;
journal = importJournal(result.saveData);
for (const item of views.values()) {
publishSelection(item);
if (cancelled) item.scheduler.request();
}
post({
protocol: PROTOCOL_VERSION,
type: "response",
id: data.id,
ok: true,
value: { status: "noop", version: state.version },
});
} else {
const committed = result as
| Extract
| Extract;
installDocumentReplacement(
committed,
"saveData" in committed
? importJournal(committed.saveData)
: checkpointJournal(application!.runtime.save(committed.state.document)),
);
post({
protocol: PROTOCOL_VERSION,
type: "response",
id: data.id,
ok: true,
value: { status: "committed", version: state.version },
});
}
return;
}
if (data.type === "state.get") {
post({
protocol: PROTOCOL_VERSION,
type: "response",
id: data.id,
ok: true,
value: application!.runtime.getState(state),
});
return;
}
if (data.type === "save") {
post({
protocol: PROTOCOL_VERSION,
type: "response",
id: data.id,
ok: true,
value: application!.runtime.save(state.document),
});
return;
}
if (data.type === "save.data") {
post({
protocol: PROTOCOL_VERSION,
type: "response",
id: data.id,
ok: true,
value: journalSaveData(journal!, application!.compiled.source),
});
return;
}
if (data.type === "view.viewport") {
const device = fxNodeDevicePixels(data.viewport.width, data.viewport.height, data.viewport.dpr),
aggregate = [...views.values()].reduce(
(sum, item) => sum + (item === view ? 0 : item.deviceWidth * item.deviceHeight),
0,
);
if (!device || aggregate + device.width * device.height > FXNODE_VIEW_LIMITS.maxActiveDevicePixels) {
reject(data.id, { code: "view.limit", message: "View rendering limits exceeded" });
return;
}
if (data.expectedSurfaceGeneration !== view!.surfaceGeneration + 1) {
reject(data.id, { code: "view.surface-generation", message: "Unexpected view surface generation" });
return;
}
if (
data.hostGeneration < view!.hostGeneration ||
(data.pointerFence?.before && data.pointerFence.before.hostGeneration > data.hostGeneration)
) {
reject(data.id, { code: "view.host-generation", message: "Stale view host generation" });
return;
}
applyPointerFence(view!, data.pointerFence);
if (!adoptHostGeneration(view!, data.hostGeneration)) {
reject(data.id, { code: "view.host-generation", message: "Stale view host generation" });
return;
}
view!.resizePending = true;
try {
const mutation = await atlas.resize(view!.id, device);
view!.viewport = data.viewport;
view!.deviceWidth = device.width;
view!.deviceHeight = device.height;
view!.surfaceGeneration = data.expectedSurfaceGeneration;
const invalidated = new Set(mutation.invalidatedViewIds);
invalidated.add(view!.id);
for (const id of invalidated) views.get(id)?.scheduler.request(0, DirtyReason.Viewport);
refreshLayout(view!);
post({ protocol: PROTOCOL_VERSION, type: "response", id: data.id, ok: true });
} catch (error) {
if (error instanceof ViewAtlasError && !error.fatal) {
for (const item of views.values()) item.scheduler.request(0, DirtyReason.Viewport);
reject(data.id, { code: error.code, message: error.message });
} else throw error;
} finally {
view!.resizePending = false;
}
return;
}
if (data.type === "view.render") {
applyPointerFence(view!, data.pointerFence);
if (!adoptHostGeneration(view!, data.hostGeneration)) return;
view!.scheduler.request(data.renderId, DirtyReason.Barrier);
return;
}
if (data.type === "view.frame.consumed") {
releaseFramePixels(view!, data.frameId);
view!.scheduler.consumed(data.frameId);
return;
}
if (data.type === "view.pointer.flush") {
applyPointerFence(view!, data.pointerFence);
return;
}
if (data.type === "dispose") {
for (const item of views.values()) {
item.scheduler.stop();
releaseFramePixels(item);
}
views.clear();
atlas.dispose();
closeResourceImages();
scope.close();
return;
}
if (data.type === "view.input") {
applyPointerFence(view!, data.pointerFence);
if (!adoptHostGeneration(view!, data.hostGeneration)) return;
input(view!, data.event, data.nodeMenuRequestId, data.resourceOpenRequestId);
}
} catch (error) {
if ((data.type === "init" || data.type === "composition.update") && error instanceof FxNodeCompositionError) {
reject(data.id, { code: "composition.invalid", message: error.message, issues: error.issues });
return;
}
fatal(error);
}
}
let viewLifecycleQueue: Promise = Promise.resolve();
const pendingViewLifecycle = new Map();
scope.onmessage = (event) => {
const type =
typeof event.data === "object" && event.data !== null ? (event.data as { type?: unknown }).type : undefined,
viewId =
typeof event.data === "object" &&
event.data !== null &&
typeof (event.data as { viewId?: unknown }).viewId === "string"
? (event.data as { viewId: string }).viewId
: undefined;
if (type === "view.attach" || type === "view.viewport" || type === "view.detach") {
if (viewId) pendingViewLifecycle.set(viewId, (pendingViewLifecycle.get(viewId) ?? 0) + 1);
viewLifecycleQueue = viewLifecycleQueue
.then(() => handleMessage(event))
.catch(fatal)
.finally(() => {
if (!viewId) return;
const remaining = (pendingViewLifecycle.get(viewId) ?? 1) - 1;
if (remaining) pendingViewLifecycle.set(viewId, remaining);
else pendingViewLifecycle.delete(viewId);
});
} else if (viewId && pendingViewLifecycle.has(viewId))
void viewLifecycleQueue.then(() => handleMessage(event)).catch(fatal);
else void handleMessage(event).catch(fatal);
};