@liberation-data/desk 0.1.0
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 +201 -0
- package/NOTICE +4 -0
- package/README.md +436 -0
- package/dist/core/commands.d.ts +40 -0
- package/dist/core/commands.d.ts.map +1 -0
- package/dist/core/commands.js +92 -0
- package/dist/core/commands.js.map +1 -0
- package/dist/core/desk.d.ts +46 -0
- package/dist/core/desk.d.ts.map +1 -0
- package/dist/core/desk.js +178 -0
- package/dist/core/desk.js.map +1 -0
- package/dist/core/events.d.ts +42 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +54 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +7 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/location.d.ts +30 -0
- package/dist/core/location.d.ts.map +1 -0
- package/dist/core/location.js +99 -0
- package/dist/core/location.js.map +1 -0
- package/dist/core/shortcuts.d.ts +36 -0
- package/dist/core/shortcuts.d.ts.map +1 -0
- package/dist/core/shortcuts.js +73 -0
- package/dist/core/shortcuts.js.map +1 -0
- package/dist/core/titles.d.ts +3 -0
- package/dist/core/titles.d.ts.map +1 -0
- package/dist/core/titles.js +26 -0
- package/dist/core/titles.js.map +1 -0
- package/dist/core/types.d.ts +51 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/desk.css +2550 -0
- package/dist/react/Desktop.d.ts +24 -0
- package/dist/react/Desktop.d.ts.map +1 -0
- package/dist/react/Desktop.js +212 -0
- package/dist/react/Desktop.js.map +1 -0
- package/dist/react/Dock.d.ts +42 -0
- package/dist/react/Dock.d.ts.map +1 -0
- package/dist/react/Dock.js +103 -0
- package/dist/react/Dock.js.map +1 -0
- package/dist/react/MenuBar.d.ts +64 -0
- package/dist/react/MenuBar.d.ts.map +1 -0
- package/dist/react/MenuBar.js +245 -0
- package/dist/react/MenuBar.js.map +1 -0
- package/dist/react/appFrame.d.ts +37 -0
- package/dist/react/appFrame.d.ts.map +1 -0
- package/dist/react/appFrame.js +119 -0
- package/dist/react/appFrame.js.map +1 -0
- package/dist/react/arrange.d.ts +24 -0
- package/dist/react/arrange.d.ts.map +1 -0
- package/dist/react/arrange.js +61 -0
- package/dist/react/arrange.js.map +1 -0
- package/dist/react/commands.d.ts +23 -0
- package/dist/react/commands.d.ts.map +1 -0
- package/dist/react/commands.js +41 -0
- package/dist/react/commands.js.map +1 -0
- package/dist/react/context.d.ts +25 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/context.js +28 -0
- package/dist/react/context.js.map +1 -0
- package/dist/react/controls.d.ts +111 -0
- package/dist/react/controls.d.ts.map +1 -0
- package/dist/react/controls.js +83 -0
- package/dist/react/controls.js.map +1 -0
- package/dist/react/conversation.d.ts +41 -0
- package/dist/react/conversation.d.ts.map +1 -0
- package/dist/react/conversation.js +73 -0
- package/dist/react/conversation.js.map +1 -0
- package/dist/react/dnd.d.ts +51 -0
- package/dist/react/dnd.d.ts.map +1 -0
- package/dist/react/dnd.js +138 -0
- package/dist/react/dnd.js.map +1 -0
- package/dist/react/dragContext.d.ts +32 -0
- package/dist/react/dragContext.d.ts.map +1 -0
- package/dist/react/dragContext.js +14 -0
- package/dist/react/dragContext.js.map +1 -0
- package/dist/react/events.d.ts +41 -0
- package/dist/react/events.d.ts.map +1 -0
- package/dist/react/events.js +91 -0
- package/dist/react/events.js.map +1 -0
- package/dist/react/index.d.ts +43 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +22 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/layers.d.ts +5 -0
- package/dist/react/layers.d.ts.map +1 -0
- package/dist/react/layers.js +65 -0
- package/dist/react/layers.js.map +1 -0
- package/dist/react/overlays.d.ts +67 -0
- package/dist/react/overlays.d.ts.map +1 -0
- package/dist/react/overlays.js +98 -0
- package/dist/react/overlays.js.map +1 -0
- package/dist/react/popup.d.ts +21 -0
- package/dist/react/popup.d.ts.map +1 -0
- package/dist/react/popup.js +94 -0
- package/dist/react/popup.js.map +1 -0
- package/dist/react/search.d.ts +33 -0
- package/dist/react/search.d.ts.map +1 -0
- package/dist/react/search.js +82 -0
- package/dist/react/search.js.map +1 -0
- package/dist/react/setupProgress.d.ts +35 -0
- package/dist/react/setupProgress.d.ts.map +1 -0
- package/dist/react/setupProgress.js +68 -0
- package/dist/react/setupProgress.js.map +1 -0
- package/dist/react/shell.d.ts +17 -0
- package/dist/react/shell.d.ts.map +1 -0
- package/dist/react/shell.js +13 -0
- package/dist/react/shell.js.map +1 -0
- package/dist/react/sidebar.d.ts +23 -0
- package/dist/react/sidebar.d.ts.map +1 -0
- package/dist/react/sidebar.js +26 -0
- package/dist/react/sidebar.js.map +1 -0
- package/dist/react/table.d.ts +39 -0
- package/dist/react/table.d.ts.map +1 -0
- package/dist/react/table.js +86 -0
- package/dist/react/table.js.map +1 -0
- package/dist/react/tasks.d.ts +41 -0
- package/dist/react/tasks.d.ts.map +1 -0
- package/dist/react/tasks.js +79 -0
- package/dist/react/tasks.js.map +1 -0
- package/dist/react/tour.d.ts +42 -0
- package/dist/react/tour.d.ts.map +1 -0
- package/dist/react/tour.js +68 -0
- package/dist/react/tour.js.map +1 -0
- package/dist/react/windowBoundary.d.ts +22 -0
- package/dist/react/windowBoundary.d.ts.map +1 -0
- package/dist/react/windowBoundary.js +50 -0
- package/dist/react/windowBoundary.js.map +1 -0
- package/dist/react/wizard.d.ts +41 -0
- package/dist/react/wizard.d.ts.map +1 -0
- package/dist/react/wizard.js +60 -0
- package/dist/react/wizard.js.map +1 -0
- package/llms.txt +410 -0
- package/package.json +64 -0
- package/src/core/commands.ts +142 -0
- package/src/core/desk.ts +229 -0
- package/src/core/events.ts +99 -0
- package/src/core/index.ts +43 -0
- package/src/core/location.ts +127 -0
- package/src/core/shortcuts.ts +119 -0
- package/src/core/titles.ts +28 -0
- package/src/core/types.ts +49 -0
- package/src/desk.css +2550 -0
- package/src/react/Desktop.tsx +349 -0
- package/src/react/Dock.tsx +258 -0
- package/src/react/MenuBar.tsx +350 -0
- package/src/react/appFrame.tsx +183 -0
- package/src/react/arrange.ts +82 -0
- package/src/react/commands.ts +60 -0
- package/src/react/context.tsx +55 -0
- package/src/react/controls.tsx +364 -0
- package/src/react/conversation.tsx +208 -0
- package/src/react/dnd.tsx +189 -0
- package/src/react/dragContext.tsx +60 -0
- package/src/react/events.tsx +149 -0
- package/src/react/index.ts +64 -0
- package/src/react/layers.ts +66 -0
- package/src/react/overlays.tsx +254 -0
- package/src/react/popup.tsx +176 -0
- package/src/react/search.tsx +177 -0
- package/src/react/setupProgress.ts +113 -0
- package/src/react/shell.tsx +27 -0
- package/src/react/sidebar.tsx +84 -0
- package/src/react/table.tsx +176 -0
- package/src/react/tasks.tsx +176 -0
- package/src/react/tour.tsx +183 -0
- package/src/react/windowBoundary.tsx +110 -0
- package/src/react/wizard.tsx +150 -0
package/src/core/desk.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CascadeOptions,
|
|
3
|
+
DeskOptions,
|
|
4
|
+
DeskState,
|
|
5
|
+
DeskWindow,
|
|
6
|
+
Frame,
|
|
7
|
+
OpenOptions,
|
|
8
|
+
Size,
|
|
9
|
+
WindowId,
|
|
10
|
+
} from './types.js'
|
|
11
|
+
|
|
12
|
+
export interface Desk {
|
|
13
|
+
getState(): DeskState
|
|
14
|
+
subscribe(listener: (state: DeskState) => void): () => void
|
|
15
|
+
/** Opens a window filling the desk, over the ones already open, or brings it to the front if it is open. */
|
|
16
|
+
open(id: WindowId, options?: OpenOptions): void
|
|
17
|
+
/** Opens another window of the same kind — `query#2` beside `query` — and returns its id. */
|
|
18
|
+
openInstance(type: string, options?: OpenOptions): WindowId
|
|
19
|
+
close(id: WindowId): void
|
|
20
|
+
closeAll(): void
|
|
21
|
+
focus(id: WindowId): void
|
|
22
|
+
/** Frees a window: where it last was, or at the next step of the cascade. Given a frame, puts it there. */
|
|
23
|
+
float(id: WindowId, frame?: Frame): void
|
|
24
|
+
/** Makes a window fill the desk, remembering where it was so freeing it again puts it back. */
|
|
25
|
+
fill(id: WindowId): void
|
|
26
|
+
/** Zooms: fills a free window, or frees a filled one. */
|
|
27
|
+
toggleMode(id: WindowId): void
|
|
28
|
+
/** Places windows exactly where they are given, as free windows. Arrange uses it. */
|
|
29
|
+
placeAll(frames: Readonly<Record<WindowId, Frame>>): void
|
|
30
|
+
/** Replaces the whole state, e.g. from a URL. Unknown shapes are normalised, not trusted. */
|
|
31
|
+
restore(state: DeskState): void
|
|
32
|
+
setStage(stage: () => Size): void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const EMPTY: DeskState = { windows: [], stack: [] }
|
|
36
|
+
|
|
37
|
+
const DEFAULT_CASCADE: CascadeOptions = {
|
|
38
|
+
maxWidth: 720,
|
|
39
|
+
maxHeight: 520,
|
|
40
|
+
margin: 16,
|
|
41
|
+
step: { x: 32, y: 28 },
|
|
42
|
+
wrap: 6,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_STAGE: Size = { width: 1024, height: 768 }
|
|
46
|
+
|
|
47
|
+
export const focusedId = (state: DeskState): WindowId | null => state.stack.at(-1) ?? null
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Two windows onto the same thing — a second query beside the first — are the
|
|
51
|
+
* same kind with different ids: `query`, then `query#2`. The part before the
|
|
52
|
+
* `#` says what to render; the whole id says which one this is.
|
|
53
|
+
*/
|
|
54
|
+
export const windowType = (id: WindowId): string => id.split('#')[0] ?? id
|
|
55
|
+
|
|
56
|
+
export const instancesOf = (state: DeskState, type: string): readonly DeskWindow[] =>
|
|
57
|
+
state.windows.filter(w => windowType(w.id) === type)
|
|
58
|
+
|
|
59
|
+
export const isOpen = (state: DeskState, id: WindowId): boolean => state.windows.some(w => w.id === id)
|
|
60
|
+
|
|
61
|
+
export function cascadeFrame(floatingCount: number, stage: Size, options: CascadeOptions = DEFAULT_CASCADE): Frame {
|
|
62
|
+
const width = Math.max(0, Math.min(options.maxWidth, stage.width - options.margin * 2))
|
|
63
|
+
const height = Math.max(0, Math.min(options.maxHeight, stage.height - options.margin * 2))
|
|
64
|
+
const n = floatingCount % options.wrap
|
|
65
|
+
return {
|
|
66
|
+
x: Math.min(options.margin + n * options.step.x, Math.max(0, stage.width - width - options.margin)),
|
|
67
|
+
y: Math.min(options.margin + n * options.step.y, Math.max(0, stage.height - height - options.margin)),
|
|
68
|
+
width,
|
|
69
|
+
height,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Which cascade step a frame sits on, or -1 once it has been dragged off one. */
|
|
74
|
+
export function cascadeSlot(frame: Frame, stage: Size, options: CascadeOptions = DEFAULT_CASCADE): number {
|
|
75
|
+
for (let slot = 0; slot < options.wrap; slot++) {
|
|
76
|
+
const candidate = cascadeFrame(slot, stage, options)
|
|
77
|
+
if (candidate.x === frame.x && candidate.y === frame.y) return slot
|
|
78
|
+
}
|
|
79
|
+
return -1
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The next free step of the cascade. Counting floating windows is not enough: a
|
|
84
|
+
* window closed from the middle frees its step, and the count wraps — either way
|
|
85
|
+
* a new window would open exactly on top of one already there, hiding it.
|
|
86
|
+
*/
|
|
87
|
+
export function nextCascadeFrame(windows: readonly DeskWindow[], stage: Size, options: CascadeOptions = DEFAULT_CASCADE): Frame {
|
|
88
|
+
const taken = new Set(
|
|
89
|
+
windows.flatMap(w => (w.mode === 'floating' ? [cascadeSlot(w.frame, stage, options)] : [])),
|
|
90
|
+
)
|
|
91
|
+
for (let slot = 0; slot < options.wrap; slot++) if (!taken.has(slot)) return cascadeFrame(slot, stage, options)
|
|
92
|
+
return cascadeFrame(taken.size % options.wrap, stage, options)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Drops duplicate ids and makes the stack agree with the windows. */
|
|
96
|
+
export function normalise(state: DeskState): DeskState {
|
|
97
|
+
const windows = state.windows.filter((w, i, all) => all.findIndex(o => o.id === w.id) === i)
|
|
98
|
+
const ids = new Set(windows.map(w => w.id))
|
|
99
|
+
const stacked = state.stack.filter((id, i, all) => ids.has(id) && all.indexOf(id) === i)
|
|
100
|
+
const unstacked = windows.map(w => w.id).filter(id => !stacked.includes(id))
|
|
101
|
+
return { windows, stack: [...unstacked, ...stacked] }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createDesk(options: DeskOptions = {}): Desk {
|
|
105
|
+
const cascade: CascadeOptions = { ...DEFAULT_CASCADE, ...options.cascade }
|
|
106
|
+
let stage = options.stage ?? (() => DEFAULT_STAGE)
|
|
107
|
+
let state = normalise(options.initial ?? EMPTY)
|
|
108
|
+
const listeners = new Set<(state: DeskState) => void>()
|
|
109
|
+
|
|
110
|
+
const commit = (next: DeskState) => {
|
|
111
|
+
if (next === state) return
|
|
112
|
+
state = next
|
|
113
|
+
listeners.forEach(listener => listener(state))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const toFront = (s: DeskState, id: WindowId): DeskState =>
|
|
117
|
+
s.stack.at(-1) === id ? s : { ...s, stack: [...s.stack.filter(x => x !== id), id] }
|
|
118
|
+
|
|
119
|
+
const nextFrame = (s: DeskState) => nextCascadeFrame(s.windows, stage(), cascade)
|
|
120
|
+
|
|
121
|
+
const replace = (s: DeskState, window: DeskWindow): DeskState => ({
|
|
122
|
+
...s,
|
|
123
|
+
windows: s.windows.map(w => (w.id === window.id ? window : w)),
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const find = (id: WindowId) => state.windows.find(w => w.id === id)
|
|
127
|
+
|
|
128
|
+
/** Where each window was last free, so filling it and freeing it again is not a surprise. */
|
|
129
|
+
const remembered = new Map<WindowId, Frame>()
|
|
130
|
+
|
|
131
|
+
/** A remembered frame is only worth restoring while it still lands on this screen. */
|
|
132
|
+
const fits = (frame: Frame | undefined) => {
|
|
133
|
+
if (!frame) return undefined
|
|
134
|
+
const { width, height } = stage()
|
|
135
|
+
return frame.x + 40 <= width && frame.y + 20 <= height && frame.x >= 0 && frame.y >= 0 ? frame : undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Plain functions rather than methods, so `const { open } = desk` works.
|
|
139
|
+
const float = (id: WindowId, frame?: Frame) => {
|
|
140
|
+
const window = find(id)
|
|
141
|
+
if (!window) return
|
|
142
|
+
if (window.mode === 'floating' && !frame) return
|
|
143
|
+
const next = frame ?? fits(remembered.get(id)) ?? nextFrame(state)
|
|
144
|
+
if (frame) remembered.set(id, frame)
|
|
145
|
+
commit(toFront(replace(state, { id, mode: 'floating', frame: next }), id))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const fill = (id: WindowId) => {
|
|
149
|
+
const window = find(id)
|
|
150
|
+
if (!window || window.mode === 'filled') return
|
|
151
|
+
remembered.set(id, window.frame)
|
|
152
|
+
commit(toFront(replace(state, { id, mode: 'filled' }), id))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
getState: () => state,
|
|
157
|
+
|
|
158
|
+
subscribe(listener) {
|
|
159
|
+
listeners.add(listener)
|
|
160
|
+
return () => {
|
|
161
|
+
listeners.delete(listener)
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
open(id, opts = {}) {
|
|
166
|
+
if (find(id)) {
|
|
167
|
+
commit(toFront(state, id))
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
const mode = opts.mode ?? (opts.frame ? 'floating' : 'filled')
|
|
171
|
+
const window: DeskWindow = mode === 'filled' ? { id, mode } : { id, mode, frame: opts.frame ?? nextFrame(state) }
|
|
172
|
+
commit({ windows: [...state.windows, window], stack: [...state.stack, id] })
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
openInstance(type, opts = {}) {
|
|
176
|
+
const taken = new Set(state.windows.map(w => w.id))
|
|
177
|
+
let id = type
|
|
178
|
+
for (let n = 2; taken.has(id); n++) id = `${type}#${n}`
|
|
179
|
+
this.open(id, opts)
|
|
180
|
+
return id
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
close(id) {
|
|
184
|
+
if (find(id)) commit({ windows: state.windows.filter(w => w.id !== id), stack: state.stack.filter(x => x !== id) })
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
closeAll() {
|
|
188
|
+
if (state.windows.length) commit(EMPTY)
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
focus(id) {
|
|
192
|
+
if (find(id)) commit(toFront(state, id))
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
float,
|
|
196
|
+
fill,
|
|
197
|
+
|
|
198
|
+
toggleMode(id) {
|
|
199
|
+
const window = find(id)
|
|
200
|
+
if (!window) return
|
|
201
|
+
if (window.mode === 'filled') float(id)
|
|
202
|
+
else fill(id)
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
placeAll(frames) {
|
|
206
|
+
const ids = Object.keys(frames)
|
|
207
|
+
if (!ids.length) return
|
|
208
|
+
ids.forEach(id => {
|
|
209
|
+
const frame = frames[id]
|
|
210
|
+
if (frame) remembered.set(id, frame)
|
|
211
|
+
})
|
|
212
|
+
commit({
|
|
213
|
+
...state,
|
|
214
|
+
windows: state.windows.map(w => {
|
|
215
|
+
const frame = frames[w.id]
|
|
216
|
+
return frame ? { id: w.id, mode: 'floating' as const, frame } : w
|
|
217
|
+
}),
|
|
218
|
+
})
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
restore(next) {
|
|
222
|
+
commit(normalise(next))
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
setStage(next) {
|
|
226
|
+
stage = next
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { WindowId } from './types.js'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Windows talk to each other by publishing what happened, not by calling each
|
|
5
|
+
* other: Rides says a ride was chosen, and whoever cares — the map, a chat
|
|
6
|
+
* window — answers. Nobody holds a reference to anybody.
|
|
7
|
+
*
|
|
8
|
+
* Topics are dotted names. A subscriber can take a whole branch with `ride.*`,
|
|
9
|
+
* or everything with `*`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface DeskEvent<T = unknown> {
|
|
13
|
+
readonly topic: string
|
|
14
|
+
readonly payload: T
|
|
15
|
+
/** The window it came from, when it came from one. */
|
|
16
|
+
readonly from: WindowId | null
|
|
17
|
+
readonly at: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type EventHandler<T = unknown> = (event: DeskEvent<T>) => void
|
|
21
|
+
|
|
22
|
+
export interface SubscribeOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Deliver the last event on this topic straight away, if there was one. A window
|
|
25
|
+
* opened by the event that concerns it would otherwise miss it by a frame.
|
|
26
|
+
*/
|
|
27
|
+
readonly replay?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Bus {
|
|
31
|
+
publish<T>(topic: string, payload: T, options?: { readonly from?: WindowId | null }): DeskEvent<T>
|
|
32
|
+
subscribe<T>(topic: string, handler: EventHandler<T>, options?: SubscribeOptions): () => void
|
|
33
|
+
/** The last event on a topic, if one has been published. */
|
|
34
|
+
last<T>(topic: string): DeskEvent<T> | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function topicMatches(pattern: string, topic: string): boolean {
|
|
38
|
+
if (pattern === '*' || pattern === topic) return true
|
|
39
|
+
if (!pattern.endsWith('.*')) return false
|
|
40
|
+
const branch = pattern.slice(0, -2)
|
|
41
|
+
return topic === branch || topic.startsWith(`${branch}.`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createBus(): Bus {
|
|
45
|
+
const subscribers = new Set<{ pattern: string; handler: EventHandler<never> }>()
|
|
46
|
+
const latest = new Map<string, DeskEvent<unknown>>()
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
publish(topic, payload, options = {}) {
|
|
50
|
+
const event: DeskEvent<typeof payload> = { topic, payload, from: options.from ?? null, at: Date.now() }
|
|
51
|
+
latest.set(topic, event as DeskEvent<unknown>)
|
|
52
|
+
// Copy first: a handler may subscribe or unsubscribe while this one is being delivered.
|
|
53
|
+
for (const subscriber of [...subscribers]) {
|
|
54
|
+
if (subscribers.has(subscriber) && topicMatches(subscriber.pattern, topic)) {
|
|
55
|
+
;(subscriber.handler as EventHandler<typeof payload>)(event)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return event
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
subscribe(topic, handler, options = {}) {
|
|
62
|
+
const subscriber = { pattern: topic, handler: handler as EventHandler<never> }
|
|
63
|
+
subscribers.add(subscriber)
|
|
64
|
+
if (options.replay) {
|
|
65
|
+
const previous = [...latest.values()]
|
|
66
|
+
.filter(event => topicMatches(topic, event.topic))
|
|
67
|
+
.sort((a, b) => a.at - b.at)
|
|
68
|
+
.at(-1)
|
|
69
|
+
if (previous) (handler as EventHandler<unknown>)(previous)
|
|
70
|
+
}
|
|
71
|
+
return () => {
|
|
72
|
+
subscribers.delete(subscriber)
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
last(topic) {
|
|
77
|
+
return latest.get(topic) as DeskEvent<never> | undefined
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ── Typed input: text with no target, which goes to the key window ── */
|
|
83
|
+
|
|
84
|
+
export const InputCommands = {
|
|
85
|
+
/** Brings up the bar. A menu item can perform this, as a shortcut does. */
|
|
86
|
+
open: 'desk.input.open',
|
|
87
|
+
/** Sent with the text. A window that takes typed input answers this. */
|
|
88
|
+
submit: 'desk.input.submit',
|
|
89
|
+
/** Asks the chain how the bar should present itself for the key window. */
|
|
90
|
+
describe: 'desk.input.describe',
|
|
91
|
+
} as const
|
|
92
|
+
|
|
93
|
+
/** Filled in by whichever responder answers `desk.input.describe`. */
|
|
94
|
+
export interface InputDescription {
|
|
95
|
+
placeholder?: string
|
|
96
|
+
/** What the text will reach, for the bar to show: "Rides", "Chat". */
|
|
97
|
+
target?: string
|
|
98
|
+
disabled?: boolean
|
|
99
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createDesk,
|
|
3
|
+
cascadeFrame,
|
|
4
|
+
cascadeSlot,
|
|
5
|
+
focusedId,
|
|
6
|
+
instancesOf,
|
|
7
|
+
isOpen,
|
|
8
|
+
nextCascadeFrame,
|
|
9
|
+
normalise,
|
|
10
|
+
windowType,
|
|
11
|
+
EMPTY,
|
|
12
|
+
} from './desk.js'
|
|
13
|
+
export type { Desk } from './desk.js'
|
|
14
|
+
export {
|
|
15
|
+
addCommandHandler,
|
|
16
|
+
addDeskCommands,
|
|
17
|
+
canPerform,
|
|
18
|
+
chainStart,
|
|
19
|
+
DeskCommands,
|
|
20
|
+
perform,
|
|
21
|
+
STAGE_ATTRIBUTE,
|
|
22
|
+
WINDOW_ATTRIBUTE,
|
|
23
|
+
windowElement,
|
|
24
|
+
} from './commands.js'
|
|
25
|
+
export type { ChainOptions, CommandHandler, CommandId, CommandOptions } from './commands.js'
|
|
26
|
+
export { createBus, InputCommands, topicMatches } from './events.js'
|
|
27
|
+
export type { Bus, DeskEvent, EventHandler, InputDescription, SubscribeOptions } from './events.js'
|
|
28
|
+
export { bindShortcuts, formatShortcut, isApplePlatform, isInstalledApp, matchesShortcut, parseShortcut } from './shortcuts.js'
|
|
29
|
+
export type { Keymap, Shortcut, ShortcutOptions } from './shortcuts.js'
|
|
30
|
+
export { parse, serialize, syncWithLocation } from './location.js'
|
|
31
|
+
export { isTitleCase, titleCase } from './titles.js'
|
|
32
|
+
export type { LocationEnv, LocationOptions } from './location.js'
|
|
33
|
+
export type {
|
|
34
|
+
CascadeOptions,
|
|
35
|
+
DeskOptions,
|
|
36
|
+
DeskState,
|
|
37
|
+
DeskWindow,
|
|
38
|
+
Frame,
|
|
39
|
+
OpenOptions,
|
|
40
|
+
Size,
|
|
41
|
+
WindowId,
|
|
42
|
+
WindowMode,
|
|
43
|
+
} from './types.js'
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { cascadeFrame, focusedId, normalise } from './desk.js'
|
|
2
|
+
import type { Desk } from './desk.js'
|
|
3
|
+
import type { DeskState, DeskWindow, Size, WindowId } from './types.js'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* The URL carries which windows are open, which are free, and which has focus —
|
|
7
|
+
* never frames. A shared link should open the same things, not reproduce
|
|
8
|
+
* someone else's window positions on a different screen.
|
|
9
|
+
*
|
|
10
|
+
* #w=notes,clock~,inspector&f=clock
|
|
11
|
+
*
|
|
12
|
+
* `~` marks a free window; the others fill the desk. `f` names the focused window.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const FLOAT = '~'
|
|
16
|
+
|
|
17
|
+
const warned = new Set<string>()
|
|
18
|
+
/** Said once per message: a warning on every parse would be noise, not help. */
|
|
19
|
+
const warnOnce = (message: string) => {
|
|
20
|
+
if (warned.has(message)) return
|
|
21
|
+
warned.add(message)
|
|
22
|
+
console.warn(message)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LocationOptions {
|
|
26
|
+
/** Hash parameter that holds the windows. Default `w`. */
|
|
27
|
+
readonly key?: string
|
|
28
|
+
/** Hash parameter that holds the focused window. Default `f`. */
|
|
29
|
+
readonly focusKey?: string
|
|
30
|
+
/** Ids the app can render. Anything else in a URL is dropped. */
|
|
31
|
+
readonly isKnown?: (id: WindowId) => boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function serialize(state: DeskState, options: LocationOptions = {}): URLSearchParams {
|
|
35
|
+
const params = new URLSearchParams()
|
|
36
|
+
if (!state.windows.length) return params
|
|
37
|
+
params.set(
|
|
38
|
+
options.key ?? 'w',
|
|
39
|
+
state.windows.map(w => `${encodeURIComponent(w.id)}${w.mode === 'floating' ? FLOAT : ''}`).join(','),
|
|
40
|
+
)
|
|
41
|
+
const focused = focusedId(state)
|
|
42
|
+
if (focused) params.set(options.focusKey ?? 'f', focused)
|
|
43
|
+
return params
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parse(params: URLSearchParams, stage: Size, options: LocationOptions = {}): DeskState {
|
|
47
|
+
const known =
|
|
48
|
+
options.isKnown ??
|
|
49
|
+
(id => {
|
|
50
|
+
// Without `isKnown` a stale or hand-edited link opens ids the app cannot render.
|
|
51
|
+
warnOnce(`desk: no isKnown given, so "${id}" from a URL is taken on trust. Pass isKnown to drop unknown windows.`)
|
|
52
|
+
return true
|
|
53
|
+
})
|
|
54
|
+
const raw = params.get(options.key ?? 'w')
|
|
55
|
+
if (!raw) return { windows: [], stack: [] }
|
|
56
|
+
|
|
57
|
+
const entries = raw
|
|
58
|
+
.split(',')
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.map(token => {
|
|
61
|
+
const floating = token.endsWith(FLOAT)
|
|
62
|
+
return { id: decodeURIComponent(floating ? token.slice(0, -1) : token), floating }
|
|
63
|
+
})
|
|
64
|
+
.filter(e => e.id && known(e.id))
|
|
65
|
+
|
|
66
|
+
const windows = entries.map(
|
|
67
|
+
(e, i): DeskWindow =>
|
|
68
|
+
e.floating
|
|
69
|
+
? { id: e.id, mode: 'floating', frame: cascadeFrame(entries.slice(0, i).filter(x => x.floating).length, stage) }
|
|
70
|
+
: { id: e.id, mode: 'filled' },
|
|
71
|
+
)
|
|
72
|
+
const focus = params.get(options.focusKey ?? 'f')
|
|
73
|
+
const ids = windows.map(w => w.id)
|
|
74
|
+
const stack = focus && ids.includes(focus) ? [...ids.filter(id => id !== focus), focus] : ids
|
|
75
|
+
return normalise({ windows, stack })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface LocationEnv {
|
|
79
|
+
readonly location: { hash: string }
|
|
80
|
+
readonly history: { pushState(data: unknown, unused: string, url: string): void; replaceState(data: unknown, unused: string, url: string): void }
|
|
81
|
+
addEventListener(type: 'popstate', listener: () => void): void
|
|
82
|
+
removeEventListener(type: 'popstate', listener: () => void): void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const openSet = (state: DeskState) => state.windows.map(w => w.id).join('|')
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Keeps the desk and the URL hash in step. Opening or closing a window pushes a
|
|
89
|
+
* history entry, so Back closes it; focus and mode changes replace the entry.
|
|
90
|
+
* Other hash parameters are left alone, so the app can keep using the hash too.
|
|
91
|
+
*/
|
|
92
|
+
export function syncWithLocation(desk: Desk, stage: () => Size, options: LocationOptions = {}, env?: LocationEnv): () => void {
|
|
93
|
+
const target = env ?? (globalThis as unknown as LocationEnv)
|
|
94
|
+
const keys = [options.key ?? 'w', options.focusKey ?? 'f']
|
|
95
|
+
const read = () => new URLSearchParams(target.location.hash.replace(/^#/, ''))
|
|
96
|
+
|
|
97
|
+
const fromUrl = () => desk.restore(parse(read(), stage(), options))
|
|
98
|
+
|
|
99
|
+
const toUrl = (state: DeskState, previous: DeskState) => {
|
|
100
|
+
const params = read()
|
|
101
|
+
keys.forEach(k => params.delete(k))
|
|
102
|
+
serialize(state, options).forEach((value, key) => params.set(key, value))
|
|
103
|
+
const url = `#${params.toString().replace(/%2C/g, ',').replace(/%7E/g, FLOAT)}`
|
|
104
|
+
if (url === target.location.hash || (url === '#' && !target.location.hash)) return
|
|
105
|
+
if (openSet(state) !== openSet(previous)) target.history.pushState(null, '', url)
|
|
106
|
+
else target.history.replaceState(null, '', url)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A URL that names windows wins. One that names none adopts whatever the app opened.
|
|
110
|
+
if (read().has(keys[0] as string)) fromUrl()
|
|
111
|
+
else if (desk.getState().windows.length) toUrl(desk.getState(), desk.getState())
|
|
112
|
+
let previous = desk.getState()
|
|
113
|
+
const unsubscribe = desk.subscribe(state => {
|
|
114
|
+
toUrl(state, previous)
|
|
115
|
+
previous = state
|
|
116
|
+
})
|
|
117
|
+
const onPop = () => {
|
|
118
|
+
fromUrl()
|
|
119
|
+
previous = desk.getState()
|
|
120
|
+
}
|
|
121
|
+
target.addEventListener('popstate', onPop)
|
|
122
|
+
|
|
123
|
+
return () => {
|
|
124
|
+
unsubscribe()
|
|
125
|
+
target.removeEventListener('popstate', onPop)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { perform } from './commands.js'
|
|
2
|
+
import type { CommandId } from './commands.js'
|
|
3
|
+
import type { Desk } from './desk.js'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Shortcuts are written once, platform-neutrally — `mod+shift+z` — and mean ⌘ on
|
|
7
|
+
* Apple devices and Ctrl elsewhere. A shortcut performs a command through the
|
|
8
|
+
* responder chain; if nothing handles it, the browser's default is left alone,
|
|
9
|
+
* so ⌘C still copies when no window has claimed Copy.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type Keymap = Readonly<Record<string, CommandId>>
|
|
13
|
+
|
|
14
|
+
export interface Shortcut {
|
|
15
|
+
readonly key: string
|
|
16
|
+
readonly mod: boolean
|
|
17
|
+
readonly shift: boolean
|
|
18
|
+
readonly alt: boolean
|
|
19
|
+
readonly ctrl: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const NAMES: Readonly<Record<string, string>> = { esc: 'escape', return: 'enter', comma: ',', space: ' ', backquote: '`' }
|
|
23
|
+
|
|
24
|
+
export function parseShortcut(text: string): Shortcut {
|
|
25
|
+
const parts = text.toLowerCase().split('+')
|
|
26
|
+
const key = parts.pop() ?? ''
|
|
27
|
+
return {
|
|
28
|
+
key: NAMES[key] ?? key,
|
|
29
|
+
mod: parts.includes('mod'),
|
|
30
|
+
shift: parts.includes('shift'),
|
|
31
|
+
alt: parts.includes('alt') || parts.includes('option'),
|
|
32
|
+
ctrl: parts.includes('ctrl'),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether the app is running installed, rather than in a browser tab. Only then
|
|
38
|
+
* may it take shortcuts the browser normally owns, such as ⌘W (HIG.md §5).
|
|
39
|
+
*/
|
|
40
|
+
export const isInstalledApp = (): boolean =>
|
|
41
|
+
typeof matchMedia === 'function' && (matchMedia('(display-mode: standalone)').matches || matchMedia('(display-mode: window-controls-overlay)').matches)
|
|
42
|
+
|
|
43
|
+
export const isApplePlatform = (): boolean =>
|
|
44
|
+
typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent)
|
|
45
|
+
|
|
46
|
+
interface KeyLike {
|
|
47
|
+
readonly key: string
|
|
48
|
+
readonly code?: string
|
|
49
|
+
readonly metaKey: boolean
|
|
50
|
+
readonly ctrlKey: boolean
|
|
51
|
+
readonly shiftKey: boolean
|
|
52
|
+
readonly altKey: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const CODES: Readonly<Record<string, string>> = {
|
|
56
|
+
BracketLeft: '[', BracketRight: ']', Backquote: '`', Comma: ',', Period: '.', Slash: '/', Semicolon: ';', Quote: "'", Minus: '-', Equal: '=', Backslash: '\\', Space: ' ',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The unmodified key a physical key stands for: ⌥ on a Mac turns `]` into `‘`, but the code is still BracketRight. */
|
|
60
|
+
const keyFromCode = (code: string | undefined) =>
|
|
61
|
+
!code ? undefined
|
|
62
|
+
: code.startsWith('Key') ? code.slice(3).toLowerCase()
|
|
63
|
+
: code.startsWith('Digit') ? code.slice(5)
|
|
64
|
+
: CODES[code]
|
|
65
|
+
|
|
66
|
+
export function matchesShortcut(event: KeyLike, shortcut: Shortcut, apple = isApplePlatform()): boolean {
|
|
67
|
+
const wantMeta = apple && shortcut.mod
|
|
68
|
+
const wantCtrl = shortcut.ctrl || (!apple && shortcut.mod)
|
|
69
|
+
return (
|
|
70
|
+
(event.key.toLowerCase() === shortcut.key || keyFromCode(event.code) === shortcut.key) &&
|
|
71
|
+
event.metaKey === wantMeta &&
|
|
72
|
+
event.ctrlKey === wantCtrl &&
|
|
73
|
+
event.shiftKey === shortcut.shift &&
|
|
74
|
+
event.altKey === shortcut.alt
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const GLYPHS: Readonly<Record<string, string>> = {
|
|
79
|
+
escape: 'Esc', enter: '↩', ',': ',', ' ': 'Space', arrowup: '↑', arrowdown: '↓', arrowleft: '←', arrowright: '→', backspace: '⌫', '`': '`',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** How a menu shows a shortcut: `⇧⌘Z` on Apple devices, `Ctrl+Shift+Z` elsewhere. */
|
|
83
|
+
export function formatShortcut(text: string, apple = isApplePlatform()): string {
|
|
84
|
+
const s = parseShortcut(text)
|
|
85
|
+
const key = GLYPHS[s.key] ?? s.key.toUpperCase()
|
|
86
|
+
if (apple) return `${s.ctrl ? '⌃' : ''}${s.alt ? '⌥' : ''}${s.shift ? '⇧' : ''}${s.mod ? '⌘' : ''}${key}`
|
|
87
|
+
return [s.ctrl || s.mod ? 'Ctrl' : '', s.alt ? 'Alt' : '', s.shift ? 'Shift' : '', key].filter(Boolean).join('+')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const isEditable = (target: EventTarget | null) =>
|
|
91
|
+
target instanceof Element && (target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== null)
|
|
92
|
+
|
|
93
|
+
export interface ShortcutOptions {
|
|
94
|
+
readonly target?: EventTarget
|
|
95
|
+
readonly apple?: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Performs commands from keyboard shortcuts. Returns a function that stops listening. */
|
|
99
|
+
export function bindShortcuts(desk: Desk | null, keymap: Keymap | (() => Keymap), options: ShortcutOptions = {}): () => void {
|
|
100
|
+
const target = options.target ?? globalThis
|
|
101
|
+
const bind = (map: Keymap) => Object.entries(map).map(([text, command]) => ({ shortcut: parseShortcut(text), command }))
|
|
102
|
+
// A keymap given as a function is read at each key press, for menus whose items change.
|
|
103
|
+
const fixed = typeof keymap === 'function' ? null : bind(keymap)
|
|
104
|
+
|
|
105
|
+
const onKeyDown = (event: Event) => {
|
|
106
|
+
const key = event as KeyboardEvent
|
|
107
|
+
if (key.defaultPrevented || key.isComposing) return
|
|
108
|
+
const bindings = fixed ?? bind((keymap as () => Keymap)())
|
|
109
|
+
const binding = bindings.find(b => matchesShortcut(key, b.shortcut, options.apple))
|
|
110
|
+
if (!binding) return
|
|
111
|
+
// A bare key belongs to whatever is being typed into; a modified one can still be a command.
|
|
112
|
+
const plain = !binding.shortcut.mod && !binding.shortcut.ctrl && !binding.shortcut.alt
|
|
113
|
+
if (plain && isEditable(key.target)) return
|
|
114
|
+
if (perform(desk, binding.command)) key.preventDefault()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
target.addEventListener('keydown', onKeyDown)
|
|
118
|
+
return () => target.removeEventListener('keydown', onKeyDown)
|
|
119
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* App and window names are names, so they are written in title case: Ride Card,
|
|
3
|
+
* Parts & Wear, Service History. It is not applied for you, because a window showing one
|
|
4
|
+
* document takes that document's own name, as written. Use `titleCase` where a name
|
|
5
|
+
* is defined, and `isTitleCase` in a test over the names an app defines.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Short words that stay lower case inside a title: articles, conjunctions, short prepositions. */
|
|
9
|
+
const MINOR = new Set(['a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'in', 'nor', 'of', 'on', 'or', 'per', 'so', 'the', 'to', 'up', 'via', 'vs', 'yet'])
|
|
10
|
+
|
|
11
|
+
// A word someone has already cased on purpose — iPhone, API, macOS — is left alone.
|
|
12
|
+
const deliberate = (word: string) => /[A-Z]/.test(word.slice(1))
|
|
13
|
+
|
|
14
|
+
const capital = (word: string) => word.charAt(0).toUpperCase() + word.slice(1)
|
|
15
|
+
|
|
16
|
+
export function titleCase(text: string): string {
|
|
17
|
+
const words = text.trim().split(/\s+/)
|
|
18
|
+
return words
|
|
19
|
+
.map((word, i) => {
|
|
20
|
+
if (deliberate(word)) return word
|
|
21
|
+
const edge = i === 0 || i === words.length - 1
|
|
22
|
+
if (!edge && MINOR.has(word.toLowerCase())) return word.toLowerCase()
|
|
23
|
+
return word.split('-').map(capital).join('-')
|
|
24
|
+
})
|
|
25
|
+
.join(' ')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const isTitleCase = (text: string): boolean => titleCase(text) === text.trim()
|