Skip to content

Commit 1ed1d1e

Browse files
feat(library): place palette elements with a tap or keyboard, not only drag (#822)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 75769ce commit 1ed1d1e

14 files changed

Lines changed: 975 additions & 238 deletions

File tree

.changeset/curly-lions-tap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tumaet/apollon": minor
3+
---
4+
5+
Tap a palette element to place it. Clicking (or tapping) an element in the palette now drops it in the centre of the visible canvas and selects it, instead of leaving it hidden underneath the palette. Dragging still places the element exactly where you drop it. Placing several elements in a row cascades them diagonally so they never stack on top of one another, and palette entries are now keyboard-operable — focus one and press Enter or Space to add it.

library/lib/components/DraggableGhost.tsx

Lines changed: 116 additions & 236 deletions
Large diffs are not rendered by default.

library/lib/constants.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,21 @@ export const MARKERS = Object.freeze({
443443
export const DROPS = Object.freeze({
444444
SIDEBAR_PREVIEW_SCALE: 0.8,
445445
DEFAULT_ELEMENT_WIDTH: 160,
446+
/**
447+
* Max pointer travel over a palette press for it to count as a tap
448+
* (click-to-place) rather than a drag. Touch is looser: finger-roll on an
449+
* intended tap routinely exceeds a mouse-tight threshold, and misreading a
450+
* touch tap as a drag drops the node hidden under the palette — the exact
451+
* failure this feature removes.
452+
*/
453+
TAP_SLOP_MOUSE_PX: 8,
454+
TAP_SLOP_TOUCH_PX: 16,
455+
/**
456+
* Diagonal offset applied to each consecutive tap-placed node so a burst of
457+
* taps cascades instead of stacking — the same affordance, and the same
458+
* step, as pasting repeatedly.
459+
*/
460+
TAP_CASCADE_PX: CANVAS.PASTE_OFFSET_PX,
446461
} as const)
447462

448463
export type DropElementConfig = {
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import { useCallback } from "react"
2+
import { useReactFlow, type Node, type XYPosition } from "@xyflow/react"
3+
import { useShallow } from "zustand/shallow"
4+
import { CANVAS, DROPS, type DropElementConfig } from "@/constants"
5+
import {
6+
buildPaletteNode,
7+
getPositionOnCanvas,
8+
isParentNodeType,
9+
resizeAllParents,
10+
resolveTapPosition,
11+
snapToGrid,
12+
} from "@/utils"
13+
import { canDropIntoParent } from "@/utils/bpmnConstraints"
14+
import { useDiagramStore } from "@/store/context"
15+
import { log } from "../logger"
16+
17+
/**
18+
* Palette node creation: the two ways a palette element becomes a canvas node.
19+
*
20+
* `dropAtPointer` — a drag release, placing the node under the cursor (keeping
21+
* the grabbed point of the preview under the pointer).
22+
*
23+
* `placeAtViewportCenter` — a tap/keyboard activation, placing the node at the
24+
* centre of the visible canvas and selecting it, so it never lands hidden
25+
* under the palette. Consecutive taps cascade diagonally off the previously
26+
* placed (and still-selected) node.
27+
*
28+
* Both build nodes through `buildPaletteNode` and nest through the same
29+
* `findDropParent`, so a tap can never create a nesting a drag forbids.
30+
*/
31+
export function usePalettePlacement(
32+
dropElementConfig: DropElementConfig,
33+
previewScale: number
34+
) {
35+
const snapPx = CANVAS.SNAP_TO_GRID_PX
36+
const { screenToFlowPosition, getIntersectingNodes } = useReactFlow()
37+
const {
38+
diagramId,
39+
nodes,
40+
setNodes,
41+
edges,
42+
setEdges,
43+
selectedElementIds,
44+
setSelectedElementsId,
45+
lastPlacedElementId,
46+
setLastPlacedElementId,
47+
} = useDiagramStore(
48+
useShallow((state) => ({
49+
diagramId: state.diagramId,
50+
nodes: state.nodes,
51+
setNodes: state.setNodes,
52+
edges: state.edges,
53+
setEdges: state.setEdges,
54+
selectedElementIds: state.selectedElementIds,
55+
setSelectedElementsId: state.setSelectedElementsId,
56+
lastPlacedElementId: state.lastPlacedElementId,
57+
setLastPlacedElementId: state.setLastPlacedElementId,
58+
}))
59+
)
60+
61+
const getCanvas = useCallback(
62+
() => document.getElementById(`react-flow-library-${diagramId}`),
63+
[diagramId]
64+
)
65+
66+
// The deepest parent-capable node under a flow-space point that this element
67+
// is allowed to drop into (BPMN nesting rules included).
68+
const findDropParent = useCallback(
69+
(hitPoint: XYPosition): Node | undefined => {
70+
const intersecting = getIntersectingNodes({
71+
x: hitPoint.x,
72+
y: hitPoint.y,
73+
width: CANVAS.MOUSE_UP_OFFSET_PX,
74+
height: CANVAS.MOUSE_UP_OFFSET_PX,
75+
}).filter(
76+
(node) =>
77+
isParentNodeType(node.type) &&
78+
node.type &&
79+
canDropIntoParent(dropElementConfig.type, node.type)
80+
)
81+
return intersecting[intersecting.length - 1]
82+
},
83+
[getIntersectingNodes, dropElementConfig.type]
84+
)
85+
86+
// Rebase an absolute flow position into the parent found under it, returning
87+
// the node's local position and its parent id.
88+
const nestInParent = useCallback(
89+
(absolute: XYPosition): { position: XYPosition; parentId?: string } => {
90+
const parent = findDropParent(absolute)
91+
if (!parent) return { position: absolute }
92+
const parentOnCanvas = getPositionOnCanvas(parent, nodes)
93+
return {
94+
position: {
95+
x: absolute.x - parentOnCanvas.x,
96+
y: absolute.y - parentOnCanvas.y,
97+
},
98+
parentId: parent.id,
99+
}
100+
},
101+
[findDropParent, nodes]
102+
)
103+
104+
// Append a freshly built node, growing any parent it nests into. Uses a
105+
// functional update so rapid placements never lose each other. When `select`,
106+
// the node becomes the sole selection (mirrors paste).
107+
const commitNode = useCallback(
108+
(
109+
build: (prev: Node[]) => Node,
110+
parentId: string | undefined,
111+
select: boolean
112+
) => {
113+
setNodes((prev) => {
114+
const newNode = build(prev)
115+
const next = [...prev, newNode]
116+
if (parentId) resizeAllParents(newNode, next)
117+
return select
118+
? next.map((node) =>
119+
node.id === newNode.id
120+
? node
121+
: node.selected
122+
? { ...node, selected: false }
123+
: node
124+
)
125+
: next
126+
})
127+
},
128+
[setNodes]
129+
)
130+
131+
// Returns whether a node was actually placed (false when released off-canvas),
132+
// so the caller can decide whether a trailing click still needs handling.
133+
const dropAtPointer = useCallback(
134+
(
135+
event: { clientX: number; clientY: number },
136+
clickOffset: XYPosition
137+
): boolean => {
138+
const canvas = getCanvas()
139+
if (!canvas) {
140+
log.warn("Canvas element not found")
141+
return false
142+
}
143+
144+
const bounds = canvas.getBoundingClientRect()
145+
const outside =
146+
event.clientX < bounds.left ||
147+
event.clientY < bounds.top ||
148+
event.clientX > bounds.right ||
149+
event.clientY > bounds.bottom
150+
if (outside) return false
151+
152+
// The drop/preview ratio maps the grabbed point's fraction of the preview
153+
// onto the (possibly larger) drop size, so the cursor stays over the same
154+
// relative point of the dropped node. Ratio is 1 when they match.
155+
const ratioX =
156+
(dropElementConfig.dropWidth ?? dropElementConfig.width) /
157+
dropElementConfig.width
158+
const ratioY =
159+
(dropElementConfig.dropHeight ?? dropElementConfig.height) /
160+
dropElementConfig.height
161+
162+
// Parent is hit-tested at the snapped cursor (where the ghost is
163+
// anchored); the node's top-left is the cursor backed out by the offset.
164+
const parent = findDropParent(
165+
screenToFlowPosition(
166+
{ x: event.clientX, y: event.clientY },
167+
{ snapToGrid: true }
168+
)
169+
)
170+
const absolute = screenToFlowPosition({
171+
x: event.clientX,
172+
y: event.clientY,
173+
})
174+
absolute.x -=
175+
Math.floor(((clickOffset.x / previewScale) * ratioX) / snapPx) * snapPx
176+
absolute.y -=
177+
Math.floor(((clickOffset.y / previewScale) * ratioY) / snapPx) * snapPx
178+
179+
let position = absolute
180+
if (parent) {
181+
const parentOnCanvas = getPositionOnCanvas(parent, nodes)
182+
position = {
183+
x: absolute.x - parentOnCanvas.x,
184+
y: absolute.y - parentOnCanvas.y,
185+
}
186+
}
187+
188+
commitNode(
189+
() =>
190+
buildPaletteNode(dropElementConfig, position, {
191+
parentId: parent?.id,
192+
}),
193+
parent?.id,
194+
false
195+
)
196+
return true
197+
},
198+
[
199+
getCanvas,
200+
dropElementConfig,
201+
findDropParent,
202+
screenToFlowPosition,
203+
previewScale,
204+
snapPx,
205+
nodes,
206+
commitNode,
207+
]
208+
)
209+
210+
const placeAtViewportCenter = useCallback(() => {
211+
const canvas = getCanvas()
212+
if (!canvas) {
213+
log.warn("Canvas element not found")
214+
return
215+
}
216+
const rect = canvas.getBoundingClientRect()
217+
const nodeWidth = dropElementConfig.dropWidth ?? dropElementConfig.width
218+
const nodeHeight = dropElementConfig.dropHeight ?? dropElementConfig.height
219+
220+
const center = screenToFlowPosition({
221+
x: rect.left + rect.width / 2,
222+
y: rect.top + rect.height / 2,
223+
})
224+
const topLeft = screenToFlowPosition({ x: rect.left, y: rect.top })
225+
const bottomRight = screenToFlowPosition({ x: rect.right, y: rect.bottom })
226+
227+
// Cascade only off the node the previous tap placed, and only while it is
228+
// still the sole selection — so a run of taps steps diagonally, but a tap
229+
// after selecting some unrelated element centres instead of landing beside
230+
// it.
231+
const anchor =
232+
lastPlacedElementId !== null &&
233+
selectedElementIds.length === 1 &&
234+
selectedElementIds[0] === lastPlacedElementId
235+
? nodes.find((node) => node.id === lastPlacedElementId)
236+
: undefined
237+
238+
const absolute = resolveTapPosition({
239+
centeredPosition: snapToGrid(
240+
{ x: center.x - nodeWidth / 2, y: center.y - nodeHeight / 2 },
241+
snapPx
242+
),
243+
anchorAbsolute: anchor ? getPositionOnCanvas(anchor, nodes) : null,
244+
nodeWidth,
245+
nodeHeight,
246+
visibleRect: {
247+
minX: topLeft.x,
248+
minY: topLeft.y,
249+
maxX: bottomRight.x,
250+
maxY: bottomRight.y,
251+
},
252+
stepPx: DROPS.TAP_CASCADE_PX,
253+
snapPx,
254+
})
255+
256+
const { position, parentId } = nestInParent(absolute)
257+
const newNode = buildPaletteNode(dropElementConfig, position, {
258+
parentId,
259+
selected: true,
260+
})
261+
commitNode(() => newNode, parentId, true)
262+
setSelectedElementsId([newNode.id])
263+
// commitNode only clears node.selected; a previously selected edge would
264+
// otherwise stay visually selected out of sync with selectedElementIds.
265+
if (edges.some((edge) => edge.selected)) {
266+
setEdges(
267+
edges.map((edge) =>
268+
edge.selected ? { ...edge, selected: false } : edge
269+
)
270+
)
271+
}
272+
setLastPlacedElementId(newNode.id)
273+
}, [
274+
getCanvas,
275+
dropElementConfig,
276+
screenToFlowPosition,
277+
selectedElementIds,
278+
nodes,
279+
edges,
280+
setEdges,
281+
snapPx,
282+
nestInParent,
283+
commitNode,
284+
setSelectedElementsId,
285+
lastPlacedElementId,
286+
setLastPlacedElementId,
287+
])
288+
289+
return { dropAtPointer, placeAtViewportCenter }
290+
}

library/lib/i18n/labels.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface ApollonLabels {
3131

3232
selectionActions: string
3333
elementPalette: string
34+
addElement: string
3435
paletteModelView: string
3536
paletteSelectElementsView: string
3637
paletteHighlightHint: string
@@ -297,6 +298,7 @@ export const DEFAULT_LABELS: ApollonLabels = Object.freeze<ApollonLabels>({
297298
hideMinimap: "Hide minimap",
298299
selectionActions: "Selection actions",
299300
elementPalette: "Element palette",
301+
addElement: "Add element",
300302
paletteModelView: "Model",
301303
paletteSelectElementsView: "Select Elements",
302304
paletteHighlightHint:

library/lib/store/diagramStore.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ type InitialDiagramState = {
6262
* everything peers committed during the preview.
6363
*/
6464
previewMode: boolean
65+
/**
66+
* Id of the node the last palette tap placed. A run of taps cascades off it
67+
* (offset placement) while it stays the sole selection; any other selection
68+
* change breaks the run and the next tap centres again. Transient UI state —
69+
* never persisted to Yjs.
70+
*/
71+
lastPlacedElementId: string | null
6572
}
6673

6774
const initialDiagramState: InitialDiagramState = {
@@ -78,6 +85,7 @@ const initialDiagramState: InitialDiagramState = {
7885
undoManager: null,
7986
collaborationEnabled: false,
8087
previewMode: false,
88+
lastPlacedElementId: null,
8189
}
8290

8391
function stripComputedSegmentsFromEdge(edge: Edge): Edge {
@@ -124,6 +132,8 @@ export type DiagramStore = {
124132
undoManager: Y.UndoManager | null
125133
collaborationEnabled: boolean
126134
previewMode: boolean
135+
lastPlacedElementId: string | null
136+
setLastPlacedElementId: (id: string | null) => void
127137
setDiagramId: (diagramId: string) => void
128138
setCollaborationEnabled: (enabled: boolean) => void
129139
/**
@@ -360,6 +370,14 @@ export const createDiagramStore = (
360370
set({ selectedElementIds }, undefined, "setSelectedElementsId")
361371
},
362372

373+
setLastPlacedElementId: (id) => {
374+
set(
375+
{ lastPlacedElementId: id },
376+
undefined,
377+
"setLastPlacedElementId"
378+
)
379+
},
380+
363381
toggleInteractiveElement: (elementId) => {
364382
const isNode = get().nodes.some((node) => node.id === elementId)
365383
const isNestedNodeElement = getNestedNodeElementIds(

library/lib/styles/app.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,12 @@
301301
}
302302

303303
/* width/height come inline from the computed cell size. */
304+
.apollon-palette__cell:focus-visible {
305+
outline: 2px solid var(--apollon-primary, #3e8acc);
306+
outline-offset: 2px;
307+
border-radius: var(--apollon-chrome-radius-sm);
308+
}
309+
304310
.apollon-palette__entry {
305311
display: flex;
306312
align-items: center;

0 commit comments

Comments
 (0)