@preset.nz/app-kit 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -2
- package/package.json +44 -3
- package/src-ts/CommandToolbar.tsx +25 -0
- package/src-ts/commands.ts +138 -0
- package/src-ts/core.ts +23 -0
- package/src-ts/document.ts +58 -0
- package/src-ts/history.ts +46 -0
- package/src-ts/index.ts +5 -0
- package/src-ts/nativeContextMenu.ts +22 -0
- package/src-ts/selection.ts +81 -0
- package/src-ts/store.ts +29 -0
- package/src-ts/text.ts +77 -0
- package/src-ts/toolbar.ts +31 -0
- package/src-ts/useMenuSync.ts +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 preset.nz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# app-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The behaviour of a desktop app, shared across the preset.nz family: one command table feeding the native menu and the toolbar, Undo and Redo that say what they undo, Cmd+Z that never fights a text field, and a small selection store. [`ux-kit`](https://github.com/preset-nz/ux-kit) holds the look; app-kit holds how the app acts.
|
|
4
|
+
|
|
5
|
+
An app declares its commands in Rust and plugs in its history. app-kit builds the menu bar, keeps Undo and Redo titled and gated, and hands the same table to the webview, so a label or an accelerator is written once.
|
|
6
|
+
|
|
7
|
+
Two packages, one repo, one version:
|
|
8
|
+
|
|
9
|
+
| Path | Package | What it is |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `src/` | `preset-app-kit` (crate) | Command table, menu builder, `History` trait, Tauri commands |
|
|
12
|
+
| `src-ts/` | `@preset.nz/app-kit` (npm) | `useCommands`, toolbar bridge, history hook, text-field undo, selection store |
|
|
13
|
+
|
|
14
|
+
Pre-1.0: a minor version can break things, a patch never does. Crate and npm versions move together, and the TypeScript types mirror the serde shape by hand.
|
|
15
|
+
|
|
16
|
+
The toolbar bridge renders ux-kit's `ToolbarItems`, so ux-kit is an optional peer; `@preset.nz/app-kit/core` needs none of it. ux-kit never depends on app-kit. Neither depends on rhizome: each app adapts its own history to the trait.
|
|
17
|
+
|
|
18
|
+
## Rust
|
|
19
|
+
|
|
20
|
+
```rust
|
|
21
|
+
use preset_app_kit::{AppKit, Command, History, MenuName};
|
|
22
|
+
|
|
23
|
+
impl History for Doc { /* undo_label, redo_label, undo_labels, redo_labels, undo, redo */ }
|
|
24
|
+
|
|
25
|
+
tauri::Builder::default()
|
|
26
|
+
.manage(Doc::new())
|
|
27
|
+
.setup(|app| {
|
|
28
|
+
AppKit::<tauri::Wry>::new("My App")
|
|
29
|
+
.settings() // opt in: Settings… with Cmd+,
|
|
30
|
+
.commands([
|
|
31
|
+
Command::toggle("panel.left", "Show Outline")
|
|
32
|
+
.accelerator("CmdOrCtrl+Alt+S")
|
|
33
|
+
.toolbar_label("Outline")
|
|
34
|
+
.menu(MenuName::View)
|
|
35
|
+
.section(0),
|
|
36
|
+
Command::item("doc.reset", "Reset").accelerator("CmdOrCtrl+Alt+R").disabled(),
|
|
37
|
+
])
|
|
38
|
+
.on_command(|app, id| match id { "app.settings" => { /* open it */ true } _ => false })
|
|
39
|
+
.install::<Doc>(app.handle())?; // Doc is the managed History
|
|
40
|
+
Ok(())
|
|
41
|
+
})
|
|
42
|
+
.invoke_handler(tauri::generate_handler![
|
|
43
|
+
preset_app_kit::app_kit_commands,
|
|
44
|
+
preset_app_kit::app_kit_menu_state,
|
|
45
|
+
preset_app_kit::app_kit_history,
|
|
46
|
+
preset_app_kit::app_kit_undo,
|
|
47
|
+
preset_app_kit::app_kit_redo,
|
|
48
|
+
preset_app_kit::app_kit_text_menu,
|
|
49
|
+
])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- **`Command`**: id, menu label, optional toolbar label, Tauri accelerator, `MenuName` (App, File, Edit, View, Window, Help), `section` (items in a section sit together), item or toggle. Undo and Redo are built in; Settings is opt-in.
|
|
53
|
+
- **File menu**: New, Open…, Open Recent ▸, Close, Save, Save As… and Revert to Saved… are built in, run against the app's `Document` behind the close guard. Revert is enabled only for an unsaved document with a file, and asks first, because reloading starts an empty history. Open Recent keeps ten documents, newest first, in `recent-documents.json` in the app's data directory; a file that has gone says so and leaves the list. `note_recent(app, path)` adds a document opened some other way (from the Finder, or by restore), and `recent_documents(app)[0]` is the last document. `.documents_folder(|app| …)` names where the Open and Save panels start for an Untitled document (`~/preset-nz/<App>/…`); the app supplies the lookup, so app-kit doesn't depend on app-folders.
|
|
54
|
+
- **Relaunch**: `.plugin(preset_app_kit::window_state())` on the builder brings each window back at its size and position (`tauri-plugin-window-state`, not visibility). `on_run_event` reopens the last document at launch, the first entry of Open Recent, and opens a document the Finder hands over (double-click, `open some.x`, the Dock), which wins over the restored one. A last document that has gone, or won't load, becomes a note, not a dialog: register `app_kit_document_note` and show what `useDocumentNotes` hands you. For an app with documents, app-kit owns Finder opens, so don't add a second handler.
|
|
55
|
+
- **Menu config**: `.menu_config(include_str!("../menu.toml"))` switches the built-in items on and off: `[file] new, open, open_recent, close, save, save_as, revert` and `[app] settings`. A missing key takes the default (on); an unknown key is an error, so add a test that calls `MenuConfig::parse` on your file. Labels, accelerators and positions stay the standard's. Design: guidance `projects/app-kit/design/menu-config.md`.
|
|
56
|
+
- **Apps without documents**: `install_history::<H: History>` instead of `install::<H: Document>`, for a library such as Strata. The config must turn New, Open, Open Recent, Save, Save As and Revert off; with Close off too, Window gains Close Window on Cmd+W. No close guard, no window-title handling, and it doesn't register `tauri-plugin-dialog`, so the app keeps its own.
|
|
57
|
+
- **`History`**: `undo_label`, `redo_label`, `undo_labels` (oldest first), `redo_labels` (next first), `undo`, `redo`. Implement it on the type you manage as Tauri state. The reads may lock, so call `refresh_history(app)` after a change and never while holding that lock.
|
|
58
|
+
- **`edit_titles`**: the pure rule behind Edit > Undo: "Undo Set Opacity" when there is a step, plain and disabled when there is none, plain and enabled while a text field has focus.
|
|
59
|
+
- **Events**: `command` (id, to the main window), `text-undo` (to the focused window), `app-kit://history` (a `HistoryState`), `app-kit://document` (a `DocumentState`), `app-kit://note` (a string).
|
|
60
|
+
- Commands are plain Tauri commands, not a plugin, so no capability entries are needed.
|
|
61
|
+
|
|
62
|
+
## TypeScript
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
import { CommandToolbar, useCommands, nativeContextMenu } from "@preset.nz/app-kit"
|
|
66
|
+
|
|
67
|
+
const { commands, run, shortcut } = useCommands(bindings) // bindings: Record<id, { icon, run, enabled, pressed, ... }>
|
|
68
|
+
<CommandToolbar commands={commands} layout={{ leading: ["panel.left"], groups: [["edit.undo", "edit.redo"]] }} run={run} />
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- **`useCommands(bindings)`**: loads the table from Rust, binds `run`, icon and gating by id, follows the history, routes text-field undo and keeps the menu's enabled and checked state in step. `edit.undo` and `edit.redo` need only an icon. `bindCommands`, `useCommandTable`, `useMenuSync`, `toItems` are the parts.
|
|
72
|
+
- **`CommandToolbar`**: ux-kit's `ToolbarItems` fed from the commands.
|
|
73
|
+
- **`@preset.nz/app-kit/core`**: everything except `CommandToolbar` and `toItems`, with no ux-kit import, for apps that use only the menu half. ux-kit is an optional peer.
|
|
74
|
+
- **`useDocument`, `useDocumentNotes(onNote)`**: the live `DocumentState`, and notes about the document (a last document that wouldn't reopen), each delivered once. Show a note as a snackbar.
|
|
75
|
+
- **`useHistory`, `undo`, `redo`**: the live `HistoryState`, and the same undo the menu takes. The Inspector's History list reads `undoLabels` and `redoLabels`.
|
|
76
|
+
- **`useTextUndo`, `useTextFocus`, `isTextField`, `blurField`**: Cmd+Z undoes typing while a text field has focus, the document otherwise.
|
|
77
|
+
- **`createSelection<T>(none)`**: single-select store with `get`, `use`, `select`, `clear`. **`createMultiSelection<T>(key)`**: the opt-in multi-select. **`createStore`, `useStore`**: the small store under both. Wrap them in your own narrow setters; there is no `setSelection`, and selection is not an undo step.
|
|
78
|
+
- **`nativeContextMenu()`**: no WebKit context menu. Right-click in a text field pops up a short native menu (Undo, Redo, Cut, Copy, Paste, Select All; needs `app_kit_text_menu` registered); elsewhere nothing, and app-drawn menus still open.
|
|
79
|
+
|
|
80
|
+
The table arrives from Rust, so a plain browser tab (`just dev-web` in the playground) has no commands and an empty toolbar.
|
|
81
|
+
|
|
82
|
+
## Consuming it
|
|
83
|
+
|
|
84
|
+
```toml
|
|
85
|
+
# src-tauri/Cargo.toml
|
|
86
|
+
preset-app-kit = "0.1"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
pnpm add @preset.nz/app-kit
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`@preset.nz/app-kit/core` is everything except the toolbar, for an app that doesn't use ux-kit; ux-kit is an optional peer.
|
|
94
|
+
|
|
95
|
+
In Vite, add `@preset.nz/app-kit` (with `react`, `react-dom`, `@tauri-apps/api`, `@preset.nz/ux-kit`) to `resolve.dedupe`; a second copy of React fails only in the window. app-kit ships no Tailwind classes, so it needs no `@source` line.
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
`just check` runs `cargo fmt --check`, clippy, `cargo test`, `tsc` and eslint. MIT.
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preset.nz/app-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The behaviour of a preset.nz desktop app: command table, menu sync, Undo and Redo titles, text-field undo routing and a selection store, over the preset-app-kit Rust crate.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/preset-nz/app-kit.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src-ts/index.ts",
|
|
14
|
+
"./core": "./src-ts/core.ts"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src-ts"
|
|
18
|
+
],
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@preset.nz/ux-kit": "^0.1.0",
|
|
21
|
+
"@tauri-apps/api": "^2",
|
|
22
|
+
"react": "^19"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@eslint/js": "^9.39.4",
|
|
26
|
+
"@preset.nz/ux-kit": "^0.1.0",
|
|
27
|
+
"@tauri-apps/api": "^2.11.1",
|
|
28
|
+
"@types/react": "^19.2.17",
|
|
29
|
+
"@types/react-dom": "^19.2.3",
|
|
30
|
+
"eslint": "^9.39.4",
|
|
31
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
32
|
+
"eslint-plugin-react-refresh": "^0.5.3",
|
|
33
|
+
"globals": "^16.5.0",
|
|
34
|
+
"react": "^19.2.7",
|
|
35
|
+
"react-dom": "^19.2.7",
|
|
36
|
+
"typescript": "~5.9.3",
|
|
37
|
+
"typescript-eslint": "^8.61.1"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"@preset.nz/ux-kit": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
6
47
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ComponentProps } from "react"
|
|
2
|
+
import { ToolbarItems } from "@preset.nz/ux-kit"
|
|
3
|
+
|
|
4
|
+
import type { Command } from "./commands"
|
|
5
|
+
import { toItems, type ToolbarLayout } from "./toolbar"
|
|
6
|
+
|
|
7
|
+
type Props = Omit<ComponentProps<typeof ToolbarItems>, "groups" | "leading" | "trailing" | "onCommand"> & {
|
|
8
|
+
commands: Command[]
|
|
9
|
+
layout: ToolbarLayout
|
|
10
|
+
/** From `useCommands`: runs the command the menu would. */
|
|
11
|
+
run: (id: string) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** ux-kit's `ToolbarItems`, fed from the command table: same labels, shortcuts and gating as the menu. */
|
|
15
|
+
export function CommandToolbar({ commands, layout, run, ...props }: Props) {
|
|
16
|
+
return (
|
|
17
|
+
<ToolbarItems
|
|
18
|
+
{...props}
|
|
19
|
+
leading={toItems(commands, layout.leading)}
|
|
20
|
+
groups={layout.groups.map((ids) => toItems(commands, ids))}
|
|
21
|
+
trailing={toItems(commands, layout.trailing)}
|
|
22
|
+
onCommand={run}
|
|
23
|
+
/>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
|
2
|
+
import { invoke } from "@tauri-apps/api/core"
|
|
3
|
+
|
|
4
|
+
import { redo, undo, useHistory, type HistoryState } from "./history"
|
|
5
|
+
import { useTextUndo } from "./text"
|
|
6
|
+
import { useMenuSync } from "./useMenuSync"
|
|
7
|
+
|
|
8
|
+
/** A command as Rust declares it (`CommandInfo`): the one place ids, labels and accelerators live. */
|
|
9
|
+
export interface CommandSpec {
|
|
10
|
+
id: string
|
|
11
|
+
/** Menu text. */
|
|
12
|
+
label: string
|
|
13
|
+
/** Toolbar text, when it differs from the menu's. */
|
|
14
|
+
toolbarLabel: string | null
|
|
15
|
+
/** Tauri accelerator string, `CmdOrCtrl+Alt+S`. */
|
|
16
|
+
accelerator: string | null
|
|
17
|
+
/** Display form for tooltips, `⌥⌘S`. */
|
|
18
|
+
shortcut: string | null
|
|
19
|
+
menu: "app" | "file" | "edit" | "view" | "domain" | "window" | "help"
|
|
20
|
+
/** The domain menu's title ("Effect"), for `menu: "domain"`. */
|
|
21
|
+
domain: string | null
|
|
22
|
+
/** The submenu of its section ("Add"), if any. */
|
|
23
|
+
submenu: string | null
|
|
24
|
+
/** The op's own category and tags, for the Add submenu, the library and the palette. */
|
|
25
|
+
category: string | null
|
|
26
|
+
tags: string[]
|
|
27
|
+
kind: "item" | "toggle"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What the app attaches to a command id: what it does, how it looks, when it is available. */
|
|
31
|
+
export interface Binding {
|
|
32
|
+
icon?: ReactNode
|
|
33
|
+
/** Only for text that depends on state ("Light mode" / "Dark mode"). Otherwise the table's label shows. */
|
|
34
|
+
label?: string
|
|
35
|
+
/** Default true. */
|
|
36
|
+
enabled?: boolean
|
|
37
|
+
disabledReason?: string
|
|
38
|
+
/** For toggles. */
|
|
39
|
+
pressed?: boolean
|
|
40
|
+
run?: () => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A command bound on both sides: the table's text and shortcut, the app's `run`, icon and gating. */
|
|
44
|
+
export interface Command {
|
|
45
|
+
id: string
|
|
46
|
+
label: string
|
|
47
|
+
icon: ReactNode
|
|
48
|
+
shortcut?: string
|
|
49
|
+
enabled: boolean
|
|
50
|
+
disabledReason?: string
|
|
51
|
+
/** Present for toggles. */
|
|
52
|
+
pressed?: boolean
|
|
53
|
+
run: () => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The command table from Rust. Null until it arrives (a few milliseconds; never in a plain browser tab). */
|
|
57
|
+
export function useCommandTable(): CommandSpec[] | null {
|
|
58
|
+
const [table, setTable] = useState<CommandSpec[] | null>(null)
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
invoke<CommandSpec[]>("app_kit_commands").then(setTable, () => {})
|
|
61
|
+
}, [])
|
|
62
|
+
return table
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Undo and redo are built in: their text, gating and action come from the history. */
|
|
66
|
+
function historyBindings(h: HistoryState | null): Record<string, Binding> {
|
|
67
|
+
return {
|
|
68
|
+
"edit.undo": {
|
|
69
|
+
label: h?.undoLabel ? `Undo ${h.undoLabel}` : "Undo",
|
|
70
|
+
enabled: h?.undoLabel != null,
|
|
71
|
+
disabledReason: "Nothing to undo",
|
|
72
|
+
run: () => void undo(),
|
|
73
|
+
},
|
|
74
|
+
"edit.redo": {
|
|
75
|
+
label: h?.redoLabel ? `Redo ${h.redoLabel}` : "Redo",
|
|
76
|
+
enabled: h?.redoLabel != null,
|
|
77
|
+
disabledReason: "Nothing to redo",
|
|
78
|
+
run: () => void redo(),
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Bind the table to the app's behaviour by id. Commands with no binding (or no `run`) stay
|
|
85
|
+
* menu-only. For `edit.undo` and `edit.redo` a binding supplies just the icon; the rest is the history's.
|
|
86
|
+
*/
|
|
87
|
+
export function bindCommands(
|
|
88
|
+
table: CommandSpec[],
|
|
89
|
+
bindings: Record<string, Binding>,
|
|
90
|
+
history: HistoryState | null = null,
|
|
91
|
+
): Command[] {
|
|
92
|
+
const builtin = historyBindings(history)
|
|
93
|
+
return table.flatMap((spec) => {
|
|
94
|
+
const b: Binding = { ...bindings[spec.id], ...builtin[spec.id] }
|
|
95
|
+
const bound = spec.id in bindings || spec.id in builtin
|
|
96
|
+
if (!bound || !b.run) return []
|
|
97
|
+
return [
|
|
98
|
+
{
|
|
99
|
+
id: spec.id,
|
|
100
|
+
label: b.label ?? spec.toolbarLabel ?? spec.label,
|
|
101
|
+
icon: b.icon ?? null,
|
|
102
|
+
shortcut: spec.shortcut ?? undefined,
|
|
103
|
+
enabled: b.enabled ?? true,
|
|
104
|
+
disabledReason: b.disabledReason,
|
|
105
|
+
pressed: spec.kind === "toggle" ? (b.pressed ?? false) : b.pressed,
|
|
106
|
+
run: b.run,
|
|
107
|
+
},
|
|
108
|
+
]
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The app's whole command wiring in one hook: loads the table, binds it, follows the history,
|
|
114
|
+
* routes text-field undo, and keeps the native menu's enabled and checked state in step.
|
|
115
|
+
* `bindings` should be memoised; the commands are rebuilt when it changes.
|
|
116
|
+
*/
|
|
117
|
+
export function useCommands(bindings: Record<string, Binding>) {
|
|
118
|
+
const table = useCommandTable()
|
|
119
|
+
const history = useHistory()
|
|
120
|
+
const textFocus = useTextUndo()
|
|
121
|
+
const commands = useMemo(
|
|
122
|
+
() => (table ? bindCommands(table, bindings, history) : []),
|
|
123
|
+
[table, bindings, history],
|
|
124
|
+
)
|
|
125
|
+
useMenuSync(commands, textFocus)
|
|
126
|
+
|
|
127
|
+
/** Run a command by id, as the menu does: only when enabled. */
|
|
128
|
+
const run = useCallback(
|
|
129
|
+
(id: string) => {
|
|
130
|
+
const c = commands.find((x) => x.id === id)
|
|
131
|
+
if (c?.enabled) c.run()
|
|
132
|
+
},
|
|
133
|
+
[commands],
|
|
134
|
+
)
|
|
135
|
+
/** A command's display shortcut, for anything that shows one (a panel header's tooltip). */
|
|
136
|
+
const shortcut = useCallback((id: string) => table?.find((s) => s.id === id)?.shortcut ?? undefined, [table])
|
|
137
|
+
return { commands, run, shortcut, history }
|
|
138
|
+
}
|
package/src-ts/core.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The menu half of app-kit: commands, history, text-field undo, selection and stores, with
|
|
2
|
+
// no dependency on ux-kit. `index.ts` adds the toolbar bridge on top.
|
|
3
|
+
export {
|
|
4
|
+
bindCommands,
|
|
5
|
+
useCommands,
|
|
6
|
+
useCommandTable,
|
|
7
|
+
type Binding,
|
|
8
|
+
type Command,
|
|
9
|
+
type CommandSpec,
|
|
10
|
+
} from "./commands"
|
|
11
|
+
export { useDocument, useDocumentNotes, type DocumentState } from "./document"
|
|
12
|
+
export { redo, undo, useHistory, type HistoryState } from "./history"
|
|
13
|
+
export { nativeContextMenu } from "./nativeContextMenu"
|
|
14
|
+
export {
|
|
15
|
+
createMultiSelection,
|
|
16
|
+
createSelection,
|
|
17
|
+
insertIndex,
|
|
18
|
+
type MultiSelection,
|
|
19
|
+
type Selection,
|
|
20
|
+
} from "./selection"
|
|
21
|
+
export { createStore, useStore, type Store } from "./store"
|
|
22
|
+
export { blurField, isTextField, useTextFocus, useTextUndo } from "./text"
|
|
23
|
+
export { useMenuSync } from "./useMenuSync"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react"
|
|
2
|
+
import { invoke } from "@tauri-apps/api/core"
|
|
3
|
+
import { listen } from "@tauri-apps/api/event"
|
|
4
|
+
|
|
5
|
+
/** The document as Rust reports it (`DocumentState`): from `app_kit_document`, pushed as `app-kit://document`. */
|
|
6
|
+
export interface DocumentState {
|
|
7
|
+
/** `Untitled-1` or the file's name. */
|
|
8
|
+
name: string
|
|
9
|
+
path: string | null
|
|
10
|
+
unsaved: boolean
|
|
11
|
+
/** The window title: the name, then ` *` while unsaved. Rust sets it; shown here for reference. */
|
|
12
|
+
title: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The live document state, or null until the first read. The status bar reads `unsaved`. */
|
|
16
|
+
export function useDocument(): DocumentState | null {
|
|
17
|
+
const [state, setState] = useState<DocumentState | null>(null)
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
let live = true
|
|
20
|
+
invoke<DocumentState>("app_kit_document").then(
|
|
21
|
+
(s) => live && setState((cur) => cur ?? s),
|
|
22
|
+
() => {},
|
|
23
|
+
)
|
|
24
|
+
const un = listen<DocumentState>("app-kit://document", (e) => setState(e.payload))
|
|
25
|
+
return () => {
|
|
26
|
+
live = false
|
|
27
|
+
un.then((f) => f())
|
|
28
|
+
}
|
|
29
|
+
}, [])
|
|
30
|
+
return state
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Notes about the document for the app to show, such as a last document that wouldn't reopen
|
|
35
|
+
* at launch. Each arrives once: the ones held from before the webview listened, then any later
|
|
36
|
+
* `app-kit://note`. Show them as the app shows a passing message (a snackbar), never a dialog.
|
|
37
|
+
*/
|
|
38
|
+
export function useDocumentNotes(onNote: (message: string) => void): void {
|
|
39
|
+
const handler = useRef(onNote)
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
handler.current = onNote
|
|
42
|
+
}, [onNote])
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
let live = true
|
|
45
|
+
const un = listen<string>("app-kit://note", (e) => handler.current(e.payload))
|
|
46
|
+
// Ask only once listening, so nothing falls between the held notes and the event. And only
|
|
47
|
+
// while still mounted: taking them is once-only, so an unmounted effect (StrictMode mounts
|
|
48
|
+
// twice in dev) must leave them for the next.
|
|
49
|
+
un.then(() => (live ? invoke<string[]>("app_kit_document_note") : [])).then(
|
|
50
|
+
(held) => live && held.forEach((m) => handler.current(m)),
|
|
51
|
+
() => {},
|
|
52
|
+
)
|
|
53
|
+
return () => {
|
|
54
|
+
live = false
|
|
55
|
+
un.then((f) => f())
|
|
56
|
+
}
|
|
57
|
+
}, [])
|
|
58
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
import { invoke } from "@tauri-apps/api/core"
|
|
3
|
+
import { listen } from "@tauri-apps/api/event"
|
|
4
|
+
|
|
5
|
+
import { blurField } from "./text"
|
|
6
|
+
|
|
7
|
+
/** The history as Rust reports it (`HistoryState`): from the `app_kit_history` command, pushed as `app-kit://history`. */
|
|
8
|
+
export interface HistoryState {
|
|
9
|
+
undoLabel: string | null
|
|
10
|
+
redoLabel: string | null
|
|
11
|
+
/** Steps done. */
|
|
12
|
+
historyLen: number
|
|
13
|
+
/** Oldest first. */
|
|
14
|
+
undoLabels: string[]
|
|
15
|
+
/** Next first. */
|
|
16
|
+
redoLabels: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The live history, or null until the first read. */
|
|
20
|
+
export function useHistory(): HistoryState | null {
|
|
21
|
+
const [state, setState] = useState<HistoryState | null>(null)
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let live = true
|
|
24
|
+
invoke<HistoryState>("app_kit_history").then(
|
|
25
|
+
(s) => live && setState((cur) => cur ?? s),
|
|
26
|
+
() => {},
|
|
27
|
+
)
|
|
28
|
+
const un = listen<HistoryState>("app-kit://history", (e) => setState(e.payload))
|
|
29
|
+
return () => {
|
|
30
|
+
live = false
|
|
31
|
+
un.then((f) => f())
|
|
32
|
+
}
|
|
33
|
+
}, [])
|
|
34
|
+
return state
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Undo one step, the one the menu item takes. Blurs a focused field first so its text commits as an edit. */
|
|
38
|
+
export function undo(): Promise<void> {
|
|
39
|
+
blurField()
|
|
40
|
+
return invoke<void>("app_kit_undo")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function redo(): Promise<void> {
|
|
44
|
+
blurField()
|
|
45
|
+
return invoke<void>("app_kit_redo")
|
|
46
|
+
}
|
package/src-ts/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { invoke } from "@tauri-apps/api/core"
|
|
2
|
+
|
|
3
|
+
import { isTextField } from "./text"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Native context menus only (native-apps.md): right-click opens an app-drawn menu, the short
|
|
7
|
+
* native text menu, or nothing, never WebKit's Look Up / Translate / Inspect Element.
|
|
8
|
+
* - In an editable text field: WebKit's menu is prevented and `app_kit_text_menu` pops up a
|
|
9
|
+
* native Undo / Redo / Cut / Copy / Paste / Select All at the pointer.
|
|
10
|
+
* - Anywhere else: nothing.
|
|
11
|
+
* - App-drawn menus (the kit's ContextMenu) call preventDefault themselves; this leaves them be.
|
|
12
|
+
* Returns the cleanup.
|
|
13
|
+
*/
|
|
14
|
+
export function nativeContextMenu(): () => void {
|
|
15
|
+
const onContextMenu = (e: MouseEvent) => {
|
|
16
|
+
if (e.defaultPrevented) return
|
|
17
|
+
e.preventDefault()
|
|
18
|
+
if (isTextField(e.target)) void invoke("app_kit_text_menu")
|
|
19
|
+
}
|
|
20
|
+
document.addEventListener("contextmenu", onContextMenu)
|
|
21
|
+
return () => document.removeEventListener("contextmenu", onContextMenu)
|
|
22
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Interaction state, kept out of the document (guidance/design/interaction-state.md): small
|
|
2
|
+
// stores, a discriminated union, narrow setters, no `setSelection`. Selection is not an undo
|
|
3
|
+
// step: undo and redo leave it alone. A selection whose target is gone must read as none; the
|
|
4
|
+
// app knows what exists, so it resolves the item against its document before using it.
|
|
5
|
+
//
|
|
6
|
+
// An app wraps these in its own narrow setters, `selectLayer = (id) => layer.select({ kind: "layer", id })`,
|
|
7
|
+
// and exports those, never the store.
|
|
8
|
+
|
|
9
|
+
import { createStore, useStore } from "./store"
|
|
10
|
+
|
|
11
|
+
/** One selected item of type `T`, or `none`. `T` is usually a discriminated union with a `none` member. */
|
|
12
|
+
export interface Selection<T> {
|
|
13
|
+
get(): T
|
|
14
|
+
/** Read from a component. */
|
|
15
|
+
use(): T
|
|
16
|
+
select(item: T): void
|
|
17
|
+
/** Back to `none`. */
|
|
18
|
+
clear(): void
|
|
19
|
+
subscribe(listener: () => void): () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A single-select store. `none` is the value that means nothing is selected. */
|
|
23
|
+
export function createSelection<T>(none: T): Selection<T> {
|
|
24
|
+
const store = createStore<T>(none)
|
|
25
|
+
return {
|
|
26
|
+
get: store.get,
|
|
27
|
+
use: () => useStore(store),
|
|
28
|
+
select: (item) => store.set(item),
|
|
29
|
+
clear: () => store.set(none),
|
|
30
|
+
subscribe: store.subscribe,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Several selected items. Opt in where an app has multi-select; most do not. */
|
|
35
|
+
export interface MultiSelection<T> {
|
|
36
|
+
get(): readonly T[]
|
|
37
|
+
use(): readonly T[]
|
|
38
|
+
has(item: T): boolean
|
|
39
|
+
/** Replace the selection with one item. */
|
|
40
|
+
select(item: T): void
|
|
41
|
+
/** Replace the selection with these items. */
|
|
42
|
+
selectMany(items: readonly T[]): void
|
|
43
|
+
/** Add the item, or remove it when already selected. */
|
|
44
|
+
toggle(item: T): void
|
|
45
|
+
add(item: T): void
|
|
46
|
+
remove(item: T): void
|
|
47
|
+
clear(): void
|
|
48
|
+
subscribe(listener: () => void): () => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A multi-select store. `key` says when two items are the same item. Order is selection order. */
|
|
52
|
+
export function createMultiSelection<T>(key: (item: T) => string): MultiSelection<T> {
|
|
53
|
+
const store = createStore<readonly T[]>([])
|
|
54
|
+
const has = (item: T) => store.get().some((x) => key(x) === key(item))
|
|
55
|
+
const without = (item: T) => store.get().filter((x) => key(x) !== key(item))
|
|
56
|
+
return {
|
|
57
|
+
get: store.get,
|
|
58
|
+
use: () => useStore(store),
|
|
59
|
+
has,
|
|
60
|
+
select: (item) => store.set([item]),
|
|
61
|
+
selectMany: (items) => store.set([...items]),
|
|
62
|
+
toggle: (item) => store.set(has(item) ? without(item) : [...store.get(), item]),
|
|
63
|
+
add: (item) => {
|
|
64
|
+
if (!has(item)) store.set([...store.get(), item])
|
|
65
|
+
},
|
|
66
|
+
remove: (item) => store.set(without(item)),
|
|
67
|
+
clear: () => store.set([]),
|
|
68
|
+
subscribe: store.subscribe,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Where a new item goes (`ux-patterns.md`, menu-standard decision 6): straight after the selected
|
|
74
|
+
* item, or at the end when nothing in `items` is selected. Returns the index to insert at.
|
|
75
|
+
*/
|
|
76
|
+
export function insertIndex<T>(items: readonly T[], isSelected: (item: T) => boolean): number {
|
|
77
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
78
|
+
if (isSelected(items[i])) return i + 1
|
|
79
|
+
}
|
|
80
|
+
return items.length
|
|
81
|
+
}
|
package/src-ts/store.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react"
|
|
2
|
+
|
|
3
|
+
/** A tiny external store: one value, subscribers, no setter wider than the owner chooses to export. */
|
|
4
|
+
export interface Store<T> {
|
|
5
|
+
get(): T
|
|
6
|
+
set(next: T): void
|
|
7
|
+
subscribe(listener: () => void): () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createStore<T>(initial: T): Store<T> {
|
|
11
|
+
let state = initial
|
|
12
|
+
const listeners = new Set<() => void>()
|
|
13
|
+
return {
|
|
14
|
+
get: () => state,
|
|
15
|
+
set(next) {
|
|
16
|
+
if (Object.is(next, state)) return
|
|
17
|
+
state = next
|
|
18
|
+
listeners.forEach((l) => l())
|
|
19
|
+
},
|
|
20
|
+
subscribe(l) {
|
|
21
|
+
listeners.add(l)
|
|
22
|
+
return () => void listeners.delete(l)
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read a store from a component; it re-renders when the value changes. */
|
|
28
|
+
export const useStore = <T>(store: Pick<Store<T>, "get" | "subscribe">): T =>
|
|
29
|
+
useSyncExternalStore(store.subscribe, store.get)
|
package/src-ts/text.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
|
|
3
|
+
|
|
4
|
+
const TEXT_INPUT_TYPES = new Set(["", "text", "search", "url", "email", "tel", "password", "number"])
|
|
5
|
+
|
|
6
|
+
/** True for elements that have their own typing undo: text inputs, textareas, contenteditable. */
|
|
7
|
+
export function isTextField(el: EventTarget | Element | null): boolean {
|
|
8
|
+
if (!(el instanceof HTMLElement)) return false
|
|
9
|
+
if (el instanceof HTMLTextAreaElement) return !el.readOnly && !el.disabled
|
|
10
|
+
if (el instanceof HTMLInputElement) return TEXT_INPUT_TYPES.has(el.type) && !el.readOnly && !el.disabled
|
|
11
|
+
return el.isContentEditable
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether an editable text field has focus in this window. Checkbox, colour, range and other
|
|
16
|
+
* non-text inputs do not count.
|
|
17
|
+
*/
|
|
18
|
+
export function useTextFocus(): boolean {
|
|
19
|
+
const [focused, setFocused] = useState(() => isTextField(document.activeElement))
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const onIn = (e: FocusEvent) => setFocused(isTextField(e.target))
|
|
22
|
+
const onOut = (e: FocusEvent) => setFocused(isTextField(e.relatedTarget))
|
|
23
|
+
// The window losing focus keeps document.activeElement; the menu must not treat it as typing.
|
|
24
|
+
const onWinBlur = () => setFocused(false)
|
|
25
|
+
const onWinFocus = () => setFocused(isTextField(document.activeElement))
|
|
26
|
+
document.addEventListener("focusin", onIn)
|
|
27
|
+
document.addEventListener("focusout", onOut)
|
|
28
|
+
window.addEventListener("blur", onWinBlur)
|
|
29
|
+
window.addEventListener("focus", onWinFocus)
|
|
30
|
+
return () => {
|
|
31
|
+
document.removeEventListener("focusin", onIn)
|
|
32
|
+
document.removeEventListener("focusout", onOut)
|
|
33
|
+
window.removeEventListener("blur", onWinBlur)
|
|
34
|
+
window.removeEventListener("focus", onWinFocus)
|
|
35
|
+
}
|
|
36
|
+
}, [])
|
|
37
|
+
// WebKit fires no focusout when the focused field is removed from the page (a name field
|
|
38
|
+
// that unmounts on Enter), which would leave Undo stuck on the field. While a field has
|
|
39
|
+
// focus, recheck whenever nodes go away.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!focused) return
|
|
42
|
+
const observer = new MutationObserver(() => {
|
|
43
|
+
if (!isTextField(document.activeElement)) setFocused(false)
|
|
44
|
+
})
|
|
45
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
46
|
+
return () => observer.disconnect()
|
|
47
|
+
}, [focused])
|
|
48
|
+
return focused
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Text fields keep their native typing undo. Edit > Undo and Redo are custom menu items, so
|
|
53
|
+
* the menu would swallow Cmd+Z; instead, while a text field has focus:
|
|
54
|
+
* 1. this hook reports `textFocus` (`useCommands` passes it to `useMenuSync`, which sends it);
|
|
55
|
+
* 2. Rust shows plain "Undo" and "Redo" and, on select, emits `text-undo` ("undo" | "redo")
|
|
56
|
+
* to the focused window instead of touching the document;
|
|
57
|
+
* 3. this hook runs `document.execCommand` on the focused field.
|
|
58
|
+
* Fields commit on blur or Enter as one labelled edit, so the two undo scopes do not overlap.
|
|
59
|
+
* `execCommand` is deprecated but is the only way to drive WebKit's field undo stack.
|
|
60
|
+
*/
|
|
61
|
+
export function useTextUndo(): boolean {
|
|
62
|
+
const textFocus = useTextFocus()
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const un = getCurrentWebviewWindow().listen<"undo" | "redo">("text-undo", (e) => {
|
|
65
|
+
document.execCommand(e.payload)
|
|
66
|
+
})
|
|
67
|
+
return () => {
|
|
68
|
+
un.then((f) => f())
|
|
69
|
+
}
|
|
70
|
+
}, [])
|
|
71
|
+
return textFocus
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Commit a focused field (its onBlur) before a document-level undo or redo from a button. */
|
|
75
|
+
export function blurField() {
|
|
76
|
+
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
|
77
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ToolbarItemSpec } from "@preset.nz/ux-kit"
|
|
2
|
+
|
|
3
|
+
import type { Command } from "./commands"
|
|
4
|
+
|
|
5
|
+
// The ux-kit half of the command wiring. Kept apart from `commands.ts` so the `./core` entry
|
|
6
|
+
// never reaches ux-kit, for apps that only use the menu half (Strata, Fault).
|
|
7
|
+
|
|
8
|
+
/** The toolbar is a subset of the commands: these ids, in this order, in these groups. */
|
|
9
|
+
export interface ToolbarLayout {
|
|
10
|
+
leading?: string[]
|
|
11
|
+
groups: string[][]
|
|
12
|
+
trailing?: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function toItems(commands: Command[], ids: string[] = []): ToolbarItemSpec[] {
|
|
16
|
+
return ids.flatMap((id) => {
|
|
17
|
+
const c = commands.find((x) => x.id === id)
|
|
18
|
+
if (!c) return []
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
id: c.id,
|
|
22
|
+
label: c.label,
|
|
23
|
+
icon: c.icon,
|
|
24
|
+
shortcut: c.shortcut,
|
|
25
|
+
enabled: c.enabled,
|
|
26
|
+
disabledReason: c.disabledReason,
|
|
27
|
+
pressed: c.pressed,
|
|
28
|
+
},
|
|
29
|
+
]
|
|
30
|
+
})
|
|
31
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react"
|
|
2
|
+
import { invoke } from "@tauri-apps/api/core"
|
|
3
|
+
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
|
|
4
|
+
|
|
5
|
+
import type { Command } from "./commands"
|
|
6
|
+
|
|
7
|
+
/** Rust owns these: Undo and Redo follow the history, Revert to Saved… the document. */
|
|
8
|
+
const RUST_GATED_IDS = new Set(["edit.undo", "edit.redo", "file.revert"])
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Connects the commands to the native menu: menu items arrive as `command` events carrying
|
|
12
|
+
* the command id, and enabled and checked state goes back through `app_kit_menu_state`.
|
|
13
|
+
* Undo and redo are not sent; Rust sets their titles from the history, or from `textFocus`
|
|
14
|
+
* (see text.ts) while a text field has focus.
|
|
15
|
+
*/
|
|
16
|
+
export function useMenuSync(commands: Command[], textFocus: boolean) {
|
|
17
|
+
const latest = useRef(commands)
|
|
18
|
+
// Declared before the effects below, so they see the commands of this render.
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
latest.current = commands
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const un = getCurrentWebviewWindow().listen<string>("command", (e) => {
|
|
25
|
+
const c = latest.current.find((x) => x.id === e.payload)
|
|
26
|
+
if (c?.enabled) c.run()
|
|
27
|
+
})
|
|
28
|
+
return () => {
|
|
29
|
+
un.then((f) => f())
|
|
30
|
+
}
|
|
31
|
+
}, [])
|
|
32
|
+
|
|
33
|
+
const key = JSON.stringify(commands.map((c) => [c.id, c.enabled, c.pressed])) + textFocus
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const states = latest.current
|
|
36
|
+
.filter((c) => !RUST_GATED_IDS.has(c.id))
|
|
37
|
+
.map((c) => ({ id: c.id, enabled: c.enabled, checked: c.pressed }))
|
|
38
|
+
invoke("app_kit_menu_state", { states, textFocus }).catch(() => {})
|
|
39
|
+
// `key` stands for the commands' menu-relevant state; `latest` holds the commands.
|
|
40
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
41
|
+
}, [key])
|
|
42
|
+
}
|