Skip to content

Commit d19bd7e

Browse files
Merge branch 'main' into 50-color-class-attributes
2 parents ba54c7c + cec0a4c commit d19bd7e

13 files changed

Lines changed: 433 additions & 22 deletions

File tree

.changeset/thick-geese-invent.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+
Select and move several elements at once: a new multi-select toggle in the canvas controls lets you build a selection by clicking or tapping elements to add or remove them — and, with a mouse, by dragging a selection box across the canvas. It needs no keyboard, so groups can be positioned together on phones and tablets too. Click empty canvas or press Escape to clear.

library/lib/App.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
import { diagramNodeTypes } from "./nodes"
4545
import { useDiagramModifiable } from "./hooks/useDiagramModifiable"
4646
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"
47+
import { useMultiSelectionMode } from "./hooks/useMultiSelectionMode"
4748
import { usePaneClicked } from "./hooks/usePaneClicked"
4849
import {
4950
useRemoteDraggingNodes,
@@ -144,6 +145,7 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
144145
const { onBeforeDelete, onNodeDoubleClick, onEdgeDoubleClick } =
145146
useElementInteractions()
146147
const { onPaneClicked } = usePaneClicked()
148+
const multiSelectionMode = useMultiSelectionMode()
147149

148150
const handleReactFlowInit = useCallback(
149151
(instance: ReactFlowInstance) => {
@@ -245,13 +247,22 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
245247
nodesDraggable={isDiagramModifiable}
246248
panOnScroll={!scrollLock || scrollEnabled}
247249
zoomOnScroll={!scrollLock || scrollEnabled}
248-
// Keep the default left-drag pan (panOnDrag=true) — we do NOT switch
249-
// to selectionOnDrag/space-pan. Adding Shift to multiSelectionKeyCode
250-
// makes Shift+CLICK on a node/edge toggle it in/out of the
251-
// multi-selection. selectionKeyCode keeps its default (Shift), so a
252-
// Shift+DRAG on the empty pane still box-selects — no conflict, since
253-
// a click on a node and a drag on the pane are different surfaces.
250+
// Shift is also selectionKeyCode's default, but there's no conflict:
251+
// a click on a node and a Shift+drag on the pane are different
252+
// surfaces.
254253
multiSelectionKeyCode={["Shift", "Meta", "Control"]}
254+
// With multiSelectionActive forced on, React Flow's pointerdown
255+
// select would toggle the pressed node OUT of the selection and drop
256+
// it from the group drag; selecting on click keeps the group whole.
257+
selectNodesOnDrag={!multiSelectionMode}
258+
// In the mode, a plain left-drag on the pane draws a selection box;
259+
// panning moves to middle/right-drag (scroll/trackpad already pans
260+
// by default). This is mouse-only by construction: d3-zoom gates the
261+
// pan buttons on `mousedown` alone, so a touch drag ignores the [1,2]
262+
// gate and keeps panning through the separate touch handler — which
263+
// is why a one-finger drag never box-selects and pinch-zoom survives.
264+
selectionOnDrag={multiSelectionMode}
265+
panOnDrag={multiSelectionMode ? [1, 2] : true}
255266
// Delete the current selection with either key (Backspace on macOS,
256267
// Delete on full keyboards).
257268
deleteKeyCode={["Backspace", "Delete"]}

library/lib/chrome/builtins/ZoomControls.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import { useReactFlow, useStore } from "@xyflow/react"
22
import { useShallow } from "zustand/shallow"
3-
import { Maximize, Redo2, Undo2, ZoomIn, ZoomOut } from "lucide-react"
4-
import { useDiagramStore, useOverlayStore } from "@/store/context"
3+
import {
4+
Maximize,
5+
Redo2,
6+
SquareMousePointer,
7+
Undo2,
8+
ZoomIn,
9+
ZoomOut,
10+
} from "lucide-react"
11+
import {
12+
useDiagramStore,
13+
useMetadataStore,
14+
useOverlayStore,
15+
} from "@/store/context"
516
import { insetAwareFitView } from "@/overlay/fitView"
617
import { Tooltip } from "@/components/ui"
718
import { useLabels } from "@/i18n/useLabels"
@@ -13,8 +24,8 @@ export interface ZoomControlsProps {
1324
}
1425

1526
/**
16-
* The zoom / history cluster: a [zoom-out][%-reset][zoom-in][fit] island and a
17-
* separate [undo][redo] history island. The fit button reserves the current insets
27+
* The canvas cluster: a [zoom-out][%-reset][zoom-in][fit][multi-select] island and
28+
* a separate [undo][redo] history island. The fit button reserves the current insets
1829
* so content frames clear of the chrome. `history: false` drops the history island.
1930
* One `role="toolbar"` spans both islands so a single Tab stop + arrows rove every
2031
* button (roving-tabindex, APG-valid at ≥3 controls).
@@ -36,6 +47,13 @@ export function ZoomControls({ history = true }: ZoomControlsProps) {
3647
}))
3748
)
3849

50+
const { multiSelectionMode, setMultiSelectionMode } = useMetadataStore(
51+
useShallow((state) => ({
52+
multiSelectionMode: state.multiSelectionMode,
53+
setMultiSelectionMode: state.setMultiSelectionMode,
54+
}))
55+
)
56+
3957
const { ref: toolbarRef, onKeyDown: onToolbarKeyDown } =
4058
useRovingToolbar<HTMLDivElement>()
4159

@@ -90,6 +108,17 @@ export function ZoomControls({ history = true }: ZoomControlsProps) {
90108
<Maximize width={18} height={18} aria-hidden="true" />
91109
</button>
92110
</Tooltip>
111+
<Tooltip title={t.multiSelectionHint}>
112+
<button
113+
type="button"
114+
className="apollon-chrome-iconbtn apollon-chrome-iconbtn--toggle"
115+
onClick={() => setMultiSelectionMode(!multiSelectionMode)}
116+
aria-label={t.multiSelection}
117+
aria-pressed={multiSelectionMode}
118+
>
119+
<SquareMousePointer width={18} height={18} aria-hidden="true" />
120+
</button>
121+
</Tooltip>
93122
</div>
94123

95124
{history && undoManagerExist && (

library/lib/hooks/useKeyboardShortcuts.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useRef } from "react"
2-
import { useDiagramStore } from "@/store/context"
2+
import { useDiagramStore, useMetadataStore } from "@/store/context"
33
import { useShallow } from "zustand/shallow"
44
import { useSelectionForCopyPaste } from "./useSelectionForCopyPaste"
55
import { useDiagramModifiable } from "./useDiagramModifiable"
@@ -16,6 +16,9 @@ export const useKeyboardShortcuts = () => {
1616
undoManager: state.undoManager,
1717
}))
1818
)
19+
const setMultiSelectionMode = useMetadataStore(
20+
(state) => state.setMultiSelectionMode
21+
)
1922
const isDiagramModifiable = useDiagramModifiable()
2023
const {
2124
selectedElementIds,
@@ -42,6 +45,7 @@ export const useKeyboardShortcuts = () => {
4245

4346
if (event.key === "Escape") {
4447
event.preventDefault()
48+
setMultiSelectionMode(false)
4549
clearSelection()
4650
return
4751
}
@@ -142,5 +146,6 @@ export const useKeyboardShortcuts = () => {
142146
copySelectedElements,
143147
cutSelectedElements, // Add this to dependencies
144148
pasteElements,
149+
setMultiSelectionMode,
145150
])
146151
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useEffect } from "react"
2+
import { useStoreApi } from "@xyflow/react"
3+
import { useMetadataStore } from "@/store/context"
4+
5+
/**
6+
* Reproduces Shift/Ctrl multi-selection while `multiSelectionMode` is on, for
7+
* pointers that have no modifier key (touch). React Flow has no public prop for
8+
* "a modifier is held" — `multiSelectionKeyCode` needs a real key — so the
9+
* internal flag its click paths read is the only lever. React Flow's key handler
10+
* rewrites that flag on every modifier transition, hence the re-asserting
11+
* subscription rather than a one-shot write.
12+
*
13+
* Being internal, the flag could be renamed in any React Flow release: this
14+
* hook's unit test asserts it against a real store, so a rename fails CI on the
15+
* catalog bump instead of silently leaving the toggle inert.
16+
*/
17+
export const useMultiSelectionMode = (): boolean => {
18+
const multiSelectionMode = useMetadataStore(
19+
(state) => state.multiSelectionMode
20+
)
21+
const store = useStoreApi()
22+
23+
useEffect(() => {
24+
if (!multiSelectionMode) return
25+
26+
store.setState({ multiSelectionActive: true })
27+
const unsubscribe = store.subscribe((state) => {
28+
if (!state.multiSelectionActive) {
29+
store.setState({ multiSelectionActive: true })
30+
}
31+
})
32+
33+
return () => {
34+
unsubscribe()
35+
store.setState({ multiSelectionActive: false })
36+
}
37+
}, [multiSelectionMode, store])
38+
39+
return multiSelectionMode
40+
}

library/lib/i18n/labels.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* control of word order.
66
*/
77
export interface ApollonLabels {
8-
// Zoom / history cluster
8+
// Zoom / history / multi-select cluster
99
zoomToolbar: string
1010
zoomIn: string
1111
zoomOut: string
@@ -19,6 +19,8 @@ export interface ApollonLabels {
1919
undoHint: string
2020
redo: string
2121
redoHint: string
22+
multiSelection: string
23+
multiSelectionHint: string
2224

2325
// Minimap
2426
miniMap: string
@@ -289,7 +291,7 @@ function defaultNodeTypeLabel(nodeType?: string): string {
289291

290292
/** The shipped English strings — the fallback for any key a host doesn't override. */
291293
export const DEFAULT_LABELS: ApollonLabels = Object.freeze<ApollonLabels>({
292-
zoomToolbar: "Zoom and history controls",
294+
zoomToolbar: "Zoom, history and selection controls",
293295
zoomIn: "Zoom in",
294296
zoomOut: "Zoom out",
295297
fitView: "Fit view",
@@ -299,6 +301,8 @@ export const DEFAULT_LABELS: ApollonLabels = Object.freeze<ApollonLabels>({
299301
undoHint: "Undo (Ctrl+Z)",
300302
redo: "Redo",
301303
redoHint: "Redo (Ctrl+Y or Ctrl+Shift+Z)",
304+
multiSelection: "Select multiple elements",
305+
multiSelectionHint: "Select multiple: click elements to add or remove",
302306
miniMap: "Mini map",
303307
showMinimap: "Show minimap",
304308
showMinimapHint: "Show minimap (overview)",

library/lib/store/metadataStore.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export type MetadataStore = {
1818
readonly: boolean
1919
debug: boolean
2020
scrollLock: boolean
21+
/** Tool toggle: clicks/taps add or remove elements — the touch path to a multi-selection. */
22+
multiSelectionMode: boolean
2123
/** User-facing strings for the editor's own chrome; host-overridable for i18n. */
2224
labels: ApollonLabels
2325
/** Element-tag authoring config; disabled until a host opts in. */
@@ -34,6 +36,7 @@ export type MetadataStore = {
3436
setAvailableViews: (availableViews: ApollonView[]) => void
3537
setReadonly: (readonly: boolean) => void
3638
setScrollLock: (scrollLock: boolean) => void
39+
setMultiSelectionMode: (multiSelectionMode: boolean) => void
3740
setLabels: (labels: ApollonLabels) => void
3841
setTagConfig: (tagConfig: TagConfig) => void
3942
setScrollEnabled: (scrollEnabled: boolean) => void
@@ -65,6 +68,7 @@ type InitialMetadataState = {
6568
readonly: boolean
6669
debug: boolean
6770
scrollLock: boolean
71+
multiSelectionMode: boolean
6872
labels: ApollonLabels
6973
tagConfig: TagConfig
7074
scrollEnabled: boolean
@@ -86,6 +90,7 @@ const initialMetadataState: InitialMetadataState = {
8690
readonly: false,
8791
debug: false,
8892
scrollLock: false,
93+
multiSelectionMode: false,
8994
labels: DEFAULT_LABELS,
9095
tagConfig: DISABLED_TAG_CONFIG,
9196
scrollEnabled: false,
@@ -179,6 +184,10 @@ export const createMetadataStore = (
179184
set({ scrollLock }, undefined, "setScrollLock")
180185
},
181186

187+
setMultiSelectionMode: (multiSelectionMode: boolean) => {
188+
set({ multiSelectionMode }, undefined, "setMultiSelectionMode")
189+
},
190+
182191
setLabels: (labels) => {
183192
// Skip the write when the merged labels are value-equal to the current
184193
// set. Hosts routinely pass an inline `labels={{…}}` literal (new object

library/lib/styles/app.css

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,36 @@
454454
too (App.tsx), so editor chrome still gets it; it references only chrome tokens
455455
so the rendered control is identical wherever the class is defined. */
456456

457+
/* Opt-in `--toggle` peer: an unqualified `[aria-pressed="true"]` here would paint
458+
every future editor chrome toggle, and the home band's pills (components.css)
459+
already paint their own on-state. The hover/active peers exist because the base
460+
button's own state rules would otherwise overwrite the pressed fill. */
461+
.apollon-chrome-iconbtn--toggle[aria-pressed="true"] {
462+
background: color-mix(in srgb, var(--apollon-chrome-accent) 18%, transparent);
463+
color: var(--apollon-chrome-accent);
464+
}
465+
466+
.apollon-chrome-iconbtn--toggle[aria-pressed="true"]:hover:not(:disabled) {
467+
background: color-mix(in srgb, var(--apollon-chrome-accent) 26%, transparent);
468+
}
469+
470+
.apollon-chrome-iconbtn--toggle[aria-pressed="true"]:active:not(:disabled) {
471+
background: color-mix(in srgb, var(--apollon-chrome-accent) 34%, transparent);
472+
}
473+
474+
/* Forced colours drop the accent fill, which is this toggle's ONLY signal that a
475+
mode is armed — repaint it with the system pair so the mode stays visible. The
476+
hover/active peers are re-listed because their higher specificity would
477+
otherwise restore the (now-invisible) accent fill. */
478+
@media (forced-colors: active) {
479+
.apollon-chrome-iconbtn--toggle[aria-pressed="true"],
480+
.apollon-chrome-iconbtn--toggle[aria-pressed="true"]:hover:not(:disabled),
481+
.apollon-chrome-iconbtn--toggle[aria-pressed="true"]:active:not(:disabled) {
482+
background: Highlight;
483+
color: HighlightText;
484+
}
485+
}
486+
457487
/* Expanded minimap = its own glass card (the .react-flow__panel glass rule
458488
supplies the pane). Drop the panel padding so the map fills the rounded card
459489
(no fat glass gutter) and clip the svg to the card radius. */
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect, beforeEach } from "vitest"
2+
import { act, renderHook } from "@testing-library/react"
3+
import { type ReactNode } from "react"
4+
import { ReactFlowProvider, useStoreApi } from "@xyflow/react"
5+
import * as Y from "yjs"
6+
import { MetadataStoreContext } from "@/store/context"
7+
import { createMetadataStore } from "@/store/metadataStore"
8+
import { useMultiSelectionMode } from "@/hooks/useMultiSelectionMode"
9+
10+
describe("useMultiSelectionMode", () => {
11+
let metadataStore: ReturnType<typeof createMetadataStore>
12+
13+
const wrapper = ({ children }: { children: ReactNode }) => (
14+
<MetadataStoreContext value={metadataStore}>
15+
<ReactFlowProvider>{children}</ReactFlowProvider>
16+
</MetadataStoreContext>
17+
)
18+
19+
/** Renders the hook and hands back the React Flow store it drives. */
20+
const renderMultiSelectionMode = () =>
21+
renderHook(
22+
() => {
23+
useMultiSelectionMode()
24+
return useStoreApi()
25+
},
26+
{ wrapper }
27+
)
28+
29+
beforeEach(() => {
30+
metadataStore = createMetadataStore(new Y.Doc())
31+
})
32+
33+
it("forces React Flow's multiSelectionActive while the mode is on", () => {
34+
const { result } = renderMultiSelectionMode()
35+
expect(result.current.getState().multiSelectionActive).toBe(false)
36+
37+
act(() => metadataStore.getState().setMultiSelectionMode(true))
38+
expect(result.current.getState().multiSelectionActive).toBe(true)
39+
40+
act(() => metadataStore.getState().setMultiSelectionMode(false))
41+
expect(result.current.getState().multiSelectionActive).toBe(false)
42+
})
43+
44+
it("re-asserts the flag when React Flow's key handler clears it", () => {
45+
const { result } = renderMultiSelectionMode()
46+
act(() => metadataStore.getState().setMultiSelectionMode(true))
47+
48+
// React Flow writes this flag from its own modifier-key handler on every
49+
// key transition, which would silently break toggle-taps mid-mode.
50+
act(() => result.current.setState({ multiSelectionActive: false }))
51+
expect(result.current.getState().multiSelectionActive).toBe(true)
52+
})
53+
})

standalone/webapp/tests/e2e/controls-a11y.spec.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ test.describe("built-in controls accessibility", () => {
4242
page,
4343
}) => {
4444
const toolbar = page.getByRole("toolbar", {
45-
name: "Zoom and history controls",
45+
name: "Zoom, history and selection controls",
4646
})
4747
await expect(toolbar).toBeVisible()
4848

@@ -54,22 +54,23 @@ test.describe("built-in controls accessibility", () => {
5454
await tabbable.focus()
5555
expect(await activeLabel(page)).toBe("Zoom out")
5656

57-
// ArrowRight roves across BOTH glass islands (view [−][%][+][fit] then
58-
// history), staying a single tab stop — the toolbar spans both islands.
57+
// ArrowRight roves across BOTH glass islands (view [−][%][+][fit][multi-
58+
// select] then history), staying a single tab stop — the toolbar spans
59+
// both islands.
5960
await page.keyboard.press("ArrowRight")
6061
expect(await activeLabel(page)).toBe("Zoom is 100%, reset to 100%")
6162
await expect(toolbar.locator("button[tabindex='0']")).toHaveCount(1)
6263

6364
// End jumps to the last ENABLED control (roving skips disabled buttons). With
6465
// a freshly-loaded diagram the history island is either absent (no undo
6566
// manager) or its undo/redo start disabled, so the last reachable control is
66-
// the fit-view button; if an enabled redo is present it is "Redo". Assert the
67-
// concrete last DOM control — not merely "not the first".
67+
// the multi-select toggle; if an enabled redo is present it is "Redo". Assert
68+
// the concrete last DOM control — not merely "not the first".
6869
const expectedLast = await toolbar
6970
.locator("button:enabled")
7071
.last()
7172
.getAttribute("aria-label")
72-
expect(["Fit view", "Redo"]).toContain(expectedLast)
73+
expect(["Select multiple elements", "Redo"]).toContain(expectedLast)
7374
await page.keyboard.press("End")
7475
expect(await activeLabel(page)).toBe(expectedLast)
7576

0 commit comments

Comments
 (0)