feat: add render graph driven renderer architecture
Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
+154
@@ -0,0 +1,154 @@
|
||||
import type { FxNodeView } from "@lib/index.js";
|
||||
|
||||
/** Application-owned add-node menu used by the standalone examples. */
|
||||
|
||||
interface Point {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
export interface AddNodeMenu {
|
||||
open(viewPosition: Point): void;
|
||||
close(restoreCanvasFocus?: boolean): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
function cloneElement<T extends Element>(template: HTMLTemplateElement, selector: string): T {
|
||||
const element = template.content.firstElementChild?.cloneNode(true);
|
||||
if (!(element instanceof Element)) throw new Error(`Add-node menu template ${selector} is empty`);
|
||||
return element as T;
|
||||
}
|
||||
|
||||
export function createAddNodeMenu(
|
||||
template: HTMLTemplateElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
api: FxNodeView,
|
||||
onError: (error: unknown) => void,
|
||||
): AddNodeMenu {
|
||||
const entries = [...template.content.querySelectorAll<HTMLButtonElement>("button[data-fxnode-menu-option]")].map(
|
||||
(option) => {
|
||||
const typeId = option.dataset.typeId;
|
||||
const title = option.textContent?.trim();
|
||||
const group = option.closest<HTMLElement>("[data-fxnode-menu-group]");
|
||||
const groupTitle = group?.querySelector<HTMLElement>("[data-fxnode-menu-heading]")?.textContent?.trim();
|
||||
if (!typeId || !title || !group || !groupTitle) throw new Error("Add-node menu option is missing HTML context");
|
||||
return {
|
||||
typeId,
|
||||
searchText: `${title} ${groupTitle} ${typeId} ${option.dataset.keywords ?? ""}`.toLowerCase(),
|
||||
option,
|
||||
};
|
||||
},
|
||||
);
|
||||
if (!entries.length) throw new Error("Add-node menu HTML template has no node options");
|
||||
let host: HTMLDivElement | undefined;
|
||||
let removeListeners = () => {};
|
||||
|
||||
const close = (restore = false) => {
|
||||
if (!host) return;
|
||||
removeListeners();
|
||||
host.remove();
|
||||
host = undefined;
|
||||
if (restore) canvas.focus();
|
||||
};
|
||||
|
||||
const open = (viewPosition: Point) => {
|
||||
close();
|
||||
host = cloneElement<HTMLDivElement>(template, "root");
|
||||
const input = host.querySelector<HTMLInputElement>("[data-fxnode-menu-search]");
|
||||
const results = host.querySelector<HTMLDivElement>("[data-fxnode-menu-results]");
|
||||
const empty = host.querySelector<HTMLElement>("[data-fxnode-menu-empty]");
|
||||
const options = [...host.querySelectorAll<HTMLButtonElement>("button[data-fxnode-menu-option]")];
|
||||
if (!input || !results || !empty || options.length !== entries.length) {
|
||||
host.remove();
|
||||
host = undefined;
|
||||
throw new Error("Add-node menu HTML template is incomplete");
|
||||
}
|
||||
|
||||
let visible = entries.slice();
|
||||
let active = 0;
|
||||
const choose = (index: number) => {
|
||||
const item = visible[index];
|
||||
if (!item) return;
|
||||
close(true);
|
||||
void api.addNode({ typeId: item.typeId, viewPosition }).catch(onError);
|
||||
};
|
||||
const render = () => {
|
||||
entries.forEach((item, sourceIndex) => {
|
||||
const button = options[sourceIndex]!;
|
||||
const index = visible.indexOf(item);
|
||||
button.hidden = index < 0;
|
||||
if (index >= 0) {
|
||||
button.classList.toggle("active", index === active);
|
||||
button.id = `fxnode-node-option-${index}`;
|
||||
button.setAttribute("aria-selected", String(index === active));
|
||||
button.onpointerenter = () => {
|
||||
if (active !== index) {
|
||||
active = index;
|
||||
render();
|
||||
}
|
||||
};
|
||||
button.onclick = () => choose(index);
|
||||
}
|
||||
});
|
||||
for (const group of results.querySelectorAll<HTMLElement>("[data-fxnode-menu-group]"))
|
||||
group.hidden = !group.querySelector("button[data-fxnode-menu-option]:not([hidden])");
|
||||
empty.hidden = visible.length > 0;
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
input.setAttribute("aria-activedescendant", visible.length ? `fxnode-node-option-${active}` : "");
|
||||
};
|
||||
input.oninput = () => {
|
||||
const query = input.value.trim().toLowerCase();
|
||||
visible = entries.filter((item) => !query || item.searchText.includes(query));
|
||||
active = 0;
|
||||
render();
|
||||
};
|
||||
input.onkeydown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close(true);
|
||||
return;
|
||||
}
|
||||
if (!visible.length) return;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") {
|
||||
event.preventDefault();
|
||||
active =
|
||||
event.key === "Home"
|
||||
? 0
|
||||
: event.key === "End"
|
||||
? visible.length - 1
|
||||
: (active + (event.key === "ArrowDown" ? 1 : -1) + visible.length) % visible.length;
|
||||
render();
|
||||
host?.querySelector(`#fxnode-node-option-${active}`)?.scrollIntoView({ block: "nearest" });
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
choose(active);
|
||||
}
|
||||
};
|
||||
|
||||
render();
|
||||
document.body.append(host);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const menu = host.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
host.style.left = `${Math.max(margin, Math.min(rect.left + viewPosition.x, window.innerWidth - menu.width - margin))}px`;
|
||||
host.style.top = `${Math.max(margin, Math.min(rect.top + viewPosition.y, window.innerHeight - menu.height - margin))}px`;
|
||||
host.style.visibility = "visible";
|
||||
input.focus();
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (host && !event.composedPath().includes(host)) close(false);
|
||||
};
|
||||
const blur = () => close(false);
|
||||
const scroll = () => close(false);
|
||||
document.addEventListener("pointerdown", outside, true);
|
||||
window.addEventListener("blur", blur);
|
||||
window.addEventListener("scroll", scroll, true);
|
||||
removeListeners = () => {
|
||||
document.removeEventListener("pointerdown", outside, true);
|
||||
window.removeEventListener("blur", blur);
|
||||
window.removeEventListener("scroll", scroll, true);
|
||||
removeListeners = () => {};
|
||||
};
|
||||
};
|
||||
|
||||
return { open, close, destroy: () => close(false) };
|
||||
}
|
||||
+439
@@ -0,0 +1,439 @@
|
||||
import type { FxNode, FxNodeModifiers, FxNodeResourceOpenRequest, FxNodeView, FxNodeViewport } from "@lib/index.js";
|
||||
import { createAddNodeMenu, type AddNodeMenu } from "./add-node-menu.js";
|
||||
|
||||
/** Explicit DOM host adapter; callers own attachment and cleanup. */
|
||||
|
||||
export interface FxNodeBrowserHostOptions {
|
||||
readonly canvas: HTMLCanvasElement;
|
||||
readonly lifecycle?: "explicit" | "detach-on-disconnect";
|
||||
readonly addNodeMenuTemplate?: HTMLTemplateElement;
|
||||
readonly activateResourcePicker?: (request: FxNodeResourceOpenRequest) => void | Promise<void>;
|
||||
readonly onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface PreparedFxNodeBrowserHost {
|
||||
readonly initialViewport: FxNodeViewport;
|
||||
readonly currentViewport: FxNodeViewport;
|
||||
attach(root: FxNode, view: FxNodeView): void;
|
||||
syncViewport(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
const INPUT_EVENTS = [
|
||||
"pointerdown",
|
||||
"pointermove",
|
||||
"pointerup",
|
||||
"pointercancel",
|
||||
"mousedown",
|
||||
"wheel",
|
||||
"keydown",
|
||||
"keyup",
|
||||
"focus",
|
||||
"blur",
|
||||
] as const;
|
||||
const activeHosts = new WeakMap<HTMLCanvasElement, PreparedFxNodeBrowserHost>();
|
||||
type DisconnectRegistration = { canvas: HTMLCanvasElement; disconnected(): void };
|
||||
const disconnectRegistries = new WeakMap<
|
||||
Document,
|
||||
{ observer: MutationObserver; registrations: Set<DisconnectRegistration> }
|
||||
>();
|
||||
|
||||
function watchDisconnect(documentValue: Document, registration: DisconnectRegistration): () => void {
|
||||
let registry = disconnectRegistries.get(documentValue);
|
||||
if (!registry) {
|
||||
const registrations = new Set<DisconnectRegistration>();
|
||||
const Observer = documentValue.defaultView?.MutationObserver;
|
||||
if (!Observer) throw new Error("detach-on-disconnect requires a connected canvas and MutationObserver");
|
||||
const observer = new Observer(() => {
|
||||
for (const candidate of registrations) {
|
||||
if (candidate.canvas.ownerDocument === documentValue && candidate.canvas.isConnected) continue;
|
||||
queueMicrotask(() => {
|
||||
if (
|
||||
registrations.has(candidate) &&
|
||||
(candidate.canvas.ownerDocument !== documentValue || !candidate.canvas.isConnected)
|
||||
)
|
||||
candidate.disconnected();
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(documentValue, { childList: true, subtree: true });
|
||||
registry = { observer, registrations };
|
||||
disconnectRegistries.set(documentValue, registry);
|
||||
}
|
||||
registry.registrations.add(registration);
|
||||
return () => {
|
||||
registry!.registrations.delete(registration);
|
||||
if (registry!.registrations.size === 0) {
|
||||
registry!.observer.disconnect();
|
||||
disconnectRegistries.delete(documentValue);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function measureViewport(canvas: HTMLCanvasElement, devicePixelRatio: number): FxNodeViewport {
|
||||
const dpr = Math.min(4, Math.max(1, devicePixelRatio || 1)),
|
||||
maxLogicalDimension = Math.floor(8192 / dpr),
|
||||
maxLogicalPixels = Math.floor(16_777_216 / (dpr * dpr)),
|
||||
width = Math.min(maxLogicalDimension, Math.max(1, canvas.clientWidth)),
|
||||
height = Math.min(maxLogicalDimension, Math.floor(maxLogicalPixels / width), Math.max(1, canvas.clientHeight));
|
||||
return { width, height, dpr };
|
||||
}
|
||||
|
||||
function sameViewport(left: FxNodeViewport, right: FxNodeViewport): boolean {
|
||||
return left.width === right.width && left.height === right.height && left.dpr === right.dpr;
|
||||
}
|
||||
function sizeCanvas(canvas: HTMLCanvasElement, viewport: FxNodeViewport): void {
|
||||
const width = Math.max(1, Math.round(viewport.width * viewport.dpr)),
|
||||
height = Math.max(1, Math.round(viewport.height * viewport.dpr));
|
||||
if (canvas.width !== width) canvas.width = width;
|
||||
if (canvas.height !== height) canvas.height = height;
|
||||
}
|
||||
function modifiers(event: MouseEvent | KeyboardEvent): FxNodeModifiers {
|
||||
return { alt: event.altKey, control: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey };
|
||||
}
|
||||
export function prepareFxNodeBrowserHost({
|
||||
canvas,
|
||||
lifecycle = "explicit",
|
||||
addNodeMenuTemplate,
|
||||
activateResourcePicker,
|
||||
onError = console.error,
|
||||
}: FxNodeBrowserHostOptions): PreparedFxNodeBrowserHost {
|
||||
if (activeHosts.has(canvas)) throw new Error("Canvas already has an active FxNode browser host");
|
||||
if (
|
||||
lifecycle === "detach-on-disconnect" &&
|
||||
(!canvas.isConnected || !canvas.ownerDocument.defaultView?.MutationObserver)
|
||||
)
|
||||
throw new Error("detach-on-disconnect requires a connected canvas and MutationObserver");
|
||||
const ownerDocument = canvas.ownerDocument,
|
||||
ownerWindow = ownerDocument.defaultView ?? window,
|
||||
report = (error: unknown) => {
|
||||
try {
|
||||
onError(error);
|
||||
} catch (reportError) {
|
||||
console.error("FxNode browser host error callback failed", reportError);
|
||||
}
|
||||
};
|
||||
const originalTabIndex = canvas.getAttribute("tabindex"),
|
||||
originalTouchAction = canvas.style.touchAction;
|
||||
let viewport = measureViewport(canvas, ownerWindow.devicePixelRatio);
|
||||
sizeCanvas(canvas, viewport);
|
||||
let view: FxNodeView | undefined,
|
||||
active = true,
|
||||
attached = false,
|
||||
lifecycleGeneration = 0,
|
||||
authorization: { request: FxNodeResourceOpenRequest; generation: number } | undefined,
|
||||
observer: ResizeObserver | undefined;
|
||||
let unwatchDisconnect: (() => void) | undefined,
|
||||
pendingViewport: FxNodeViewport | undefined,
|
||||
resizeInFlight = false;
|
||||
let changedTabIndex = false,
|
||||
appliedTabIndex: string | null = null,
|
||||
changedTouchAction = false,
|
||||
pickerGeneration = 0,
|
||||
menuPending = false;
|
||||
const capturedPointers = new Set<number>(),
|
||||
subscriptions = new Set<() => void>();
|
||||
let menu: AddNodeMenu | undefined;
|
||||
let resourceFile: HTMLInputElement | undefined;
|
||||
|
||||
const defaultPicker = (request: FxNodeResourceOpenRequest) => {
|
||||
if (!resourceFile) {
|
||||
resourceFile = ownerDocument.createElement("input");
|
||||
resourceFile.type = "file";
|
||||
resourceFile.hidden = true;
|
||||
resourceFile.dataset.fxnodeResourceFile = "";
|
||||
ownerDocument.body.append(resourceFile);
|
||||
resourceFile.addEventListener("change", resourceChanged);
|
||||
}
|
||||
const generation = ++pickerGeneration;
|
||||
authorization = { request, generation };
|
||||
resourceFile.accept = request.resource.accept.join(",");
|
||||
resourceFile.value = "";
|
||||
resourceFile.click();
|
||||
};
|
||||
const resourceChanged = () => {
|
||||
const pending = authorization,
|
||||
file = resourceFile?.files?.[0];
|
||||
authorization = undefined;
|
||||
if (!pending || !file || !view) return;
|
||||
void file
|
||||
.arrayBuffer()
|
||||
.then((bytes) => {
|
||||
if (active && view && pending.generation === pickerGeneration)
|
||||
return view.provideResource(pending.request.authorization, { name: file.name, mime: file.type, bytes });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active && pending.generation === pickerGeneration) report(error);
|
||||
});
|
||||
};
|
||||
const activate = activateResourcePicker ?? defaultPicker;
|
||||
const position = (event: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||
};
|
||||
|
||||
const input = (event: Event) => {
|
||||
if (!active || !view) return;
|
||||
if (event instanceof PointerEvent) {
|
||||
const phase =
|
||||
event.type === "pointerdown"
|
||||
? "down"
|
||||
: event.type === "pointermove"
|
||||
? "move"
|
||||
: event.type === "pointerup"
|
||||
? "up"
|
||||
: "cancel";
|
||||
const point = position(event);
|
||||
if (phase === "down") {
|
||||
menu?.close(false);
|
||||
menuPending = event.button === 2 && !event.ctrlKey && (event.buttons & 1) === 0;
|
||||
canvas.focus();
|
||||
try {
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
capturedPointers.add(event.pointerId);
|
||||
} catch {
|
||||
/* unsupported or detached */
|
||||
}
|
||||
}
|
||||
if ((phase === "up" || phase === "cancel") && capturedPointers.delete(event.pointerId))
|
||||
try {
|
||||
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
/* detached */
|
||||
}
|
||||
view.feedInput({
|
||||
kind: "pointer",
|
||||
phase,
|
||||
pointerId: event.pointerId,
|
||||
pointerType: event.pointerType,
|
||||
position: point,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
modifiers: modifiers(event),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event instanceof WheelEvent) {
|
||||
event.preventDefault();
|
||||
menu?.close(false);
|
||||
menuPending = false;
|
||||
const rect = canvas.getBoundingClientRect(),
|
||||
scale =
|
||||
event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
||||
? 16
|
||||
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
||||
? Math.max(1, rect.height)
|
||||
: 1;
|
||||
view.feedInput({
|
||||
kind: "wheel",
|
||||
position: { x: event.clientX - rect.left, y: event.clientY - rect.top },
|
||||
delta: { x: event.deltaX * scale, y: event.deltaY * scale },
|
||||
modifiers: modifiers(event),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event instanceof MouseEvent) {
|
||||
if (event.button !== 2 || (event.buttons & 1) === 0) return;
|
||||
menuPending = false;
|
||||
view.feedInput({
|
||||
kind: "pointer",
|
||||
phase: "down",
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
position: position(event),
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
modifiers: modifiers(event),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event instanceof KeyboardEvent) {
|
||||
menu?.close(false);
|
||||
menuPending = false;
|
||||
view.feedInput({
|
||||
kind: "key",
|
||||
phase: event.type === "keydown" ? "down" : "up",
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
repeat: event.repeat,
|
||||
modifiers: modifiers(event),
|
||||
});
|
||||
return;
|
||||
}
|
||||
view.feedInput({ kind: "focus", phase: event.type === "focus" ? "focus" : "blur" });
|
||||
};
|
||||
const contextMenu = (event: Event) => event.preventDefault();
|
||||
const lostCapture = (event: PointerEvent) => capturedPointers.delete(event.pointerId);
|
||||
const outsidePointer = (event: PointerEvent) => {
|
||||
if (
|
||||
active &&
|
||||
view &&
|
||||
event.button === 0 &&
|
||||
view.getHostSnapshot().colorPickerOpen &&
|
||||
event.target !== canvas &&
|
||||
!canvas.contains(event.target as Node)
|
||||
) {
|
||||
menuPending = false;
|
||||
view.feedInput({ kind: "outside-pointer", button: 0 });
|
||||
}
|
||||
};
|
||||
const pumpViewport = () => {
|
||||
if (!active || !view || resizeInFlight || !pendingViewport) return;
|
||||
const next = pendingViewport,
|
||||
generation = lifecycleGeneration;
|
||||
pendingViewport = undefined;
|
||||
if (sameViewport(viewport, next)) {
|
||||
pumpViewport();
|
||||
return;
|
||||
}
|
||||
resizeInFlight = true;
|
||||
menu?.close(false);
|
||||
menuPending = false;
|
||||
void Promise.resolve(view.setViewport(next))
|
||||
.then(() => {
|
||||
if (!active || generation !== lifecycleGeneration) return;
|
||||
viewport = next;
|
||||
sizeCanvas(canvas, next);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active && generation === lifecycleGeneration) report(error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!active || generation !== lifecycleGeneration) return;
|
||||
resizeInFlight = false;
|
||||
pumpViewport();
|
||||
});
|
||||
};
|
||||
const syncViewport = () => {
|
||||
if (!active) return;
|
||||
const next = measureViewport(canvas, ownerWindow.devicePixelRatio);
|
||||
if (view) {
|
||||
pendingViewport = next;
|
||||
pumpViewport();
|
||||
} else {
|
||||
viewport = next;
|
||||
sizeCanvas(canvas, next);
|
||||
}
|
||||
};
|
||||
const resize = () => syncViewport();
|
||||
const host: PreparedFxNodeBrowserHost = {
|
||||
initialViewport: viewport,
|
||||
get currentViewport() {
|
||||
return viewport;
|
||||
},
|
||||
attach(rootValue, viewValue) {
|
||||
if (!active) throw new Error("FxNode browser host has been destroyed");
|
||||
if (attached) throw new Error("FxNode browser host is already attached");
|
||||
if (lifecycle === "detach-on-disconnect" && (!canvas.isConnected || canvas.ownerDocument !== ownerDocument)) {
|
||||
host.destroy();
|
||||
throw new Error("detach-on-disconnect requires the canvas to remain connected to its original document");
|
||||
}
|
||||
attached = true;
|
||||
view = viewValue;
|
||||
try {
|
||||
if (addNodeMenuTemplate) menu = createAddNodeMenu(addNodeMenuTemplate, canvas, viewValue, report);
|
||||
if (canvas.tabIndex < 0) {
|
||||
canvas.tabIndex = 0;
|
||||
changedTabIndex = true;
|
||||
appliedTabIndex = canvas.getAttribute("tabindex");
|
||||
}
|
||||
if (canvas.style.touchAction !== "none") {
|
||||
canvas.style.touchAction = "none";
|
||||
changedTouchAction = true;
|
||||
}
|
||||
observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(resize);
|
||||
for (const name of INPUT_EVENTS) canvas.addEventListener(name, input, { passive: name !== "wheel" });
|
||||
canvas.addEventListener("contextmenu", contextMenu);
|
||||
canvas.addEventListener("lostpointercapture", lostCapture);
|
||||
ownerDocument.addEventListener("pointerdown", outsidePointer, true);
|
||||
ownerWindow.addEventListener("resize", resize);
|
||||
observer?.observe(canvas);
|
||||
if (lifecycle === "detach-on-disconnect")
|
||||
unwatchDisconnect = watchDisconnect(ownerDocument, {
|
||||
canvas,
|
||||
disconnected() {
|
||||
const detachedView = view;
|
||||
host.destroy();
|
||||
void detachedView?.detach().catch(report);
|
||||
},
|
||||
});
|
||||
subscriptions.add(
|
||||
viewValue.onHostRequests((request) => {
|
||||
if (request.kind === "add-node-menu") {
|
||||
if (!menuPending) return;
|
||||
menuPending = false;
|
||||
menu?.open(request.viewPosition);
|
||||
return;
|
||||
}
|
||||
menuPending = false;
|
||||
menu?.close(false);
|
||||
try {
|
||||
void Promise.resolve(activate(request)).catch((error) => {
|
||||
if (active) report(error);
|
||||
});
|
||||
} catch (error) {
|
||||
if (active) report(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
const closeMenu = () => {
|
||||
menuPending = false;
|
||||
menu?.close(false);
|
||||
};
|
||||
const invalidatePicker = () => {
|
||||
pickerGeneration++;
|
||||
authorization = undefined;
|
||||
closeMenu();
|
||||
};
|
||||
subscriptions.add(rootValue.onCompositionChanges(invalidatePicker));
|
||||
subscriptions.add(rootValue.onMutations(invalidatePicker));
|
||||
syncViewport();
|
||||
} catch (error) {
|
||||
host.destroy();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
syncViewport,
|
||||
destroy() {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
lifecycleGeneration++;
|
||||
pendingViewport = undefined;
|
||||
unwatchDisconnect?.();
|
||||
unwatchDisconnect = undefined;
|
||||
for (const unsubscribe of subscriptions) unsubscribe();
|
||||
subscriptions.clear();
|
||||
pickerGeneration++;
|
||||
menuPending = false;
|
||||
observer?.disconnect();
|
||||
ownerWindow.removeEventListener("resize", resize);
|
||||
ownerDocument.removeEventListener("pointerdown", outsidePointer, true);
|
||||
for (const name of INPUT_EVENTS) canvas.removeEventListener(name, input);
|
||||
canvas.removeEventListener("contextmenu", contextMenu);
|
||||
canvas.removeEventListener("lostpointercapture", lostCapture);
|
||||
for (const pointerId of capturedPointers)
|
||||
try {
|
||||
if (canvas.hasPointerCapture(pointerId)) canvas.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
/* detached */
|
||||
}
|
||||
capturedPointers.clear();
|
||||
authorization = undefined;
|
||||
menu?.destroy();
|
||||
if (resourceFile) {
|
||||
resourceFile.removeEventListener("change", resourceChanged);
|
||||
resourceFile.remove();
|
||||
resourceFile = undefined;
|
||||
}
|
||||
if (changedTouchAction && canvas.style.touchAction === "none") canvas.style.touchAction = originalTouchAction;
|
||||
if (changedTabIndex && canvas.getAttribute("tabindex") === appliedTabIndex) {
|
||||
if (originalTabIndex === null) canvas.removeAttribute("tabindex");
|
||||
else canvas.setAttribute("tabindex", originalTabIndex);
|
||||
}
|
||||
if (activeHosts.get(canvas) === host) activeHosts.delete(canvas);
|
||||
view = undefined;
|
||||
},
|
||||
};
|
||||
activeHosts.set(canvas, host);
|
||||
return host;
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
background: #101216;
|
||||
color: #f1f3f5;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
main {
|
||||
width: 1000px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 24px;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 14px;
|
||||
color: #adb5bd;
|
||||
line-height: 1.45;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 1000px;
|
||||
height: 560px;
|
||||
background: #1d1f23;
|
||||
}
|
||||
button {
|
||||
margin: 0 0 14px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
#status {
|
||||
color: #ffd8a8;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import type { CompositionReceipt, FxNode, FxNodeView } from "@lib/index.js";
|
||||
import type { PreparedFxNodeBrowserHost } from "./browser-host.js";
|
||||
|
||||
declare global {
|
||||
interface StandaloneExampleHandle {
|
||||
root: FxNode | null;
|
||||
view: FxNodeView | null;
|
||||
host: PreparedFxNodeBrowserHost;
|
||||
ready: Promise<void>;
|
||||
graphVersion?: number;
|
||||
lastCompositionReceipt?: CompositionReceipt;
|
||||
cleanup(): void;
|
||||
}
|
||||
interface Window {
|
||||
fxnodeStandalone: StandaloneExampleHandle;
|
||||
fxnodeMultiView: MultiViewExampleHandle;
|
||||
}
|
||||
interface MultiViewExampleHandle {
|
||||
root: FxNode | null;
|
||||
views: FxNodeView[];
|
||||
ready: Promise<void>;
|
||||
cleanup(): Promise<void>;
|
||||
renderCounts: number[];
|
||||
}
|
||||
}
|
||||
export {};
|
||||
@@ -0,0 +1,352 @@
|
||||
import type { FxNodeDefinition } from "@lib/index.js";
|
||||
/** Shared canonical presentation/schema example; it does not evaluate pixels. */
|
||||
export const colorBalanceNode = [
|
||||
"fxnode.compositor.color-balance",
|
||||
{
|
||||
version: 1,
|
||||
title: "Color Balance",
|
||||
behavior: "standard",
|
||||
style: "compositorColor",
|
||||
parameters: {
|
||||
mode: {
|
||||
type: "string",
|
||||
default: {
|
||||
kind: "string",
|
||||
value: "Lift/Gamma/Gain",
|
||||
},
|
||||
enum: ["Lift/Gamma/Gain", "Offset/Power/Slope", "White Point"],
|
||||
},
|
||||
lift: {
|
||||
type: "number",
|
||||
default: {
|
||||
kind: "number",
|
||||
value: 0,
|
||||
},
|
||||
},
|
||||
liftColor: {
|
||||
type: "color",
|
||||
default: {
|
||||
kind: "color",
|
||||
value: [1, 1, 1, 1],
|
||||
},
|
||||
},
|
||||
gamma: {
|
||||
type: "number",
|
||||
default: {
|
||||
kind: "number",
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
gammaColor: {
|
||||
type: "color",
|
||||
default: {
|
||||
kind: "color",
|
||||
value: [1, 1, 1, 1],
|
||||
},
|
||||
},
|
||||
gain: {
|
||||
type: "number",
|
||||
default: {
|
||||
kind: "number",
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
gainColor: {
|
||||
type: "color",
|
||||
default: {
|
||||
kind: "color",
|
||||
value: [1, 1, 1, 1],
|
||||
},
|
||||
},
|
||||
offset: {
|
||||
type: "number",
|
||||
default: {
|
||||
kind: "number",
|
||||
value: 0,
|
||||
},
|
||||
},
|
||||
offsetColor: {
|
||||
type: "color",
|
||||
default: {
|
||||
kind: "color",
|
||||
value: [1, 1, 1, 1],
|
||||
},
|
||||
},
|
||||
power: {
|
||||
type: "number",
|
||||
default: {
|
||||
kind: "number",
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
powerColor: {
|
||||
type: "color",
|
||||
default: {
|
||||
kind: "color",
|
||||
value: [1, 1, 1, 1],
|
||||
},
|
||||
},
|
||||
slope: {
|
||||
type: "number",
|
||||
default: {
|
||||
kind: "number",
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
slopeColor: {
|
||||
type: "color",
|
||||
default: {
|
||||
kind: "color",
|
||||
value: [1, 1, 1, 1],
|
||||
},
|
||||
},
|
||||
inputTemperature: {
|
||||
type: "number",
|
||||
default: {
|
||||
kind: "number",
|
||||
value: 6500,
|
||||
},
|
||||
},
|
||||
inputTint: {
|
||||
type: "number",
|
||||
default: {
|
||||
kind: "number",
|
||||
value: 0,
|
||||
},
|
||||
},
|
||||
inputColor: {
|
||||
type: "color",
|
||||
default: {
|
||||
kind: "color",
|
||||
value: [1, 1, 1, 1],
|
||||
},
|
||||
},
|
||||
outputTemperature: {
|
||||
type: "number",
|
||||
default: {
|
||||
kind: "number",
|
||||
value: 6500,
|
||||
},
|
||||
},
|
||||
outputTint: {
|
||||
type: "number",
|
||||
default: {
|
||||
kind: "number",
|
||||
value: 0,
|
||||
},
|
||||
},
|
||||
outputColor: {
|
||||
type: "color",
|
||||
default: {
|
||||
kind: "color",
|
||||
value: [1, 1, 1, 1],
|
||||
},
|
||||
},
|
||||
},
|
||||
sockets: {
|
||||
image: {
|
||||
title: "Image",
|
||||
direction: "input",
|
||||
type: "color",
|
||||
maxIncomingLinks: 1,
|
||||
visible: true,
|
||||
value: {
|
||||
type: "color",
|
||||
default: {
|
||||
kind: "color",
|
||||
value: [0.8, 0.8, 0.8, 1],
|
||||
},
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
},
|
||||
showValue: true,
|
||||
},
|
||||
factor: {
|
||||
title: "Factor",
|
||||
direction: "input",
|
||||
type: "float",
|
||||
maxIncomingLinks: 1,
|
||||
visible: true,
|
||||
value: {
|
||||
type: "number",
|
||||
default: {
|
||||
kind: "number",
|
||||
value: 1,
|
||||
},
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
},
|
||||
showValue: true,
|
||||
},
|
||||
result: {
|
||||
title: "Image",
|
||||
direction: "output",
|
||||
type: "color",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [
|
||||
{
|
||||
kind: "parameter",
|
||||
parameter: "mode",
|
||||
},
|
||||
{
|
||||
kind: "widget",
|
||||
widget: "grading-wheels",
|
||||
bindings: [
|
||||
{
|
||||
title: "Lift",
|
||||
scalar: "lift",
|
||||
color: "liftColor",
|
||||
},
|
||||
{
|
||||
title: "Gamma",
|
||||
scalar: "gamma",
|
||||
color: "gammaColor",
|
||||
},
|
||||
{
|
||||
title: "Gain",
|
||||
scalar: "gain",
|
||||
color: "gainColor",
|
||||
},
|
||||
],
|
||||
visibleWhen: {
|
||||
parameter: "mode",
|
||||
equals: "Lift/Gamma/Gain",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "widget",
|
||||
widget: "grading-wheels",
|
||||
bindings: [
|
||||
{
|
||||
title: "Offset",
|
||||
scalar: "offset",
|
||||
color: "offsetColor",
|
||||
},
|
||||
{
|
||||
title: "Power",
|
||||
scalar: "power",
|
||||
color: "powerColor",
|
||||
},
|
||||
{
|
||||
title: "Slope",
|
||||
scalar: "slope",
|
||||
color: "slopeColor",
|
||||
},
|
||||
],
|
||||
visibleWhen: {
|
||||
parameter: "mode",
|
||||
equals: "Offset/Power/Slope",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "text",
|
||||
variant: "section",
|
||||
title: "Input",
|
||||
visibleWhen: {
|
||||
parameter: "mode",
|
||||
equals: "White Point",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "parameter",
|
||||
parameter: "inputTemperature",
|
||||
title: "Temperature",
|
||||
visibleWhen: {
|
||||
parameter: "mode",
|
||||
equals: "White Point",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "parameter",
|
||||
parameter: "inputTint",
|
||||
title: "Tint",
|
||||
visibleWhen: {
|
||||
parameter: "mode",
|
||||
equals: "White Point",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "parameter",
|
||||
parameter: "inputColor",
|
||||
title: "Color",
|
||||
visibleWhen: {
|
||||
parameter: "mode",
|
||||
equals: "White Point",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "text",
|
||||
variant: "placeholder",
|
||||
title: "Eyedropper (host bridge)",
|
||||
visibleWhen: {
|
||||
parameter: "mode",
|
||||
equals: "White Point",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "text",
|
||||
variant: "section",
|
||||
title: "Output",
|
||||
visibleWhen: {
|
||||
parameter: "mode",
|
||||
equals: "White Point",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "parameter",
|
||||
parameter: "outputTemperature",
|
||||
title: "Temperature",
|
||||
visibleWhen: {
|
||||
parameter: "mode",
|
||||
equals: "White Point",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "parameter",
|
||||
parameter: "outputTint",
|
||||
title: "Tint",
|
||||
visibleWhen: {
|
||||
parameter: "mode",
|
||||
equals: "White Point",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "parameter",
|
||||
parameter: "outputColor",
|
||||
title: "Color",
|
||||
visibleWhen: {
|
||||
parameter: "mode",
|
||||
equals: "White Point",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "text",
|
||||
variant: "placeholder",
|
||||
title: "Eyedropper (host bridge)",
|
||||
visibleWhen: {
|
||||
parameter: "mode",
|
||||
equals: "White Point",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "socket",
|
||||
socket: "image",
|
||||
},
|
||||
{
|
||||
kind: "socket",
|
||||
socket: "factor",
|
||||
},
|
||||
{
|
||||
kind: "socket",
|
||||
socket: "result",
|
||||
},
|
||||
],
|
||||
muteBypass: [["image", "result"]],
|
||||
migrations: [],
|
||||
},
|
||||
] as const satisfies readonly [string, FxNodeDefinition];
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import type { FxNodeTheme } from "@lib/index.js";
|
||||
export const exampleTheme = {
|
||||
background: "#1d1f23",
|
||||
grid: "#33363c",
|
||||
frame: "#30343a80",
|
||||
frameHeader: "#59616c",
|
||||
body: "#35383e",
|
||||
control: "#24272b",
|
||||
controlFill: "#4775b8",
|
||||
controlEditing: "#181a1d",
|
||||
textSelection: "#4775b8",
|
||||
outline: "#111216",
|
||||
text: "#e5e5e5",
|
||||
muted: "#a5a8ad",
|
||||
shadow: "#00000088",
|
||||
nodeSelected: "#ed5700",
|
||||
nodeActive: "#ffffff",
|
||||
unknownHeader: "#555b64",
|
||||
unknownSocket: "#999999",
|
||||
linkMuted: "#d94b4b",
|
||||
knifeMuted: "#e85b5b",
|
||||
emphasis: "#ffffff",
|
||||
focus: "#f5a623",
|
||||
editOutline: "#666a70",
|
||||
resize: "#8b8e95",
|
||||
muteOverlay: "#14141459",
|
||||
boxSelectionFill: "#f5a6231f",
|
||||
checkerLight: "#aaaaaa",
|
||||
checkerDark: "#777777",
|
||||
widgetBorder: "#111216",
|
||||
rampBorder: "#111111",
|
||||
resourceBackground: "#202228",
|
||||
} as const satisfies FxNodeTheme;
|
||||
Reference in New Issue
Block a user