@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,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()