diff --git a/vendor/fxnode/UPSTREAM.md b/vendor/fxnode/UPSTREAM.md index ca8b17a..30091e5 100644 --- a/vendor/fxnode/UPSTREAM.md +++ b/vendor/fxnode/UPSTREAM.md @@ -1,8 +1,8 @@ # fxnode upstream provenance - Source: https://github.com/Heaust-ops/fxnode -- Commit: `4e96585e99742959660d4107b0078c27ff13e708` -- Tree: `4fcafa60557bcbd760d7aa8d8898c0adbe0bb2c8` +- Commit: `618c4e02265568d542350902217641d1bbf1ef40` +- Tree: `03fd4246af80f2e4c855369c5dcf362157c6ef58` - Imported: 2026-07-29 - License: MIT (see `LICENSE` and `NOTICE.md`) - Local patches: none @@ -14,8 +14,8 @@ This directory is a committed source snapshot. Application adaptations belong ou ```sh git clone --filter=blob:none https://github.com/Heaust-ops/fxnode /tmp/fxnode -git -C /tmp/fxnode fetch --depth=1 origin 4e96585e99742959660d4107b0078c27ff13e708 -git -C /tmp/fxnode checkout --detach 4e96585e99742959660d4107b0078c27ff13e708 +git -C /tmp/fxnode fetch --depth=1 origin 618c4e02265568d542350902217641d1bbf1ef40 +git -C /tmp/fxnode checkout --detach 618c4e02265568d542350902217641d1bbf1ef40 rm -rf vendor/fxnode mkdir -p vendor/fxnode git -C /tmp/fxnode archive HEAD | tar -x -C vendor/fxnode diff --git a/vendor/fxnode/src/layout/types.ts b/vendor/fxnode/src/layout/types.ts index 4f1aed3..4988e87 100644 --- a/vendor/fxnode/src/layout/types.ts +++ b/vendor/fxnode/src/layout/types.ts @@ -204,3 +204,11 @@ export function layoutSocketsCompatible(from: LayoutSocket, to: LayoutSocket): b from.direction === "output" && to.direction === "input" && (to.wildcardInput || to.accepts.includes(from.dataType)) ); } + +export function layoutSocketAcceptsLink(from: LayoutSocket, to: LayoutSocket): boolean { + return ( + from.nodeId !== to.nodeId && + layoutSocketsCompatible(from, to) && + (to.capacity === 1 || to.linkIds.length < to.capacity) + ); +} diff --git a/vendor/fxnode/src/render/canvas-renderer.ts b/vendor/fxnode/src/render/canvas-renderer.ts index 6028296..dce97a4 100644 --- a/vendor/fxnode/src/render/canvas-renderer.ts +++ b/vendor/fxnode/src/render/canvas-renderer.ts @@ -2,7 +2,7 @@ import { GEOMETRY as G } from "../layout/constants.js"; import { worldToView } from "../layout/geometry.js"; import type { LinkId, NodeId, ParameterValue, SocketId } from "../core/types.js"; import { - layoutSocketsCompatible, + layoutSocketAcceptsLink, type LayoutControl, type LayoutSnapshot, type LayoutSocket, @@ -755,11 +755,24 @@ export function renderCanvas( context.bezierCurveTo(a.x + dx, a.y, b.x - dx, b.y, b.x, b.y); context.stroke(); for (const socket of snapshot.sockets.values()) { - if (layoutSocketsCompatible(from, socket)) { - const p = worldToView(socket.anchor, t); + if (layoutSocketAcceptsLink(from, socket)) { + const candidate = socket.id === interaction.linkDrag.candidate; context.beginPath(); - context.arc(p.x, p.y, socket.id === interaction.linkDrag.candidate ? 10 : 8, 0, Math.PI * 2); - context.strokeStyle = socket.id === interaction.linkDrag.candidate ? theme.emphasis : socket.color; + if (socket.shape === "multi-input" && socket.bounds) { + const topLeft = worldToView({ x: socket.bounds.x, y: socket.bounds.y }, t), + padding = candidate ? 4 : 2; + context.roundRect( + topLeft.x - padding, + topLeft.y - padding, + socket.bounds.width * t.zoom + padding * 2, + socket.bounds.height * t.zoom + padding * 2, + G.socket * t.zoom + padding, + ); + } else { + const p = worldToView(socket.anchor, t); + context.arc(p.x, p.y, candidate ? 10 : 8, 0, Math.PI * 2); + } + context.strokeStyle = candidate ? theme.emphasis : socket.color; context.stroke(); } } diff --git a/vendor/fxnode/src/worker/interaction.ts b/vendor/fxnode/src/worker/interaction.ts index 4424d3d..c86f9bb 100644 --- a/vendor/fxnode/src/worker/interaction.ts +++ b/vendor/fxnode/src/worker/interaction.ts @@ -3,7 +3,7 @@ import type { Command } from "../commands/types.js"; import { viewToWorld } from "../layout/geometry.js"; import { GEOMETRY as G } from "../layout/constants.js"; import { - layoutSocketsCompatible, + layoutSocketAcceptsLink, type LayoutControl, type LayoutSnapshot, type Rect, @@ -219,7 +219,7 @@ export const boxNodes = (layout: LayoutSnapshot, a: Vec2, b: Vec2): NodeId[] => export const compatibleTargets = (layout: LayoutSnapshot, fromId: SocketId) => { const from = layout.sockets.get(fromId); if (!from || from.direction !== "output") return []; - return [...layout.sockets.values()].filter((to) => to.nodeId !== from.nodeId && layoutSocketsCompatible(from, to)); + return [...layout.sockets.values()].filter((to) => layoutSocketAcceptsLink(from, to)); }; export function planLink( layout: LayoutSnapshot, @@ -230,8 +230,9 @@ export function planLink( const from = layout.sockets.get(fromId), to = layout.sockets.get(toId); if (!from || !to || !compatibleTargets(layout, fromId).some((s) => s.id === toId)) return; + const replacing = to.capacity === 1 && to.linkIds.length > 0; const link: GraphLink = { - id: to.linkIds[0] ?? newId, + id: replacing ? to.linkIds[0]! : newId, fromNodeId: from.nodeId, fromSocketId: from.id, toNodeId: to.nodeId, @@ -239,9 +240,7 @@ export function planLink( muted: false, extensions: {}, }; - return to.capacity === 1 && to.linkIds.length - ? { type: "link.replace", removeId: to.linkIds[0]!, link } - : { type: "link.add", link }; + return replacing ? { type: "link.replace", removeId: to.linkIds[0]!, link } : { type: "link.add", link }; } export const clampResize = (layout: LayoutSnapshot, id: NodeId, world: Vec2): Vec2 | undefined => { const n = layout.nodes.get(id); diff --git a/vendor/fxnode/test/browser/examples.spec.ts b/vendor/fxnode/test/browser/examples.spec.ts index 46a62ef..ceb3c51 100644 --- a/vendor/fxnode/test/browser/examples.spec.ts +++ b/vendor/fxnode/test/browser/examples.spec.ts @@ -161,6 +161,51 @@ test("logic nodes accept five links through one input and application evaluation expect(initial.incoming).toBe(5); expect(initial.labels).toContain("AND: false"); + await page.evaluate(async () => { + const view = window.fxnodeStandalone.view!; + const modifiers = { alt: false, control: false, meta: false, shift: false }; + view.feedInput({ + kind: "pointer", + phase: "down", + pointerId: 41, + pointerType: "mouse", + position: { x: 353, y: 190 }, + button: 0, + buttons: 1, + modifiers, + }); + view.feedInput({ + kind: "pointer", + phase: "move", + pointerId: 41, + pointerType: "mouse", + position: { x: 464, y: 468 }, + button: 0, + buttons: 1, + modifiers, + }); + await view.whenRendered(); + view.feedInput({ + kind: "pointer", + phase: "up", + pointerId: 41, + pointerType: "mouse", + position: { x: 464, y: 468 }, + button: 0, + buttons: 0, + modifiers, + }); + }); + await expect + .poll(() => + page.evaluate(() => + window.fxnodeStandalone + .root!.getState() + .then((state) => state.links.filter((link) => link.toSocketId === "or:inputs").length), + ), + ) + .toBe(4); + await page.evaluate(async () => { const root = window.fxnodeStandalone.root!; const source = (await root.getState()).nodes.find((node) => node.id === "c")!; diff --git a/vendor/fxnode/test/layout.test.ts b/vendor/fxnode/test/layout.test.ts index 7ffec61..ab2f7c6 100644 --- a/vendor/fxnode/test/layout.test.ts +++ b/vendor/fxnode/test/layout.test.ts @@ -497,6 +497,14 @@ test("multi-input sockets use pill hit bounds and stable per-link anchors", () = for (const [id, anchor] of socket.linkAnchors) { assert.deepEqual(snapshot.links.get(id)!.points.at(-1), anchor); } + const next = planLink( + snapshot, + sources[0]!.sockets.find((candidate) => candidate.key === "mesh")!.id, + socket.id, + linkId("fresh-link"), + ); + assert.equal(next?.type, "link.add"); + if (next?.type === "link.add") assert.equal(next.link.id, linkId("fresh-link")); assert.deepEqual(hitTest(snapshot, worldToView({ x: socket.anchor.x, y: socket.bounds!.y - 2 }, transform)), { kind: "socket", id: socket.id,