@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.
Files changed (172) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +4 -0
  3. package/README.md +436 -0
  4. package/dist/core/commands.d.ts +40 -0
  5. package/dist/core/commands.d.ts.map +1 -0
  6. package/dist/core/commands.js +92 -0
  7. package/dist/core/commands.js.map +1 -0
  8. package/dist/core/desk.d.ts +46 -0
  9. package/dist/core/desk.d.ts.map +1 -0
  10. package/dist/core/desk.js +178 -0
  11. package/dist/core/desk.js.map +1 -0
  12. package/dist/core/events.d.ts +42 -0
  13. package/dist/core/events.d.ts.map +1 -0
  14. package/dist/core/events.js +54 -0
  15. package/dist/core/events.js.map +1 -0
  16. package/dist/core/index.d.ts +13 -0
  17. package/dist/core/index.d.ts.map +1 -0
  18. package/dist/core/index.js +7 -0
  19. package/dist/core/index.js.map +1 -0
  20. package/dist/core/location.d.ts +30 -0
  21. package/dist/core/location.d.ts.map +1 -0
  22. package/dist/core/location.js +99 -0
  23. package/dist/core/location.js.map +1 -0
  24. package/dist/core/shortcuts.d.ts +36 -0
  25. package/dist/core/shortcuts.d.ts.map +1 -0
  26. package/dist/core/shortcuts.js +73 -0
  27. package/dist/core/shortcuts.js.map +1 -0
  28. package/dist/core/titles.d.ts +3 -0
  29. package/dist/core/titles.d.ts.map +1 -0
  30. package/dist/core/titles.js +26 -0
  31. package/dist/core/titles.js.map +1 -0
  32. package/dist/core/types.d.ts +51 -0
  33. package/dist/core/types.d.ts.map +1 -0
  34. package/dist/core/types.js +2 -0
  35. package/dist/core/types.js.map +1 -0
  36. package/dist/desk.css +2550 -0
  37. package/dist/react/Desktop.d.ts +24 -0
  38. package/dist/react/Desktop.d.ts.map +1 -0
  39. package/dist/react/Desktop.js +212 -0
  40. package/dist/react/Desktop.js.map +1 -0
  41. package/dist/react/Dock.d.ts +42 -0
  42. package/dist/react/Dock.d.ts.map +1 -0
  43. package/dist/react/Dock.js +103 -0
  44. package/dist/react/Dock.js.map +1 -0
  45. package/dist/react/MenuBar.d.ts +64 -0
  46. package/dist/react/MenuBar.d.ts.map +1 -0
  47. package/dist/react/MenuBar.js +245 -0
  48. package/dist/react/MenuBar.js.map +1 -0
  49. package/dist/react/appFrame.d.ts +37 -0
  50. package/dist/react/appFrame.d.ts.map +1 -0
  51. package/dist/react/appFrame.js +119 -0
  52. package/dist/react/appFrame.js.map +1 -0
  53. package/dist/react/arrange.d.ts +24 -0
  54. package/dist/react/arrange.d.ts.map +1 -0
  55. package/dist/react/arrange.js +61 -0
  56. package/dist/react/arrange.js.map +1 -0
  57. package/dist/react/commands.d.ts +23 -0
  58. package/dist/react/commands.d.ts.map +1 -0
  59. package/dist/react/commands.js +41 -0
  60. package/dist/react/commands.js.map +1 -0
  61. package/dist/react/context.d.ts +25 -0
  62. package/dist/react/context.d.ts.map +1 -0
  63. package/dist/react/context.js +28 -0
  64. package/dist/react/context.js.map +1 -0
  65. package/dist/react/controls.d.ts +111 -0
  66. package/dist/react/controls.d.ts.map +1 -0
  67. package/dist/react/controls.js +83 -0
  68. package/dist/react/controls.js.map +1 -0
  69. package/dist/react/conversation.d.ts +41 -0
  70. package/dist/react/conversation.d.ts.map +1 -0
  71. package/dist/react/conversation.js +73 -0
  72. package/dist/react/conversation.js.map +1 -0
  73. package/dist/react/dnd.d.ts +51 -0
  74. package/dist/react/dnd.d.ts.map +1 -0
  75. package/dist/react/dnd.js +138 -0
  76. package/dist/react/dnd.js.map +1 -0
  77. package/dist/react/dragContext.d.ts +32 -0
  78. package/dist/react/dragContext.d.ts.map +1 -0
  79. package/dist/react/dragContext.js +14 -0
  80. package/dist/react/dragContext.js.map +1 -0
  81. package/dist/react/events.d.ts +41 -0
  82. package/dist/react/events.d.ts.map +1 -0
  83. package/dist/react/events.js +91 -0
  84. package/dist/react/events.js.map +1 -0
  85. package/dist/react/index.d.ts +43 -0
  86. package/dist/react/index.d.ts.map +1 -0
  87. package/dist/react/index.js +22 -0
  88. package/dist/react/index.js.map +1 -0
  89. package/dist/react/layers.d.ts +5 -0
  90. package/dist/react/layers.d.ts.map +1 -0
  91. package/dist/react/layers.js +65 -0
  92. package/dist/react/layers.js.map +1 -0
  93. package/dist/react/overlays.d.ts +67 -0
  94. package/dist/react/overlays.d.ts.map +1 -0
  95. package/dist/react/overlays.js +98 -0
  96. package/dist/react/overlays.js.map +1 -0
  97. package/dist/react/popup.d.ts +21 -0
  98. package/dist/react/popup.d.ts.map +1 -0
  99. package/dist/react/popup.js +94 -0
  100. package/dist/react/popup.js.map +1 -0
  101. package/dist/react/search.d.ts +33 -0
  102. package/dist/react/search.d.ts.map +1 -0
  103. package/dist/react/search.js +82 -0
  104. package/dist/react/search.js.map +1 -0
  105. package/dist/react/setupProgress.d.ts +35 -0
  106. package/dist/react/setupProgress.d.ts.map +1 -0
  107. package/dist/react/setupProgress.js +68 -0
  108. package/dist/react/setupProgress.js.map +1 -0
  109. package/dist/react/shell.d.ts +17 -0
  110. package/dist/react/shell.d.ts.map +1 -0
  111. package/dist/react/shell.js +13 -0
  112. package/dist/react/shell.js.map +1 -0
  113. package/dist/react/sidebar.d.ts +23 -0
  114. package/dist/react/sidebar.d.ts.map +1 -0
  115. package/dist/react/sidebar.js +26 -0
  116. package/dist/react/sidebar.js.map +1 -0
  117. package/dist/react/table.d.ts +39 -0
  118. package/dist/react/table.d.ts.map +1 -0
  119. package/dist/react/table.js +86 -0
  120. package/dist/react/table.js.map +1 -0
  121. package/dist/react/tasks.d.ts +41 -0
  122. package/dist/react/tasks.d.ts.map +1 -0
  123. package/dist/react/tasks.js +79 -0
  124. package/dist/react/tasks.js.map +1 -0
  125. package/dist/react/tour.d.ts +42 -0
  126. package/dist/react/tour.d.ts.map +1 -0
  127. package/dist/react/tour.js +68 -0
  128. package/dist/react/tour.js.map +1 -0
  129. package/dist/react/windowBoundary.d.ts +22 -0
  130. package/dist/react/windowBoundary.d.ts.map +1 -0
  131. package/dist/react/windowBoundary.js +50 -0
  132. package/dist/react/windowBoundary.js.map +1 -0
  133. package/dist/react/wizard.d.ts +41 -0
  134. package/dist/react/wizard.d.ts.map +1 -0
  135. package/dist/react/wizard.js +60 -0
  136. package/dist/react/wizard.js.map +1 -0
  137. package/llms.txt +410 -0
  138. package/package.json +64 -0
  139. package/src/core/commands.ts +142 -0
  140. package/src/core/desk.ts +229 -0
  141. package/src/core/events.ts +99 -0
  142. package/src/core/index.ts +43 -0
  143. package/src/core/location.ts +127 -0
  144. package/src/core/shortcuts.ts +119 -0
  145. package/src/core/titles.ts +28 -0
  146. package/src/core/types.ts +49 -0
  147. package/src/desk.css +2550 -0
  148. package/src/react/Desktop.tsx +349 -0
  149. package/src/react/Dock.tsx +258 -0
  150. package/src/react/MenuBar.tsx +350 -0
  151. package/src/react/appFrame.tsx +183 -0
  152. package/src/react/arrange.ts +82 -0
  153. package/src/react/commands.ts +60 -0
  154. package/src/react/context.tsx +55 -0
  155. package/src/react/controls.tsx +364 -0
  156. package/src/react/conversation.tsx +208 -0
  157. package/src/react/dnd.tsx +189 -0
  158. package/src/react/dragContext.tsx +60 -0
  159. package/src/react/events.tsx +149 -0
  160. package/src/react/index.ts +64 -0
  161. package/src/react/layers.ts +66 -0
  162. package/src/react/overlays.tsx +254 -0
  163. package/src/react/popup.tsx +176 -0
  164. package/src/react/search.tsx +177 -0
  165. package/src/react/setupProgress.ts +113 -0
  166. package/src/react/shell.tsx +27 -0
  167. package/src/react/sidebar.tsx +84 -0
  168. package/src/react/table.tsx +176 -0
  169. package/src/react/tasks.tsx +176 -0
  170. package/src/react/tour.tsx +183 -0
  171. package/src/react/windowBoundary.tsx +110 -0
  172. package/src/react/wizard.tsx +150 -0
@@ -0,0 +1,350 @@
1
+ import { useEffect, useId, useMemo, useRef, useState } from 'react'
2
+ import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
3
+ import { canPerform, perform } from '../core/commands.js'
4
+ import type { CommandId } from '../core/commands.js'
5
+ import { focusedId } from '../core/desk.js'
6
+ import { bindShortcuts, formatShortcut, isApplePlatform } from '../core/shortcuts.js'
7
+ import type { DeskState, WindowId } from '../core/types.js'
8
+ import { useDesk } from './context.js'
9
+
10
+ /*
11
+ * A menu bar in the AppKit manner. Menus never take keyboard focus from the
12
+ * window you were working in — they are driven by a document key listener while
13
+ * open — so a command chosen from a menu reaches the same responder a shortcut
14
+ * would. Items ask the responder chain whether they are enabled as the menu opens.
15
+ */
16
+
17
+ type Flag = boolean | (() => boolean)
18
+
19
+ interface ItemCommon {
20
+ readonly label: string
21
+ /** Shown after the label, muted. */
22
+ readonly detail?: string
23
+ readonly shortcut?: string
24
+ readonly checked?: Flag
25
+ }
26
+
27
+ export type MenuItem =
28
+ | ({ readonly type: 'command'; readonly command: CommandId; readonly args?: unknown } & ItemCommon)
29
+ | ({ readonly type: 'action'; readonly onSelect: () => void; readonly disabled?: Flag } & ItemCommon)
30
+ | { readonly type: 'separator' }
31
+ | { readonly type: 'header'; readonly label: string }
32
+
33
+ export interface Menu {
34
+ readonly id: string
35
+ /** The accessible name, and the title when `title` is not given. */
36
+ readonly label: string
37
+ /** A richer title: an icon, a badge. */
38
+ readonly title?: ReactNode
39
+ /** The app's own menu: its name, set in bold. Conventionally the first one. */
40
+ readonly emphasis?: boolean
41
+ /** Items, or a function called each time the menu opens — for lists that change, like open windows. */
42
+ readonly items: readonly MenuItem[] | (() => readonly MenuItem[])
43
+ }
44
+
45
+ type Options<T> = Omit<T, 'type' | 'label'>
46
+ export const menuCommand = (label: string, command: CommandId, options: Omit<Options<Extract<MenuItem, { type: 'command' }>>, 'command'> = {}): MenuItem => ({
47
+ type: 'command',
48
+ label,
49
+ command,
50
+ ...options,
51
+ })
52
+ export const menuAction = (label: string, onSelect: () => void, options: Omit<Options<Extract<MenuItem, { type: 'action' }>>, 'onSelect'> = {}): MenuItem => ({
53
+ type: 'action',
54
+ label,
55
+ onSelect,
56
+ ...options,
57
+ })
58
+ export const menuSeparator = (): MenuItem => ({ type: 'separator' })
59
+ export const menuHeader = (label: string): MenuItem => ({ type: 'header', label })
60
+
61
+ /** One item per open window, the key window checked — what a Window menu lists at its foot. */
62
+ export function windowMenuItems(state: DeskState, focus: (id: WindowId) => void, title: (id: WindowId) => string): MenuItem[] {
63
+ const key = focusedId(state)
64
+ return state.windows.map(w => menuAction(title(w.id), () => focus(w.id), { checked: w.id === key }))
65
+ }
66
+
67
+ export interface MenuBarProps {
68
+ /** Menus on the leading side, in order. The first is conventionally the app's own. */
69
+ readonly menus: readonly Menu[]
70
+ /** Status menus on the trailing side. */
71
+ readonly status?: readonly Menu[]
72
+ /** Leading content before the menus, e.g. the app's mark. */
73
+ readonly leading?: ReactNode
74
+ /** Trailing content after the status menus, e.g. a clock. */
75
+ readonly trailing?: ReactNode
76
+ readonly label?: string
77
+ /** Bind the shortcuts of command items, in every menu. Default true. */
78
+ readonly shortcuts?: boolean
79
+ readonly className?: string
80
+ }
81
+
82
+ interface Resolved {
83
+ readonly item: MenuItem
84
+ readonly enabled: boolean
85
+ readonly checked: boolean | undefined
86
+ }
87
+
88
+ interface OpenMenu {
89
+ readonly id: string
90
+ readonly items: readonly Resolved[]
91
+ readonly active: number
92
+ readonly via: 'pointer' | 'keyboard'
93
+ }
94
+
95
+ const read = (flag: Flag | undefined) => (typeof flag === 'function' ? flag() : flag)
96
+ const selectable = (r: Resolved) => r.enabled && (r.item.type === 'command' || r.item.type === 'action')
97
+
98
+ export function MenuBar({ menus, status = [], leading, trailing, label = 'Menu bar', shortcuts = true, className }: MenuBarProps) {
99
+ const desk = useDesk()
100
+ const all = useMemo(() => [...menus, ...status], [menus, status])
101
+ const [open, setOpen] = useState<OpenMenu | null>(null)
102
+ const [roving, setRoving] = useState(0)
103
+ const bar = useRef<HTMLDivElement>(null)
104
+ const titles = useRef(new Map<string, HTMLButtonElement>())
105
+ // Where focus was before the keyboard reached the menu bar, so a chosen command starts from there.
106
+ const returnTo = useRef<HTMLElement | null>(null)
107
+ const baseId = useId()
108
+ const apple = useMemo(isApplePlatform, [])
109
+
110
+ const resolve = (menu: Menu): Resolved[] =>
111
+ (typeof menu.items === 'function' ? menu.items() : menu.items).map(item => ({
112
+ item,
113
+ enabled:
114
+ item.type === 'command' ? canPerform(desk, item.command)
115
+ : item.type === 'action' ? !read(item.disabled)
116
+ : false,
117
+ checked: item.type === 'command' || item.type === 'action' ? read(item.checked) : undefined,
118
+ }))
119
+
120
+ const step = (items: readonly Resolved[], from: number, delta: 1 | -1) => {
121
+ for (let i = 1; i <= items.length; i++) {
122
+ const index = (from + delta * i + items.length * 2) % items.length
123
+ if (items[index] && selectable(items[index])) return index
124
+ }
125
+ return -1
126
+ }
127
+
128
+ const openMenu = (index: number, via: OpenMenu['via'], active: 'first' | 'last' | 'none' = 'first') => {
129
+ const menu = all[(index + all.length) % all.length]
130
+ if (!menu) return
131
+ const items = resolve(menu)
132
+ setRoving(all.indexOf(menu))
133
+ setOpen({
134
+ id: menu.id,
135
+ items,
136
+ via,
137
+ active: active === 'none' ? -1 : active === 'first' ? step(items, -1, 1) : step(items, items.length, -1),
138
+ })
139
+ if (via === 'keyboard') titles.current.get(menu.id)?.focus()
140
+ }
141
+
142
+ const close = (returnFocus: boolean) => {
143
+ const was = open
144
+ setOpen(null)
145
+ if (returnFocus && was) titles.current.get(was.id)?.focus()
146
+ }
147
+
148
+ const activate = (resolved: Resolved | undefined) => {
149
+ if (!resolved || !selectable(resolved)) return
150
+ const wasKeyboard = open?.via === 'keyboard'
151
+ setOpen(null)
152
+ // Keyboard use put focus on the menu bar; send it back so the command starts where the person was working.
153
+ if (wasKeyboard && bar.current?.contains(document.activeElement)) {
154
+ if (returnTo.current?.isConnected) returnTo.current.focus()
155
+ else (document.activeElement as HTMLElement).blur()
156
+ }
157
+ const { item } = resolved
158
+ if (item.type === 'command') perform(desk, item.command, item.args)
159
+ else if (item.type === 'action') item.onSelect()
160
+ }
161
+
162
+ // While a menu is open, the keyboard drives it without it ever holding focus.
163
+ useEffect(() => {
164
+ if (!open) return
165
+ const menuIndex = all.findIndex(m => m.id === open.id)
166
+ const onKey = (event: KeyboardEvent) => {
167
+ const handled = (() => {
168
+ switch (event.key) {
169
+ case 'ArrowDown': setOpen({ ...open, active: step(open.items, open.active, 1) }); return true
170
+ case 'ArrowUp': setOpen({ ...open, active: step(open.items, open.active < 0 ? open.items.length : open.active, -1) }); return true
171
+ case 'Home': setOpen({ ...open, active: step(open.items, -1, 1) }); return true
172
+ case 'End': setOpen({ ...open, active: step(open.items, open.items.length, -1) }); return true
173
+ case 'ArrowRight': openMenu(menuIndex + 1, open.via); return true
174
+ case 'ArrowLeft': openMenu(menuIndex - 1, open.via); return true
175
+ case 'Enter':
176
+ case ' ': activate(open.items[open.active]); return true
177
+ case 'Escape': close(open.via === 'keyboard'); return true
178
+ case 'Tab': close(false); return false
179
+ default: {
180
+ if (event.key.length !== 1 || event.metaKey || event.ctrlKey || event.altKey) return false
181
+ const letter = event.key.toLowerCase()
182
+ const start = open.active
183
+ for (let i = 1; i <= open.items.length; i++) {
184
+ const index = (start + i) % open.items.length
185
+ const r = open.items[index]
186
+ if (r && selectable(r) && 'label' in r.item && r.item.label.toLowerCase().startsWith(letter)) {
187
+ setOpen({ ...open, active: index })
188
+ return true
189
+ }
190
+ }
191
+ return false
192
+ }
193
+ }
194
+ })()
195
+ if (handled) {
196
+ event.preventDefault()
197
+ event.stopPropagation()
198
+ }
199
+ }
200
+ const onPointerDown = (event: PointerEvent) => {
201
+ if (!bar.current?.contains(event.target as Node)) setOpen(null)
202
+ }
203
+ document.addEventListener('keydown', onKey, true)
204
+ document.addEventListener('pointerdown', onPointerDown, true)
205
+ return () => {
206
+ document.removeEventListener('keydown', onKey, true)
207
+ document.removeEventListener('pointerdown', onPointerDown, true)
208
+ }
209
+ })
210
+
211
+ // Menus own their key equivalents, as in AppKit. Menus that build their items when opened are
212
+ // read when a key is pressed, so their shortcuts work before the menu has ever been opened.
213
+ const menusNow = useRef(all)
214
+ menusNow.current = all
215
+ useEffect(
216
+ () =>
217
+ shortcuts
218
+ ? bindShortcuts(
219
+ desk,
220
+ () =>
221
+ Object.fromEntries(
222
+ menusNow.current
223
+ .flatMap(m => (typeof m.items === 'function' ? m.items() : m.items))
224
+ .flatMap(i => (i.type === 'command' && i.shortcut ? [[i.shortcut, i.command] as const] : [])),
225
+ ),
226
+ { apple },
227
+ )
228
+ : undefined,
229
+ [desk, shortcuts, apple],
230
+ )
231
+
232
+ const onBarKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
233
+ if (open) return
234
+ const index = all.findIndex(m => titles.current.get(m.id) === document.activeElement)
235
+ if (index < 0) return
236
+ const move = (to: number) => {
237
+ const next = all[(to + all.length) % all.length]
238
+ if (next) {
239
+ setRoving(all.indexOf(next))
240
+ titles.current.get(next.id)?.focus()
241
+ }
242
+ }
243
+ switch (event.key) {
244
+ case 'ArrowRight': move(index + 1); break
245
+ case 'ArrowLeft': move(index - 1); break
246
+ case 'Home': move(0); break
247
+ case 'End': move(all.length - 1); break
248
+ case 'ArrowDown':
249
+ case 'Enter':
250
+ case ' ': openMenu(index, 'keyboard', 'first'); break
251
+ case 'ArrowUp': openMenu(index, 'keyboard', 'last'); break
252
+ default: return
253
+ }
254
+ event.preventDefault()
255
+ }
256
+
257
+ const renderMenu = (menu: Menu, index: number) => {
258
+ const isOpen = open?.id === menu.id
259
+ const titleId = `${baseId}-title-${menu.id}`
260
+ const menuId = `${baseId}-menu-${menu.id}`
261
+ return (
262
+ <div className="desk-menubar-slot" key={menu.id} data-align={index >= menus.length ? 'end' : undefined}>
263
+ <button
264
+ ref={el => {
265
+ if (el) titles.current.set(menu.id, el)
266
+ else titles.current.delete(menu.id)
267
+ }}
268
+ id={titleId}
269
+ type="button"
270
+ role="menuitem"
271
+ className="desk-menubar-title"
272
+ aria-haspopup="menu"
273
+ aria-expanded={isOpen}
274
+ aria-controls={isOpen ? menuId : undefined}
275
+ aria-label={menu.title ? menu.label : undefined}
276
+ data-emphasis={menu.emphasis || undefined}
277
+ tabIndex={index === roving ? 0 : -1}
278
+ // Opening a menu with the pointer must not move focus out of the window being worked in.
279
+ onMouseDown={event => event.preventDefault()}
280
+ onClick={() => (isOpen ? close(false) : openMenu(index, 'pointer', 'none'))}
281
+ onPointerEnter={() => {
282
+ if (open && !isOpen) openMenu(index, open.via, open.via === 'keyboard' ? 'first' : 'none')
283
+ }}
284
+ >
285
+ {menu.title ?? menu.label}
286
+ </button>
287
+ {isOpen && open && (
288
+ <div
289
+ id={menuId}
290
+ role="menu"
291
+ aria-labelledby={titleId}
292
+ className="desk-menu"
293
+ aria-activedescendant={open.active >= 0 ? `${menuId}-${open.active}` : undefined}
294
+ >
295
+ {open.items.map((resolved, i) => {
296
+ const { item } = resolved
297
+ if (item.type === 'separator') return <div key={i} role="separator" className="desk-menu-separator" />
298
+ if (item.type === 'header') return <div key={i} role="presentation" className="desk-menu-header">{item.label}</div>
299
+ const checkable = resolved.checked !== undefined
300
+ return (
301
+ <div
302
+ key={i}
303
+ id={`${menuId}-${i}`}
304
+ role={checkable ? 'menuitemcheckbox' : 'menuitem'}
305
+ aria-checked={checkable ? resolved.checked : undefined}
306
+ aria-disabled={!resolved.enabled || undefined}
307
+ className="desk-menu-item"
308
+ data-active={i === open.active || undefined}
309
+ onMouseDown={event => event.preventDefault()}
310
+ onPointerMove={() => {
311
+ if (open.active !== i && selectable(resolved)) setOpen({ ...open, active: i })
312
+ }}
313
+ onPointerLeave={() => {
314
+ if (open.via === 'pointer' && open.active === i) setOpen({ ...open, active: -1 })
315
+ }}
316
+ onClick={() => activate(resolved)}
317
+ >
318
+ <span className="desk-menu-check" aria-hidden="true">{resolved.checked ? '✓' : ''}</span>
319
+ <span className="desk-menu-label">{item.label}</span>
320
+ {item.detail && <span className="desk-menu-detail">{item.detail}</span>}
321
+ {item.shortcut && <kbd className="desk-menu-shortcut">{formatShortcut(item.shortcut, apple)}</kbd>}
322
+ </div>
323
+ )
324
+ })}
325
+ </div>
326
+ )}
327
+ </div>
328
+ )
329
+ }
330
+
331
+ return (
332
+ <div
333
+ ref={bar}
334
+ role="menubar"
335
+ aria-label={label}
336
+ className={['desk-menubar', className].filter(Boolean).join(' ')}
337
+ onKeyDown={onBarKeyDown}
338
+ onFocus={event => {
339
+ const from = event.relatedTarget
340
+ if (from instanceof HTMLElement && !bar.current?.contains(from)) returnTo.current = from
341
+ }}
342
+ >
343
+ {leading && <div className="desk-menubar-leading">{leading}</div>}
344
+ {menus.map((menu, i) => renderMenu(menu, i))}
345
+ <span className="desk-menubar-spacer" />
346
+ {status.map((menu, i) => renderMenu(menu, menus.length + i))}
347
+ {trailing && <div className="desk-menubar-trailing">{trailing}</div>}
348
+ </div>
349
+ )
350
+ }
@@ -0,0 +1,183 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import type { CSSProperties } from 'react'
3
+ import { topicMatches } from '../core/events.js'
4
+ import type { DeskEvent } from '../core/events.js'
5
+ import { useDragging, useDropTarget } from './dnd.js'
6
+ import type { Accepts } from './dnd.js'
7
+ import { useDesk, useWindowId } from './context.js'
8
+ import { useBus } from './events.js'
9
+
10
+ /*
11
+ * A generated app — a self-contained page — running inside a window, and taking
12
+ * part in the desk as a native window does: hearing events, saying things, and
13
+ * taking what is dropped on it.
14
+ *
15
+ * It runs in a sandboxed frame with no access to the host's origin, and talks
16
+ * only through messages. The host decides, per app, which topics it may hear,
17
+ * which it may say, and what may be dropped on it. Nothing else crosses.
18
+ */
19
+
20
+ /** The protocol version. Every message carries it, so a mismatch is ignored rather than misread. */
21
+ const PROTOCOL = 1
22
+
23
+ type HostMessage =
24
+ | { readonly desk: 1; readonly kind: 'hello'; readonly window: string | null; readonly listens: readonly string[]; readonly says: readonly string[] }
25
+ | { readonly desk: 1; readonly kind: 'event'; readonly topic: string; readonly payload: unknown; readonly from: string | null }
26
+ | { readonly desk: 1; readonly kind: 'drop'; readonly type: string; readonly payload: unknown; readonly from: string | null }
27
+
28
+ type AppMessage =
29
+ | { readonly desk: 1; readonly kind: 'ready' }
30
+ | { readonly desk: 1; readonly kind: 'publish'; readonly topic: string; readonly payload: unknown }
31
+ | { readonly desk: 1; readonly kind: 'open'; readonly window: string }
32
+
33
+ const isAppMessage = (data: unknown): data is AppMessage =>
34
+ typeof data === 'object' && data !== null && (data as { desk?: unknown }).desk === PROTOCOL && typeof (data as { kind?: unknown }).kind === 'string'
35
+
36
+ export interface AppFrameProps {
37
+ /** The accessible name of the frame: what the app is. */
38
+ readonly title: string
39
+ /** The app's page, as a URL or as the HTML itself. Give one. */
40
+ readonly src?: string
41
+ readonly srcDoc?: string
42
+ /** Topics the app may hear, e.g. `['ride.selected']` or `['ride.*']`. Default: none. */
43
+ readonly listens?: readonly string[]
44
+ /** Topics the app may say. Anything else it publishes is dropped. Default: none. */
45
+ readonly says?: readonly string[]
46
+ /** What may be dropped on it. Default: nothing. */
47
+ readonly accepts?: Accepts
48
+ /** Windows the app may ask to open. Default: none. */
49
+ readonly opens?: readonly string[]
50
+ /**
51
+ * The frame's sandbox. The default lets the app run its own scripts and forms, and
52
+ * nothing more: no same-origin access, no top-level navigation, no pop-ups.
53
+ */
54
+ readonly sandbox?: string
55
+ readonly className?: string
56
+ }
57
+
58
+ export function AppFrame({
59
+ title,
60
+ src,
61
+ srcDoc,
62
+ listens = [],
63
+ says = [],
64
+ accepts,
65
+ opens = [],
66
+ sandbox = 'allow-scripts allow-forms',
67
+ className,
68
+ }: AppFrameProps) {
69
+ const desk = useDesk()
70
+ const bus = useBus()
71
+ const windowId = useWindowId()
72
+ const frame = useRef<HTMLIFrameElement>(null)
73
+ const dragging = useDragging()
74
+
75
+ const post = (message: HostMessage) => {
76
+ // A sandboxed frame without same-origin has an opaque origin, so '*' is the only
77
+ // target that reaches it. It is safe here: the message goes to this frame's
78
+ // window and no other.
79
+ frame.current?.contentWindow?.postMessage(message, '*')
80
+ }
81
+
82
+ const latest = useRef({ listens, says, opens, windowId })
83
+ latest.current = { listens, says, opens, windowId }
84
+
85
+ // What the app says: only from this frame, only on topics it was granted.
86
+ useEffect(() => {
87
+ const onMessage = (event: MessageEvent) => {
88
+ if (!frame.current || event.source !== frame.current.contentWindow || !isAppMessage(event.data)) return
89
+ const message = event.data
90
+ const granted = latest.current
91
+ if (message.kind === 'ready') {
92
+ post({ desk: PROTOCOL, kind: 'hello', window: granted.windowId, listens: granted.listens, says: granted.says })
93
+ } else if (message.kind === 'publish') {
94
+ if (typeof message.topic === 'string' && granted.says.some(pattern => topicMatches(pattern, message.topic))) {
95
+ bus.publish(message.topic, message.payload, { from: granted.windowId })
96
+ }
97
+ } else if (message.kind === 'open') {
98
+ if (typeof message.window === 'string' && granted.opens.includes(message.window)) desk.open(message.window)
99
+ }
100
+ }
101
+ window.addEventListener('message', onMessage)
102
+ return () => window.removeEventListener('message', onMessage)
103
+ }, [bus, desk])
104
+
105
+ // What the app hears: only the topics it was granted, and never its own words back.
106
+ const listenKey = listens.join('|')
107
+ useEffect(() => {
108
+ if (!listens.length) return
109
+ const stops = listens.map(pattern =>
110
+ bus.subscribe(pattern, (event: DeskEvent) => {
111
+ if (event.from !== null && event.from === latest.current.windowId) return
112
+ post({ desk: PROTOCOL, kind: 'event', topic: event.topic, payload: event.payload, from: event.from })
113
+ }),
114
+ )
115
+ return () => stops.forEach(stop => stop())
116
+ // listenKey stands for the list's contents; the array itself changes identity every render.
117
+ }, [bus, listenKey])
118
+
119
+ const { dropProps } = useDropTarget({
120
+ accepts: accepts ?? (() => false),
121
+ disabled: accepts === undefined,
122
+ onDrop: drag => post({ desk: PROTOCOL, kind: 'drop', type: drag.type, payload: drag.payload, from: drag.from }),
123
+ })
124
+
125
+ // While something is being carried, the frame must not swallow the pointer, or the
126
+ // desk can never see that it is over this app.
127
+ const frameStyle: CSSProperties = { pointerEvents: dragging ? 'none' : 'auto' }
128
+
129
+ return (
130
+ <div className={['desk-app-frame', className].filter(Boolean).join(' ')} {...dropProps}>
131
+ <iframe
132
+ ref={frame}
133
+ title={title}
134
+ className="desk-app-frame-page"
135
+ sandbox={sandbox}
136
+ {...(srcDoc !== undefined ? { srcDoc } : src !== undefined ? { src } : {})}
137
+ style={frameStyle}
138
+ />
139
+ </div>
140
+ )
141
+ }
142
+
143
+ /**
144
+ * The app's half of the bridge, as a script to put in its page. It gives the page a
145
+ * small `desk` object:
146
+ *
147
+ * desk.on('ride.selected', event => …) hear a granted topic
148
+ * desk.onDrop(drop => …) take what is dropped on the window
149
+ * desk.publish('ride.selected', ride) say something, if it was granted
150
+ * desk.open('map') ask for a window, if it was granted
151
+ * desk.window which window this is, once connected
152
+ */
153
+ export const APP_BRIDGE_SCRIPT = `(() => {
154
+ const handlers = new Map();
155
+ const drops = [];
156
+ const send = message => parent.postMessage(Object.assign({ desk: ${PROTOCOL} }, message), '*');
157
+ const desk = {
158
+ window: null,
159
+ listens: [],
160
+ says: [],
161
+ on(topic, handler) { (handlers.get(topic) || handlers.set(topic, []).get(topic)).push(handler); },
162
+ onDrop(handler) { drops.push(handler); },
163
+ publish(topic, payload) { send({ kind: 'publish', topic, payload }); },
164
+ open(window) { send({ kind: 'open', window }); },
165
+ };
166
+ const matches = (pattern, topic) =>
167
+ pattern === '*' || pattern === topic || (pattern.endsWith('.*') && (topic === pattern.slice(0, -2) || topic.startsWith(pattern.slice(0, -1))));
168
+ addEventListener('message', event => {
169
+ if (event.source !== parent || !event.data || event.data.desk !== ${PROTOCOL}) return;
170
+ const message = event.data;
171
+ if (message.kind === 'hello') { desk.window = message.window; desk.listens = message.listens; desk.says = message.says; }
172
+ if (message.kind === 'event') for (const [pattern, list] of handlers) if (matches(pattern, message.topic)) list.forEach(h => h(message));
173
+ if (message.kind === 'drop') drops.forEach(h => h(message));
174
+ });
175
+ window.desk = desk;
176
+ send({ kind: 'ready' });
177
+ })();`
178
+
179
+ /** Puts the bridge into a page of HTML, so a generated app needs nothing added by hand. */
180
+ export function withAppBridge(html: string): string {
181
+ const script = `<script>${APP_BRIDGE_SCRIPT}</script>`
182
+ return /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, match => `${match}${script}`) : `${script}${html}`
183
+ }
@@ -0,0 +1,82 @@
1
+ import type { Frame, WindowId } from '../core/types.js'
2
+
3
+ /** The part of the stage windows are laid out in, and the gap between them. */
4
+ export interface Area extends Frame {
5
+ readonly gap: number
6
+ }
7
+
8
+ export interface ArrangeLimits {
9
+ readonly minWidth: number
10
+ readonly minHeight: number
11
+ }
12
+
13
+ /**
14
+ * Where Arrange puts each window. The same windows always land in the same places:
15
+ * the focused window takes the first and largest place, the rest follow in the order
16
+ * they were opened. Nothing here looks at where windows are now, so a small drag
17
+ * never swaps two windows the next time.
18
+ *
19
+ * 1 fills the area · 2 side by side · 3 one tall on the left, two stacked on the right
20
+ * 4 a 2 × 2 grid · more a grid, while every cell stays usable
21
+ *
22
+ * When a grid cell would be too small to use, the focused window and the one used
23
+ * before it share the area and the rest cascade over the second half, so the focused
24
+ * window, which stays in front, covers none of them.
25
+ */
26
+ export function arrangement(
27
+ ids: readonly WindowId[],
28
+ focused: WindowId | null,
29
+ previous: WindowId | null,
30
+ area: Area,
31
+ limits: ArrangeLimits,
32
+ ): Record<WindowId, Frame> {
33
+ const first = focused && ids.includes(focused) ? focused : ids[0]
34
+ if (first === undefined) return {}
35
+ const order = [first, ...ids.filter(id => id !== first)]
36
+ const { x, y, width, height, gap } = area
37
+ const half = (width - gap) / 2
38
+ const place = (frames: readonly Frame[]) => Object.fromEntries(order.map((id, i) => [id, frames[i] as Frame]))
39
+
40
+ if (order.length === 1) return place([{ x, y, width, height }])
41
+ if (order.length === 2) return place([{ x, y, width: half, height }, { x: x + half + gap, y, width: half, height }])
42
+ if (order.length === 3) {
43
+ const stacked = (height - gap) / 2
44
+ return place([
45
+ { x, y, width: half, height },
46
+ { x: x + half + gap, y, width: half, height: stacked },
47
+ { x: x + half + gap, y: y + stacked + gap, width: half, height: stacked },
48
+ ])
49
+ }
50
+
51
+ const columns = Math.ceil(Math.sqrt(order.length))
52
+ const rows = Math.ceil(order.length / columns)
53
+ const cellWidth = (width - gap * (columns - 1)) / columns
54
+ const cellHeight = (height - gap * (rows - 1)) / rows
55
+ if (cellWidth >= limits.minWidth && cellHeight >= limits.minHeight) {
56
+ return place(
57
+ order.map((_, i) => ({
58
+ x: x + (i % columns) * (cellWidth + gap),
59
+ y: y + Math.floor(i / columns) * (cellHeight + gap),
60
+ width: cellWidth,
61
+ height: cellHeight,
62
+ })),
63
+ )
64
+ }
65
+
66
+ // Too many to grid: the two most recent share the area, the rest cascade over the second half.
67
+ const second = previous && previous !== first && ids.includes(previous) ? previous : (order[1] as WindowId)
68
+ const rest = order.filter(id => id !== first && id !== second)
69
+ const step = 28
70
+ const floatWidth = Math.max(limits.minWidth, Math.min(720, half - step * 2))
71
+ const floatHeight = Math.max(limits.minHeight, Math.min(520, height - step * 2))
72
+ return {
73
+ [first]: { x, y, width: half, height },
74
+ [second]: { x: x + half + gap, y, width: half, height },
75
+ ...Object.fromEntries(
76
+ rest.map((id, i) => {
77
+ const n = i % 6
78
+ return [id, { x: x + half + gap + step + n * 32, y: y + step + n * step, width: floatWidth, height: floatHeight }]
79
+ }),
80
+ ),
81
+ }
82
+ }
@@ -0,0 +1,60 @@
1
+ import { useCallback, useContext, useEffect, useRef } from 'react'
2
+ import type { RefObject } from 'react'
3
+ import { addCommandHandler, canPerform, perform } from '../core/commands.js'
4
+ import type { CommandHandler, CommandId } from '../core/commands.js'
5
+ import { bindShortcuts } from '../core/shortcuts.js'
6
+ import type { Keymap, ShortcutOptions } from '../core/shortcuts.js'
7
+ import { useDesk, WindowContext } from './context.js'
8
+
9
+ export interface UseCommandOptions {
10
+ /** Whether the handler can act right now. Read when the command arrives and when a menu opens. */
11
+ readonly enabled?: boolean | (() => boolean)
12
+ /**
13
+ * Where in the chain this responder sits.
14
+ * - `'window'` (the default inside a window): the window this component is rendered in.
15
+ * - `'app'` (the default outside windows): the top of the chain.
16
+ * - a ref: that element, e.g. an editor that answers Copy only while it has focus.
17
+ */
18
+ readonly at?: 'window' | 'app' | RefObject<Element | null>
19
+ }
20
+
21
+ /** Answers a command when it reaches this component's place in the responder chain. */
22
+ export function useCommand<Args = unknown>(command: CommandId, handler: CommandHandler<Args>, options: UseCommandOptions = {}): void {
23
+ const window = useContext(WindowContext)
24
+ // Latest handler and enablement without re-registering on every render.
25
+ const latest = useRef({ handler, enabled: options.enabled })
26
+ latest.current = { handler, enabled: options.enabled }
27
+
28
+ const at = options.at ?? (window ? 'window' : 'app')
29
+ const ref = typeof at === 'object' ? at : at === 'window' ? window?.element : null
30
+
31
+ useEffect(() => {
32
+ const target: EventTarget | null | undefined = at === 'app' ? document : ref?.current
33
+ if (!target) return
34
+ return addCommandHandler<Args>(target, command, args => latest.current.handler(args), {
35
+ enabled: () => {
36
+ const enabled = latest.current.enabled
37
+ return typeof enabled === 'function' ? enabled() : (enabled ?? true)
38
+ },
39
+ })
40
+ }, [command, at, ref])
41
+ }
42
+
43
+ /** Returns a function that sends a command along the chain, and reports whether anything handled it. */
44
+ export function usePerform(): <Args = unknown>(command: CommandId, args?: Args) => boolean {
45
+ const desk = useDesk()
46
+ return useCallback((command, args) => perform(desk, command, args), [desk])
47
+ }
48
+
49
+ /** Returns a function that asks whether a command would be handled right now — what a menu calls as it opens. */
50
+ export function useCanPerform(): (command: CommandId) => boolean {
51
+ const desk = useDesk()
52
+ return useCallback(command => canPerform(desk, command), [desk])
53
+ }
54
+
55
+ /** Performs commands from keyboard shortcuts while mounted. Pass a stable keymap. */
56
+ export function useShortcuts(keymap: Keymap, options: ShortcutOptions = {}): void {
57
+ const desk = useDesk()
58
+ const { target, apple } = options
59
+ useEffect(() => bindShortcuts(desk, keymap, { ...(target ? { target } : {}), ...(apple === undefined ? {} : { apple }) }), [desk, keymap, target, apple])
60
+ }