Skip to content

Commit 5d90428

Browse files
feat: save with Ctrl+S, duplicate with Ctrl+D, and a keyboard-driven viewport (#817)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1eaf5be commit 5d90428

52 files changed

Lines changed: 3127 additions & 592 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tumaet/webapp": minor
3+
---
4+
5+
Drag a `.json` diagram exported from Apollon anywhere onto the app to import it — it opens as a new diagram, leaving whatever you had open untouched. A drop overlay confirms the target, and the File-menu "Import" item takes the same path.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@tumaet/apollon": minor
3+
---
4+
5+
Duplicates the selection with Ctrl/Cmd+D — edges between the duplicated elements included — and puts the viewport on the keyboard: Ctrl/Cmd+= and Ctrl/Cmd+- to zoom, Ctrl/Cmd+0 for 100%, Ctrl/Cmd+Shift+1 and Ctrl/Cmd+Shift+2 to fit the diagram or the selection. Esc still clears the selection, which Ctrl/Cmd+D used to do. Zooming, copying and selecting now work on a read-only diagram, shortcuts stay out of open dialogs and half-typed IME characters, and holding a key repeats only undo, redo and zoom.
6+
7+
For embedders: `APOLLON_SHORTCUTS` lists every key the editor consumes, alongside `matchesShortcutCombo`, `isTypingTarget`, `isInsideOverlay` and `shortcutKeyName` — enough to render a shortcut sheet that tracks the editor, or to bind your own keys under the same rules. Pass `keyboardShortcuts: false` to keep the editor off the keyboard entirely.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tumaet/webapp": minor
3+
---
4+
5+
Saves the diagram as a JSON file with Ctrl/Cmd+S, instead of the browser offering to save the page. Ctrl/Cmd+Shift+S now writes a version to history straight away — it used to only open the panel. The "How to use this editor?" dialog lists every shortcut with the keys your own keyboard prints — ⌘⇧Z on a Mac, Ctrl+Shift+Z everywhere else — and its viewport tips now match what the canvas does: scroll or drag pans, Ctrl/Cmd+scroll zooms.

docs/library/api.md

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,17 @@ afterwards. Re-key the component to apply them to a new editor.
7272
changes; no rebuild. Passing `undefined` for any reactive prop leaves the
7373
live value untouched (no reset). Re-key the component to fully reset.
7474

75-
| Prop | Type | Maps to |
76-
| ------------- | ------------------------ | ------------------------------------------- |
77-
| `readonly` | `boolean` | `editor.setReadonly(value)` |
78-
| `view` | `ApollonView` | `editor.view = value` |
79-
| `mode` | `ApollonMode` | `editor.setMode(value)` |
80-
| `scrollLock` | `boolean` | `editor.setScrollLock(value)` |
81-
| `labels` | `Partial<ApollonLabels>` | `editor.setLabels(value)` |
82-
| `tags` | `boolean \| TagOptions` | `editor.setTags(value)` |
83-
| `previewMode` | `boolean` | `editor.setPreviewMode(value)` |
84-
| `model` | `UMLModel` | `editor.model = value` — controlled overlay |
75+
| Prop | Type | Maps to |
76+
| ------------------- | ------------------------ | ------------------------------------------- |
77+
| `readonly` | `boolean` | `editor.setReadonly(value)` |
78+
| `view` | `ApollonView` | `editor.view = value` |
79+
| `mode` | `ApollonMode` | `editor.setMode(value)` |
80+
| `scrollLock` | `boolean` | `editor.setScrollLock(value)` |
81+
| `keyboardShortcuts` | `boolean` | `editor.setKeyboardShortcuts(value)` |
82+
| `labels` | `Partial<ApollonLabels>` | `editor.setLabels(value)` |
83+
| `tags` | `boolean \| TagOptions` | `editor.setTags(value)` |
84+
| `previewMode` | `boolean` | `editor.setPreviewMode(value)` |
85+
| `model` | `UMLModel` | `editor.model = value` — controlled overlay |
8586

8687
**Lifecycle.**
8788

@@ -262,6 +263,68 @@ These members are only meaningful with `collaborationEnabled: true`. See
262263
the [Conversion API](/library/api/conversion-api) to convert models to
263264
SVG/PNG/PDF over HTTP via the standalone server.
264265

266+
## Keyboard shortcuts
267+
268+
`Mod` is Ctrl on Windows/Linux and Cmd on macOS; combos marked _view_ work on
269+
read-only diagrams too. Nothing fires while the user is typing in a field or
270+
while a dialog or menu is open.
271+
272+
| Combo | Action |
273+
| ------------------------------ | ---------------------------------------- |
274+
| `Mod+A` / `Esc` | Select all / clear selection (_view_) |
275+
| `Delete` / `Backspace` | Delete selection |
276+
| `Mod+C` / `Mod+X` / `Mod+V` | Copy (_view_) / cut / paste |
277+
| `Mod+D` | Duplicate the selection beside itself |
278+
| Arrow keys | Nudge selection |
279+
| `Mod+Z`, `Mod+Shift+Z`/`Mod+Y` | Undo, redo |
280+
| `Mod+=` / `Mod+-` | Zoom in / out (_view_) |
281+
| `Mod+0` | Reset zoom to 100% (_view_) |
282+
| `Mod+Shift+1` / `Mod+Shift+2` | Zoom to fit / zoom to selection (_view_) |
283+
284+
Figma and Excalidraw put the last two on `Shift+1`/`Shift+2`, but a shortcut
285+
whose keys produce a printable character fails
286+
[WCAG 2.1.4](https://www.w3.org/WAI/WCAG21/Understanding/character-key-shortcuts)
287+
unless it can be turned off, remapped, or scoped to focus.
288+
289+
Pass `keyboardShortcuts: false` to keep the editor's hands off every key above —
290+
for a host that binds them itself, or that mounts more than one editor (they
291+
listen on `document`, so two would both answer).
292+
293+
`APOLLON_SHORTCUTS` is the list the editor runs, so a host can render a sheet
294+
that tracks it, or check it before binding a key of its own. Each entry's
295+
**first** combo is the primary one — a sheet should render only that; the rest
296+
are aliases (`Mod+Y` redo, layout variants of `Mod+=`). Entries flagged
297+
`canvasHandled` are React Flow's, not the editor's own handler. `shortcutKeyName`
298+
turns a combo into the key it names, so a sheet renders "1" rather than the
299+
`Digit1` code that combo matches on.
300+
301+
`matchesShortcutCombo`, `isTypingTarget` and `isInsideOverlay` are the
302+
primitives that handler matches and stands down with, exported so a host's own
303+
keys behave like the editor's:
304+
305+
```ts
306+
import {
307+
isInsideOverlay,
308+
isTypingTarget,
309+
matchesShortcutCombo,
310+
type ApollonShortcutCombo,
311+
} from "@tumaet/apollon"
312+
313+
const rename: ApollonShortcutCombo = { key: "r", mod: true }
314+
315+
document.addEventListener("keydown", (event) => {
316+
if (event.isComposing || isTypingTarget(event) || isInsideOverlay(event)) {
317+
return
318+
}
319+
if (!matchesShortcutCombo(event, rename)) return
320+
event.preventDefault()
321+
})
322+
```
323+
324+
A combo matching on `key` follows what the user's layout prints and is compared
325+
case-insensitively; use `code` for digits, whose character moves between layouts
326+
and under Shift.
327+
265328
## Diagram types
266329

267330
Enum literals (the strings on the wire and in `UMLModel.type`) on the left;

library/lib/App.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
100100
readonly,
101101
scrollLock,
102102
scrollEnabled,
103+
keyboardShortcuts,
103104
connectionGuidanceActive,
104105
startReconnectPreview,
105106
stopReconnectPreview,
@@ -109,6 +110,7 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
109110
readonly: state.readonly,
110111
scrollLock: state.scrollLock,
111112
scrollEnabled: state.scrollEnabled,
113+
keyboardShortcuts: state.keyboardShortcuts,
112114
connectionGuidanceActive: state.connectionGuidanceActive,
113115
startReconnectPreview: state.startReconnectPreview,
114116
stopReconnectPreview: state.stopReconnectPreview,
@@ -264,8 +266,14 @@ function App({ onReactFlowInit, collaboration, awareness }: AppProps) {
264266
selectionOnDrag={multiSelectionMode}
265267
panOnDrag={multiSelectionMode ? [1, 2] : true}
266268
// Delete the current selection with either key (Backspace on macOS,
267-
// Delete on full keyboards).
268-
deleteKeyCode={["Backspace", "Delete"]}
269+
// Delete on full keyboards) — but hand these keys back with the
270+
// editor's other shortcuts when a host opts out via
271+
// `keyboardShortcuts: false`. `onBeforeDelete` additionally blocks a
272+
// delete whose focus is inside an overlay over the canvas.
273+
deleteKeyCode={keyboardShortcuts ? ["Backspace", "Delete"] : []}
274+
// Arrow-key node nudging + Enter/Escape selection a11y are React
275+
// Flow's; disable them together with the rest when shortcuts are off.
276+
disableKeyboardA11y={!keyboardShortcuts}
269277
>
270278
<CustomBackground />
271279
<AlignmentGuides />

library/lib/apollon-editor.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,11 @@ export class ApollonEditor {
234234
if (options?.scrollLock !== undefined) {
235235
this.metadataStore.getState().setScrollLock(options.scrollLock)
236236
}
237+
if (options?.keyboardShortcuts !== undefined) {
238+
this.metadataStore
239+
.getState()
240+
.setKeyboardShortcuts(options.keyboardShortcuts)
241+
}
237242
if (options?.labels !== undefined) {
238243
this.metadataStore.getState().setLabels(mergeLabels(options.labels))
239244
}
@@ -934,6 +939,14 @@ export class ApollonEditor {
934939
this.metadataStore.getState().setScrollLock(scrollLock)
935940
}
936941

942+
/**
943+
* Live-toggle whether the editor answers its keyboard shortcuts (see
944+
* `APOLLON_SHORTCUTS`), React Flow's delete and arrow-key moving included.
945+
*/
946+
public setKeyboardShortcuts(keyboardShortcuts: boolean): void {
947+
this.metadataStore.getState().setKeyboardShortcuts(keyboardShortcuts)
948+
}
949+
937950
/**
938951
* Replace the editor's user-facing strings (i18n). Merged over the English
939952
* defaults, so a partial map only changes the keys it provides. Reactive — the

library/lib/chrome/builtins/ZoomControls.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
useOverlayStore,
1515
} from "@/store/context"
1616
import { insetAwareFitView } from "@/overlay/fitView"
17+
import { ariaKeyshortcuts } from "@/keyboard"
1718
import { Tooltip } from "@/components/ui"
1819
import { useLabels } from "@/i18n/useLabels"
1920
import { useRovingToolbar } from "../useRovingToolbar"
@@ -72,6 +73,7 @@ export function ZoomControls({ history = true }: ZoomControlsProps) {
7273
type="button"
7374
className="apollon-chrome-iconbtn"
7475
onClick={() => rf.zoomOut()}
76+
aria-keyshortcuts={ariaKeyshortcuts("zoom-out")}
7577
aria-label={t.zoomOut}
7678
>
7779
<ZoomOut width={18} height={18} aria-hidden="true" />
@@ -83,6 +85,7 @@ export function ZoomControls({ history = true }: ZoomControlsProps) {
8385
type="button"
8486
className="apollon-chrome-iconbtn apollon-chrome-iconbtn--readout"
8587
onClick={() => rf.zoomTo(1)}
88+
aria-keyshortcuts={ariaKeyshortcuts("reset-zoom")}
8689
aria-label={t.zoomReadout(zoomLevelPercent)}
8790
>
8891
{zoomLevelPercent}%
@@ -93,6 +96,7 @@ export function ZoomControls({ history = true }: ZoomControlsProps) {
9396
type="button"
9497
className="apollon-chrome-iconbtn"
9598
onClick={() => rf.zoomIn()}
99+
aria-keyshortcuts={ariaKeyshortcuts("zoom-in")}
96100
aria-label={t.zoomIn}
97101
>
98102
<ZoomIn width={18} height={18} aria-hidden="true" />
@@ -103,6 +107,7 @@ export function ZoomControls({ history = true }: ZoomControlsProps) {
103107
type="button"
104108
className="apollon-chrome-iconbtn"
105109
onClick={() => insetAwareFitView(rf, insets, safeArea)}
110+
aria-keyshortcuts={ariaKeyshortcuts("fit-view")}
106111
aria-label={t.fitView}
107112
>
108113
<Maximize width={18} height={18} aria-hidden="true" />
@@ -129,6 +134,7 @@ export function ZoomControls({ history = true }: ZoomControlsProps) {
129134
type="button"
130135
className="apollon-chrome-iconbtn"
131136
onClick={undo}
137+
aria-keyshortcuts={ariaKeyshortcuts("undo")}
132138
disabled={!canUndo}
133139
aria-label={t.undo}
134140
>
@@ -142,6 +148,7 @@ export function ZoomControls({ history = true }: ZoomControlsProps) {
142148
type="button"
143149
className="apollon-chrome-iconbtn"
144150
onClick={redo}
151+
aria-keyshortcuts={ariaKeyshortcuts("redo")}
145152
disabled={!canRedo}
146153
aria-label={t.redo}
147154
>

library/lib/components/react/Apollon.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ export interface ApollonProps {
6868
view?: ApollonView
6969
mode?: ApollonMode
7070
scrollLock?: boolean
71+
/**
72+
* Answer the editor's keyboard shortcuts (see `APOLLON_SHORTCUTS`), including
73+
* React Flow's delete and arrow-key moving. Set `false` where the host binds
74+
* those keys itself, or mounts more than one editor. Default `true`.
75+
*/
76+
keyboardShortcuts?: boolean
7177
/** Override the editor's own strings for i18n. See {@link ApollonEditor.setLabels}. */
7278
labels?: Partial<ApollonLabels>
7379
/** Enable + configure element-tag authoring. See {@link ApollonEditor.setTags}. */
@@ -118,6 +124,7 @@ export function Apollon(props: ApollonProps) {
118124
view,
119125
mode,
120126
scrollLock,
127+
keyboardShortcuts,
121128
labels,
122129
tags,
123130
previewMode,
@@ -200,6 +207,12 @@ export function Apollon(props: ApollonProps) {
200207
if (editor && scrollLock !== undefined) editor.setScrollLock(scrollLock)
201208
}, [editor, scrollLock])
202209

210+
useEffect(() => {
211+
if (editor && keyboardShortcuts !== undefined) {
212+
editor.setKeyboardShortcuts(keyboardShortcuts)
213+
}
214+
}, [editor, keyboardShortcuts])
215+
203216
useEffect(() => {
204217
if (editor && labels !== undefined) editor.setLabels(labels)
205218
}, [editor, labels])

library/lib/hooks/useElementInteractions.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "@xyflow/react"
1111
import { useShallow } from "zustand/shallow"
1212
import { useDiagramModifiable } from "./useDiagramModifiable"
13+
import { isElementInOverlay } from "@/keyboard"
1314

1415
export const useElementInteractions = () => {
1516
const isDiagramModifiable = useDiagramModifiable()
@@ -28,7 +29,14 @@ export const useElementInteractions = () => {
2829
const canOpenPopover = isDiagramModifiable || canOpenAssessmentPopover
2930

3031
const onBeforeDelete: OnBeforeDelete = () => {
31-
return new Promise((resolve) => resolve(isDiagramModifiable))
32+
// React Flow's Delete listener is document-level, so a Delete pressed while
33+
// focus is in a dialog or menu over the canvas would otherwise remove the
34+
// selection behind it. Block that here — the one place every RF deletion
35+
// funnels through — the same way the editor's own shortcuts stand down.
36+
if (isElementInOverlay(document.activeElement)) {
37+
return Promise.resolve(false)
38+
}
39+
return Promise.resolve(isDiagramModifiable)
3240
}
3341

3442
const onNodeDoubleClick: NodeMouseHandler<Node> = (_event, node) => {

0 commit comments

Comments
 (0)