Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1575-drawer-drag-extreme-node.fixed.md
Original file line number Diff line number Diff line change
@@ -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))
22 changes: 14 additions & 8 deletions plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) }),
Expand All @@ -695,15 +701,15 @@ 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',
style: parallelFrameStyle(members),
}, 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,
Expand Down
92 changes: 92 additions & 0 deletions plugins/workflow/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading