From 91c6dc037dd32a6fefff1e7a8f8bbde199e17517 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:22:47 +0800 Subject: [PATCH] fix(workflow): keep the drawer fit fixed while a node is dragged (#1575) Dragging an extreme node re-fitted the plane from a list that already held the drag ghost, so the extreme's fitted position was constant while its world coordinate moved: the node sat still, the rest of the graph was squashed, and pointerup stored the wider graph. Fit `workflow.nodes` for the whole gesture and draw the ghost inside that frame, which leaves a middle-node drag as it was and commits the coordinates the user saw. Docs-not-needed: the drawer fit rule and its subsystem page are unchanged; #1575 limits this to client.js and its test. --- .../1575-drawer-drag-extreme-node.fixed.md | 1 + plugins/workflow/client.js | 22 +++-- plugins/workflow/test/client.test.js | 92 +++++++++++++++++++ 3 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 changelog.d/1575-drawer-drag-extreme-node.fixed.md diff --git a/changelog.d/1575-drawer-drag-extreme-node.fixed.md b/changelog.d/1575-drawer-drag-extreme-node.fixed.md new file mode 100644 index 000000000..d24d4ea8c --- /dev/null +++ b/changelog.d/1575-drawer-drag-extreme-node.fixed.md @@ -0,0 +1 @@ +Dragging the leftmost, rightmost or topmost node in the workflow drawer moves the node under the pointer instead of leaving it pinned while the fit re-centres and rescales the rest of the graph; the fit is now fixed for the whole gesture, so a drag commits the coordinates the user saw. ([#1575](https://github.com/ThinkFlowLab/FlyBot/issues/1575)) diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index db49166c1..c5d21e807 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -612,13 +612,19 @@ window.__ModuleLoader__.load({ const [dragPreview, setDragPreview] = useState(null) const [selectedNode, setSelectedNode] = useState(null) const status = run?.nodeStatus ?? {} - const nodes = workflow.nodes.map(node => ghost?.[node.id] ? { ...node, ...ghost[node.id] } : node) + const nodes = workflow.nodes const edges = workflow.edges ?? [] - // The drawer paints a fitted copy; `nodes` stays the world graph so a drag - // commits the same coordinates the Settings canvas would (#1513). + // The drawer paints a fitted copy of the world graph (#1513). The fit is a + // fixed frame for the whole gesture: folding the ghost back into it re-centred + // and rescaled the graph under the pointer and pinned an extreme node (#1575). const plane = fitPlane(nodes, DRAWER_FLOW_W) - const fitted = new Map(plane.nodes.map(node => [node.id, node])) const scale = plane.scale || 1 + const fitted = new Map(plane.nodes.map(node => [node.id, node])) + const drawn = new Map(fitted) + for (const [id, spot] of Object.entries(ghost ?? {})) { + const base = fitted.get(id) + if (base) drawn.set(id, { ...base, x: spot.x * scale + plane.offsetX, y: spot.y + plane.offsetY }) + } const height = plane.height const locals = (roster?.bots ?? []).filter(bot => bot && bot.remote !== true && !nodes.some(node => node.botId === bot.id)) const selected = nodes.find(node => node.id === selectedNode) @@ -683,8 +689,8 @@ window.__ModuleLoader__.load({ h('div', { className: 'wf-plane', 'data-plane': '1', style: { height, width: plane.width } }, h('svg', { className: 'wf-wires', 'aria-hidden': true, ...wireSvgProps(height, plane.width) }, edges.map(edge => { - const from = fitted.get(edge.from) - const to = fitted.get(edge.to) + const from = drawn.get(edge.from) + const to = drawn.get(edge.to) if (!from || !to) return null return h('g', { key: `${edge.from}>${edge.to}` }, h('path', { d: wirePath(from, to, 0) }), @@ -695,7 +701,7 @@ window.__ModuleLoader__.load({ }) : null) })), (workflow.layers ?? []).filter(layer => layer.length > 1).map(layer => { - const members = layer.map(id => fitted.get(id)).filter(Boolean) + const members = layer.map(id => drawn.get(id)).filter(Boolean) if (members.length < 2) return null return h('div', { key: layer.join('+'), className: 'wf-par wf-abs', @@ -703,7 +709,7 @@ window.__ModuleLoader__.load({ }, h('div', { className: 'wf-plabel' }, tr('Parallel branches · Merge after all pass'))) }), nodes.map(node => { - const spot = fitted.get(node.id) ?? node + const spot = drawn.get(node.id) ?? node return h('div', { key: node.id, 'data-node': node.id, diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 2281f335f..e21db2285 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -261,6 +261,98 @@ test('the sidebar opens the flowchart, and a drag, a wire and a dropped Bot are view.unmount() }) +// A drawer-sized graph: span 344 in the 364px plane is scale 0.5 exactly, so a +// 40px pointer move is 80 world units and the rounding stays out of the numbers. +function drawerGraph(id, name, nodes) { + return { ...delivery(), id, name, layers: [], nodes, edges: [] } +} + +async function openDrawer(graph) { + const calls = [] + const call = async (name, body) => { + calls.push({ name, body }) + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node).includes(graph.name))[0].props.onClick() + button(view.tree, 'Flow').props.onClick() + button(view.tree, 'Edit').props.onClick() + const nodeAt = id => findAll(view.tree, node => node.props?.['data-node'] === id)[0] + const canvas = () => findAll(view.tree, node => String(node.props?.className ?? '').split(' ').includes('wf-canvas'))[0] + return { view, calls, nodeAt, canvas } +} + +test('dragging the rightmost node moves it on screen inside the frame the drag started with (#1575)', async () => { + const nodes = [ + { id: 'left', name: 'Left', role: '', x: 0, y: 100 }, + { id: 'middle', name: 'Middle', role: '', x: 172, y: 100 }, + { id: 'right', name: 'Right', role: '', x: 344, y: 100 }, + ] + const { view, calls, nodeAt, canvas } = await openDrawer(drawerGraph('wide', 'Wide graph', nodes)) + const fit = client.fitPlane(nodes, client.DRAWER_FLOW_W) + assert.equal(fit.scale, 0.5, 'the fixture spans wider than the drawer room') + const before = Object.fromEntries(nodes.map(node => [node.id, { ...nodeAt(node.id).props.style }])) + + nodeAt('right').props.onPointerDown({ clientX: 300, clientY: 100, target: { dataset: {} } }) + canvas().props.onPointerMove({ clientX: 320, clientY: 100 }) + canvas().props.onPointerMove({ clientX: 340, clientY: 100 }) + assert.equal(nodeAt('right').props.style.left, before.right.left + 40, 'the rightmost node follows the pointer 1:1') + assert.equal(nodeAt('middle').props.style.left, before.middle.left, 'the fit did not re-centre another node') + assert.equal(nodeAt('left').props.style.left, before.left.left, 'the fit did not re-centre another node') + + canvas().props.onPointerUp({ clientX: 340, clientY: 100, target: { dataset: {} } }) + const saved = calls.filter(item => item.name === 'workflow/graph').at(-1) + assert.equal(saved.body.nodes.find(node => node.id === 'right').x, 344 + 80, 'the commit uses the start frame, not an intermediate one') + view.unmount() +}) + +test('dragging the topmost node up moves it on screen inside the frame the drag started with (#1575)', async () => { + const nodes = [ + { id: 'top', name: 'Top', role: '', x: 172, y: 0 }, + { id: 'middle', name: 'Middle', role: '', x: 172, y: 200 }, + { id: 'bottom', name: 'Bottom', role: '', x: 172, y: 400 }, + ] + const { view, calls, nodeAt, canvas } = await openDrawer(drawerGraph('tall', 'Tall graph', nodes)) + const fit = client.fitPlane(nodes, client.DRAWER_FLOW_W) + assert.equal(fit.scale, 1, 'a vertical chain is not scaled') + const before = Object.fromEntries(nodes.map(node => [node.id, { ...nodeAt(node.id).props.style }])) + + nodeAt('top').props.onPointerDown({ clientX: 100, clientY: 100, target: { dataset: {} } }) + canvas().props.onPointerMove({ clientX: 100, clientY: 70 }) + canvas().props.onPointerMove({ clientX: 100, clientY: 60 }) + assert.equal(nodeAt('top').props.style.top, before.top.top - 40, 'the topmost node follows the pointer 1:1') + assert.equal(nodeAt('middle').props.style.top, before.middle.top, 'the fit did not push another node around') + assert.equal(nodeAt('bottom').props.style.top, before.bottom.top, 'the fit did not push another node around') + + canvas().props.onPointerUp({ clientX: 100, clientY: 60, target: { dataset: {} } }) + const saved = calls.filter(item => item.name === 'workflow/graph').at(-1) + assert.equal(saved.body.nodes.find(node => node.id === 'top').y, -40, 'the commit keeps the start frame') + view.unmount() +}) + +test('a middle-node drag keeps the frame it started with (#1575)', async () => { + const nodes = [ + { id: 'left', name: 'Left', role: '', x: 0, y: 100 }, + { id: 'middle', name: 'Middle', role: '', x: 172, y: 100 }, + { id: 'right', name: 'Right', role: '', x: 344, y: 100 }, + ] + const { view, calls, nodeAt, canvas } = await openDrawer(drawerGraph('middle', 'Middle graph', nodes)) + const before = Object.fromEntries(nodes.map(node => [node.id, { ...nodeAt(node.id).props.style }])) + + nodeAt('middle').props.onPointerDown({ clientX: 300, clientY: 100, target: { dataset: {} } }) + canvas().props.onPointerMove({ clientX: 340, clientY: 100 }) + assert.equal(nodeAt('middle').props.style.left, before.middle.left + 40, 'a middle node still follows the pointer') + assert.equal(nodeAt('left').props.style.left, before.left.left) + assert.equal(nodeAt('right').props.style.left, before.right.left) + + canvas().props.onPointerUp({ clientX: 340, clientY: 100, target: { dataset: {} } }) + const saved = calls.filter(item => item.name === 'workflow/graph').at(-1) + assert.equal(saved.body.nodes.find(node => node.id === 'middle').x, 172 + 80) + view.unmount() +}) + test('clicking a sidebar workflow, New, or Hand-a-task keeps the section and opens the stage', async () => { // The shell's createPortal throws when the container is missing — the same // throw the sidebar slot boundary turns into an empty Workflows section (#1375).