@liberation-data/desk 0.3.0 → 0.6.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/README.md +36 -1
- package/dist/core/desk.d.ts +13 -0
- package/dist/core/desk.d.ts.map +1 -1
- package/dist/core/desk.js +55 -1
- package/dist/core/desk.js.map +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/css/apps.css +57 -0
- package/dist/css/controls.css +975 -0
- package/dist/css/conversation.css +232 -0
- package/dist/css/dock.css +272 -0
- package/dist/css/menus.css +204 -0
- package/dist/css/overlays.css +237 -0
- package/dist/css/search.css +149 -0
- package/dist/css/setup.css +284 -0
- package/dist/css/tokens.css +90 -0
- package/dist/css/windows.css +321 -0
- package/dist/desk.css +1096 -1067
- package/dist/react/Desktop.d.ts +7 -1
- package/dist/react/Desktop.d.ts.map +1 -1
- package/dist/react/Desktop.js +71 -21
- package/dist/react/Desktop.js.map +1 -1
- package/llms.txt +8 -0
- package/package.json +4 -3
- package/src/core/desk.ts +67 -1
- package/src/core/index.ts +1 -1
- package/src/css/apps.css +56 -0
- package/src/css/controls.css +974 -0
- package/src/css/conversation.css +231 -0
- package/src/css/dock.css +271 -0
- package/src/css/menus.css +203 -0
- package/src/css/overlays.css +236 -0
- package/src/css/search.css +148 -0
- package/src/css/setup.css +283 -0
- package/src/css/tokens.css +89 -0
- package/src/css/windows.css +320 -0
- package/src/react/Desktop.tsx +89 -22
- package/src/desk.css +0 -2801
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* desk — the stage and its windows. Every value reads a --desk-* token, so an
|
|
3
|
+
* app restyles the desk by redefining tokens, never by overriding selectors.
|
|
4
|
+
*/
|
|
5
|
+
:root {
|
|
6
|
+
--desk-ground: #eef0f4;
|
|
7
|
+
--desk-surface: #ffffff;
|
|
8
|
+
--desk-titlebar: #f6f7f9;
|
|
9
|
+
--desk-ink: #1b1f27;
|
|
10
|
+
--desk-muted: #667085;
|
|
11
|
+
--desk-rule: #dde1e8;
|
|
12
|
+
--desk-accent: #5b57f5;
|
|
13
|
+
--desk-close: #ef6b63;
|
|
14
|
+
--desk-mode: #34c47a;
|
|
15
|
+
--desk-info: #34c47a;
|
|
16
|
+
--desk-radius: 12px;
|
|
17
|
+
--desk-gap: 12px;
|
|
18
|
+
--desk-padding: 12px;
|
|
19
|
+
--desk-shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 12px 32px rgb(16 24 40 / 10%);
|
|
20
|
+
--desk-shadow-floating: 0 2px 4px rgb(16 24 40 / 8%), 0 24px 60px rgb(16 24 40 / 22%);
|
|
21
|
+
--desk-font: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
22
|
+
--desk-font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
|
|
23
|
+
|
|
24
|
+
/* The spacing scale (HIG.md §2). Nothing between these values. */
|
|
25
|
+
--desk-space-1: 4px;
|
|
26
|
+
--desk-space-2: 8px;
|
|
27
|
+
--desk-space-3: 12px;
|
|
28
|
+
--desk-space-4: 16px;
|
|
29
|
+
--desk-space-5: 24px;
|
|
30
|
+
--desk-space-6: 32px;
|
|
31
|
+
--desk-space-7: 48px;
|
|
32
|
+
|
|
33
|
+
/* State, kept apart from the accent. */
|
|
34
|
+
--desk-ok: #2f9e68;
|
|
35
|
+
--desk-warn: #b7791f;
|
|
36
|
+
--desk-bad: #c6483f;
|
|
37
|
+
|
|
38
|
+
--desk-control-height: 28px;
|
|
39
|
+
--desk-control-height-small: 22px;
|
|
40
|
+
--desk-control-radius: 7px;
|
|
41
|
+
--desk-field-bg: #ffffff;
|
|
42
|
+
--desk-on-accent: #ffffff;
|
|
43
|
+
--desk-ease: cubic-bezier(.32, .72, 0, 1);
|
|
44
|
+
--desk-dock-size: 48px;
|
|
45
|
+
--desk-dock-glass: rgb(255 255 255 / 72%);
|
|
46
|
+
--desk-dock-icon: linear-gradient(160deg, #ffffff, #eceff5);
|
|
47
|
+
--desk-badge: #ef4444;
|
|
48
|
+
--desk-badge-ink: #ffffff;
|
|
49
|
+
/* Room the stage leaves at the bottom for an overlay dock. */
|
|
50
|
+
--desk-inset-bottom: 0px;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@media (prefers-color-scheme: dark) {
|
|
54
|
+
:root:not([data-theme="light"]) {
|
|
55
|
+
--desk-ground: #07090f;
|
|
56
|
+
--desk-surface: #101828;
|
|
57
|
+
--desk-titlebar: #0b111d;
|
|
58
|
+
--desk-ink: #e6e6e6;
|
|
59
|
+
--desk-muted: #99a1af;
|
|
60
|
+
--desk-rule: #1e2939;
|
|
61
|
+
--desk-shadow: 0 12px 32px rgb(0 0 0 / 45%);
|
|
62
|
+
--desk-shadow-floating: 0 30px 80px rgb(0 0 0 / 70%);
|
|
63
|
+
--desk-dock-glass: rgb(11 18 32 / 62%);
|
|
64
|
+
--desk-dock-icon: linear-gradient(160deg, #1a2338, #0e1526);
|
|
65
|
+
--desk-ok: #3ecf8e;
|
|
66
|
+
--desk-info: #3ecf8e;
|
|
67
|
+
--desk-warn: #fbbf24;
|
|
68
|
+
--desk-bad: #f87171;
|
|
69
|
+
--desk-field-bg: rgb(0 0 0 / 35%);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
:root[data-theme="dark"] {
|
|
74
|
+
--desk-ground: #07090f;
|
|
75
|
+
--desk-surface: #101828;
|
|
76
|
+
--desk-titlebar: #0b111d;
|
|
77
|
+
--desk-ink: #e6e6e6;
|
|
78
|
+
--desk-muted: #99a1af;
|
|
79
|
+
--desk-rule: #1e2939;
|
|
80
|
+
--desk-shadow: 0 12px 32px rgb(0 0 0 / 45%);
|
|
81
|
+
--desk-shadow-floating: 0 30px 80px rgb(0 0 0 / 70%);
|
|
82
|
+
--desk-dock-glass: rgb(11 18 32 / 62%);
|
|
83
|
+
--desk-dock-icon: linear-gradient(160deg, #1a2338, #0e1526);
|
|
84
|
+
--desk-ok: #3ecf8e;
|
|
85
|
+
--desk-info: #3ecf8e;
|
|
86
|
+
--desk-warn: #fbbf24;
|
|
87
|
+
--desk-bad: #f87171;
|
|
88
|
+
--desk-field-bg: rgb(0 0 0 / 35%);
|
|
89
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
.desk-stage {
|
|
2
|
+
position: relative;
|
|
3
|
+
box-sizing: border-box;
|
|
4
|
+
height: 100%;
|
|
5
|
+
min-height: 0;
|
|
6
|
+
padding: var(--desk-padding);
|
|
7
|
+
padding-bottom: calc(var(--desk-padding) + var(--desk-inset-bottom));
|
|
8
|
+
display: grid;
|
|
9
|
+
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
|
|
10
|
+
gap: var(--desk-gap);
|
|
11
|
+
overflow: hidden;
|
|
12
|
+
background: var(--desk-ground);
|
|
13
|
+
color: var(--desk-ink);
|
|
14
|
+
font-family: var(--desk-font);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.desk-window {
|
|
18
|
+
/* Filled windows fill the stage inside its padding, layered in the order they were used. */
|
|
19
|
+
position: absolute;
|
|
20
|
+
top: var(--desk-padding);
|
|
21
|
+
right: var(--desk-padding);
|
|
22
|
+
bottom: calc(var(--desk-padding) + var(--desk-inset-bottom));
|
|
23
|
+
left: var(--desk-padding);
|
|
24
|
+
box-sizing: border-box;
|
|
25
|
+
min-width: 0;
|
|
26
|
+
min-height: 0;
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-direction: column;
|
|
29
|
+
background: var(--desk-surface);
|
|
30
|
+
border: 1px solid var(--desk-rule);
|
|
31
|
+
border-radius: var(--desk-radius);
|
|
32
|
+
box-shadow: var(--desk-shadow);
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
transition: border-color .15s var(--desk-ease), box-shadow .15s var(--desk-ease);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.desk-window:focus {
|
|
38
|
+
/* The key window is already marked by its border; a ring on the whole window would be noise. */
|
|
39
|
+
outline: none;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.desk-window[data-focused] {
|
|
43
|
+
border-color: color-mix(in srgb, var(--desk-accent) 45%, var(--desk-rule));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.desk-window[data-hidden] {
|
|
47
|
+
display: none;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* One window at a time: it fills the stage, and the zoom control is not offered. */
|
|
51
|
+
.desk-stage[data-layout="fullscreen"] .desk-window {
|
|
52
|
+
position: static;
|
|
53
|
+
inset: auto;
|
|
54
|
+
grid-area: 1 / 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.desk-window[data-mode="floating"] {
|
|
58
|
+
right: auto;
|
|
59
|
+
bottom: auto;
|
|
60
|
+
box-shadow: var(--desk-shadow-floating);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.desk-window[data-dragging] {
|
|
64
|
+
transition: none;
|
|
65
|
+
user-select: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.desk-titlebar {
|
|
69
|
+
flex: none;
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 10px;
|
|
73
|
+
padding: 8px 12px;
|
|
74
|
+
background: var(--desk-titlebar);
|
|
75
|
+
border-bottom: 1px solid var(--desk-rule);
|
|
76
|
+
user-select: none;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.desk-titlebar[data-draggable] {
|
|
80
|
+
cursor: grab;
|
|
81
|
+
touch-action: none;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.desk-window[data-dragging] .desk-titlebar {
|
|
85
|
+
cursor: grabbing;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.desk-controls {
|
|
89
|
+
display: flex;
|
|
90
|
+
gap: 7px;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.desk-control {
|
|
94
|
+
width: 12px;
|
|
95
|
+
height: 12px;
|
|
96
|
+
padding: 0;
|
|
97
|
+
border: 0;
|
|
98
|
+
border-radius: 50%;
|
|
99
|
+
cursor: pointer;
|
|
100
|
+
background: var(--desk-rule);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.desk-window[data-focused] .desk-control[data-control="close"],
|
|
104
|
+
.desk-control[data-control="close"]:hover {
|
|
105
|
+
background: var(--desk-close);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.desk-window[data-focused] .desk-control[data-control="mode"],
|
|
109
|
+
.desk-control[data-control="mode"]:hover {
|
|
110
|
+
background: var(--desk-mode);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.desk-control:focus-visible {
|
|
114
|
+
outline: 2px solid var(--desk-accent);
|
|
115
|
+
outline-offset: 2px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.desk-title {
|
|
119
|
+
margin: 0;
|
|
120
|
+
min-width: 0;
|
|
121
|
+
overflow: hidden;
|
|
122
|
+
text-overflow: ellipsis;
|
|
123
|
+
white-space: nowrap;
|
|
124
|
+
font-size: 13px;
|
|
125
|
+
font-weight: 600;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.desk-window:not([data-focused]) .desk-title {
|
|
129
|
+
color: var(--desk-muted);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.desk-window-note {
|
|
133
|
+
margin: 0 0 0 auto;
|
|
134
|
+
min-width: 0;
|
|
135
|
+
/* The title is what the window IS; the note is what it is looking at. In a narrow window the note
|
|
136
|
+
gives up its room first, and is cut before the title is. */
|
|
137
|
+
flex: 0 999 auto;
|
|
138
|
+
overflow: hidden;
|
|
139
|
+
text-overflow: ellipsis;
|
|
140
|
+
white-space: nowrap;
|
|
141
|
+
font-size: 11.5px;
|
|
142
|
+
color: var(--desk-muted);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* The note gives up the spare room; the (i) keeps its place at the end of the bar. */
|
|
146
|
+
.desk-window-note + .desk-window-info {
|
|
147
|
+
margin-left: var(--desk-space-2);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.desk-window-info {
|
|
151
|
+
margin-left: auto;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.desk-window-note ~ .desk-window-info {
|
|
155
|
+
margin-left: var(--desk-space-2);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.desk-window-info + .desk-window-actions,
|
|
159
|
+
.desk-window-note + .desk-window-actions {
|
|
160
|
+
margin-left: var(--desk-space-1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.desk-window-actions {
|
|
164
|
+
margin-left: auto;
|
|
165
|
+
display: flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
gap: var(--desk-space-1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.desk-body {
|
|
171
|
+
flex: 1;
|
|
172
|
+
min-height: 0;
|
|
173
|
+
overflow: auto;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.desk-window-status {
|
|
177
|
+
box-sizing: border-box;
|
|
178
|
+
height: 100%;
|
|
179
|
+
min-height: 120px;
|
|
180
|
+
display: flex;
|
|
181
|
+
flex-direction: column;
|
|
182
|
+
align-items: center;
|
|
183
|
+
justify-content: center;
|
|
184
|
+
gap: var(--desk-space-2);
|
|
185
|
+
padding: var(--desk-space-4);
|
|
186
|
+
text-align: center;
|
|
187
|
+
color: var(--desk-muted);
|
|
188
|
+
font-size: 13px;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.desk-window-status b {
|
|
192
|
+
color: var(--desk-ink);
|
|
193
|
+
font-weight: 600;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.desk-window-status-detail {
|
|
197
|
+
max-width: 40ch;
|
|
198
|
+
font-size: 12px;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* Edges a window resizes from. Inside the window, so nothing clips them. */
|
|
202
|
+
.desk-edge {
|
|
203
|
+
position: absolute;
|
|
204
|
+
touch-action: none;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.desk-edge[data-edge="left"],
|
|
208
|
+
.desk-edge[data-edge="right"] {
|
|
209
|
+
top: 0;
|
|
210
|
+
bottom: 0;
|
|
211
|
+
width: 5px;
|
|
212
|
+
cursor: ew-resize;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.desk-edge[data-edge="left"] {
|
|
216
|
+
left: 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.desk-edge[data-edge="right"] {
|
|
220
|
+
right: 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.desk-edge[data-edge="bottom"] {
|
|
224
|
+
left: 0;
|
|
225
|
+
right: 0;
|
|
226
|
+
bottom: 0;
|
|
227
|
+
height: 5px;
|
|
228
|
+
cursor: ns-resize;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.desk-grip {
|
|
232
|
+
position: absolute;
|
|
233
|
+
right: 0;
|
|
234
|
+
bottom: 0;
|
|
235
|
+
width: 16px;
|
|
236
|
+
height: 16px;
|
|
237
|
+
cursor: nwse-resize;
|
|
238
|
+
touch-action: none;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* Where a dragged window would land if it were dropped now. */
|
|
242
|
+
.desk-stage[data-snap]::after {
|
|
243
|
+
content: "";
|
|
244
|
+
position: absolute;
|
|
245
|
+
top: var(--desk-padding);
|
|
246
|
+
bottom: calc(var(--desk-padding) + var(--desk-inset-bottom));
|
|
247
|
+
width: calc(50% - var(--desk-padding) - var(--desk-gap) / 2);
|
|
248
|
+
border: 2px solid var(--desk-accent);
|
|
249
|
+
border-radius: var(--desk-radius);
|
|
250
|
+
background: color-mix(in srgb, var(--desk-accent) 14%, transparent);
|
|
251
|
+
pointer-events: none;
|
|
252
|
+
z-index: 5;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.desk-stage[data-snap="start"]::after {
|
|
256
|
+
left: var(--desk-padding);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.desk-stage[data-snap="end"]::after {
|
|
260
|
+
right: var(--desk-padding);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* ── The inside of a window: controls that stay, content that scrolls ── */
|
|
264
|
+
|
|
265
|
+
.desk-pane {
|
|
266
|
+
display: flex;
|
|
267
|
+
flex-direction: column;
|
|
268
|
+
height: 100%;
|
|
269
|
+
min-height: 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.desk-pane-header,
|
|
273
|
+
.desk-pane-footer {
|
|
274
|
+
flex: none;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* The only part that scrolls, so the toolbar never leaves with the content. */
|
|
278
|
+
.desk-pane-body {
|
|
279
|
+
flex: 1;
|
|
280
|
+
min-height: 0;
|
|
281
|
+
overflow: auto;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.desk-pane-body:focus-visible {
|
|
285
|
+
outline: 2px solid var(--desk-accent);
|
|
286
|
+
outline-offset: -2px;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* A window whose whole content is a pane does not scroll itself: the pane's middle does. */
|
|
290
|
+
.desk-body:has(> .desk-pane) {
|
|
291
|
+
overflow: hidden;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.desk-toolbar {
|
|
295
|
+
display: flex;
|
|
296
|
+
align-items: center;
|
|
297
|
+
flex-wrap: wrap;
|
|
298
|
+
gap: var(--desk-space-2);
|
|
299
|
+
/* One centre line for everything in the row, whatever each control's own height is. */
|
|
300
|
+
min-height: calc(var(--desk-control-height) + var(--desk-space-2) * 2);
|
|
301
|
+
padding: var(--desk-space-2) var(--desk-space-3);
|
|
302
|
+
border-bottom: 1px solid var(--desk-rule);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.desk-toolbar > *,
|
|
306
|
+
.desk-toolbar-trailing > * {
|
|
307
|
+
flex: none;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.desk-toolbar .desk-grow {
|
|
311
|
+
flex: 1 1 140px;
|
|
312
|
+
min-width: 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.desk-toolbar-trailing {
|
|
316
|
+
margin-left: auto;
|
|
317
|
+
display: flex;
|
|
318
|
+
align-items: center;
|
|
319
|
+
gap: var(--desk-space-2);
|
|
320
|
+
}
|
package/src/react/Desktop.tsx
CHANGED
|
@@ -2,8 +2,10 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
|
|
|
2
2
|
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from 'react'
|
|
3
3
|
import { addCommandHandler, addDeskCommands, DeskCommands, STAGE_ATTRIBUTE, WINDOW_ATTRIBUTE, windowElement } from '../core/commands.js'
|
|
4
4
|
import { focusedId } from '../core/desk.js'
|
|
5
|
+
import type { Desk } from '../core/desk.js'
|
|
5
6
|
import type { DeskWindow, Frame, WindowId } from '../core/types.js'
|
|
6
7
|
import { arrangement } from './arrange.js'
|
|
8
|
+
import { InfoTip } from './infoTip.js'
|
|
7
9
|
import { useDesk, useDeskState, WindowContext } from './context.js'
|
|
8
10
|
import { WindowBoundary } from './windowBoundary.js'
|
|
9
11
|
import type { WindowFailed, WindowLoading } from './windowBoundary.js'
|
|
@@ -19,6 +21,12 @@ export interface DesktopProps {
|
|
|
19
21
|
* gives way to the actions and disappears before the title does when the window is narrow.
|
|
20
22
|
*/
|
|
21
23
|
readonly note?: (id: WindowId) => ReactNode
|
|
24
|
+
/**
|
|
25
|
+
* What this window is, behind an (i) at the end of its title bar — the same place in every window,
|
|
26
|
+
* so a person who wonders what they are looking at always knows where to ask. Explanation only: a
|
|
27
|
+
* warning, or anything that changes what someone is about to do, belongs in the window itself.
|
|
28
|
+
*/
|
|
29
|
+
readonly info?: (id: WindowId) => ReactNode
|
|
22
30
|
/** Window-wide controls on the trailing side of the title bar. One or two; more belongs in a toolbar. */
|
|
23
31
|
readonly actions?: (id: WindowId) => ReactNode
|
|
24
32
|
/** Shown when no window is open. */
|
|
@@ -76,7 +84,7 @@ const halfFrame = (stage: HTMLElement, side: 'start' | 'end'): Frame => {
|
|
|
76
84
|
return { x: side === 'start' ? area.x : area.x + width + area.gap, y: area.y, width, height: area.height }
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
export function Desktop({ renderWindow, title, note, actions, empty, loading, failed, layout = 'auto', className }: DesktopProps) {
|
|
87
|
+
export function Desktop({ renderWindow, title, note, info, actions, empty, loading, failed, layout = 'auto', className }: DesktopProps) {
|
|
80
88
|
const desk = useDesk()
|
|
81
89
|
const state = useDeskState()
|
|
82
90
|
const stage = useRef<HTMLDivElement>(null)
|
|
@@ -87,6 +95,51 @@ export function Desktop({ renderWindow, title, note, actions, empty, loading, fa
|
|
|
87
95
|
desk.setStage(() => ({ width: stage.current?.clientWidth ?? 1024, height: stage.current?.clientHeight ?? 768 }))
|
|
88
96
|
}, [desk])
|
|
89
97
|
|
|
98
|
+
/*
|
|
99
|
+
* The desk changing size is not a layout decision, but it can undo one: unplug an external
|
|
100
|
+
* display and the windows you spread across it are past the edge of the laptop screen, where
|
|
101
|
+
* nothing can drag them back. They are brought in; plug the display back in and they go out
|
|
102
|
+
* again exactly as they were.
|
|
103
|
+
*
|
|
104
|
+
* Filled windows need none of this — they are whatever size the desk is — so a desk of only
|
|
105
|
+
* filled windows never commits anything here.
|
|
106
|
+
*/
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const element = stage.current
|
|
109
|
+
if (!element || mode !== 'desktop') return undefined
|
|
110
|
+
let frame = 0
|
|
111
|
+
// Once per paint: dragging the browser window's edge fires this continuously.
|
|
112
|
+
const refit = () => {
|
|
113
|
+
cancelAnimationFrame(frame)
|
|
114
|
+
frame = requestAnimationFrame(() => {
|
|
115
|
+
const fit = desk.fitToStage()
|
|
116
|
+
/*
|
|
117
|
+
* One window that no longer fits is brought in and made smaller, and that is enough. Two or
|
|
118
|
+
* more means the layout itself is gone: each was clamped on its own, so windows that sat
|
|
119
|
+
* side by side now sit on top of each other, and the desk is left looking like one window.
|
|
120
|
+
* At that point it is laid out the way Arrange would — which is a layout for THIS screen,
|
|
121
|
+
* while the frames from the big one are remembered and come back with it.
|
|
122
|
+
*/
|
|
123
|
+
if (fit.squeezed.length > 1) arrangeNow(desk, element)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
// The stage can change size without the window doing so — a sidebar opening beside it — so the
|
|
127
|
+
// element is watched where that can be watched, and the window is the fallback where it cannot.
|
|
128
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
129
|
+
addEventListener('resize', refit)
|
|
130
|
+
return () => {
|
|
131
|
+
cancelAnimationFrame(frame)
|
|
132
|
+
removeEventListener('resize', refit)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const observer = new ResizeObserver(refit)
|
|
136
|
+
observer.observe(element)
|
|
137
|
+
return () => {
|
|
138
|
+
cancelAnimationFrame(frame)
|
|
139
|
+
observer.disconnect()
|
|
140
|
+
}
|
|
141
|
+
}, [desk, mode])
|
|
142
|
+
|
|
90
143
|
useEffect(() => (stage.current ? addDeskCommands(stage.current, desk) : undefined), [desk])
|
|
91
144
|
|
|
92
145
|
// Arrange lays every window out once, as free windows that can be moved again straight away.
|
|
@@ -96,26 +149,7 @@ export function Desktop({ renderWindow, title, note, actions, empty, loading, fa
|
|
|
96
149
|
return addCommandHandler(
|
|
97
150
|
element,
|
|
98
151
|
DeskCommands.arrange,
|
|
99
|
-
() =>
|
|
100
|
-
const { windows, stack } = desk.getState()
|
|
101
|
-
const [focused, previous] = [stack.at(-1), stack.at(-2)]
|
|
102
|
-
if (windows.length === 1) {
|
|
103
|
-
if (focused) desk.fill(focused)
|
|
104
|
-
return
|
|
105
|
-
}
|
|
106
|
-
const frames = arrangement(
|
|
107
|
-
windows.map(w => w.id),
|
|
108
|
-
focused ?? null,
|
|
109
|
-
previous ?? null,
|
|
110
|
-
arrangementArea(element),
|
|
111
|
-
{ minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT },
|
|
112
|
-
)
|
|
113
|
-
desk.placeAll(frames)
|
|
114
|
-
// Windows that cascade come in front of the half they cascade over; the focused window stays in front of all.
|
|
115
|
-
if (previous) desk.focus(previous)
|
|
116
|
-
windows.forEach(w => w.id !== focused && w.id !== previous && desk.focus(w.id))
|
|
117
|
-
if (focused) desk.focus(focused)
|
|
118
|
-
},
|
|
152
|
+
() => arrangeNow(desk, element),
|
|
119
153
|
{
|
|
120
154
|
enabled: () => {
|
|
121
155
|
const { windows } = desk.getState()
|
|
@@ -157,6 +191,7 @@ export function Desktop({ renderWindow, title, note, actions, empty, loading, fa
|
|
|
157
191
|
hidden={mode === 'fullscreen' && window.id !== focused}
|
|
158
192
|
title={title(window.id)}
|
|
159
193
|
note={note?.(window.id)}
|
|
194
|
+
info={info?.(window.id)}
|
|
160
195
|
actions={actions?.(window.id)}
|
|
161
196
|
>
|
|
162
197
|
{/* Memoised on the id and the render function: moving or focusing a window
|
|
@@ -192,11 +227,34 @@ interface WindowViewProps {
|
|
|
192
227
|
readonly focused: boolean
|
|
193
228
|
readonly title: ReactNode
|
|
194
229
|
readonly note?: ReactNode
|
|
230
|
+
readonly info?: ReactNode
|
|
195
231
|
readonly actions?: ReactNode
|
|
196
232
|
readonly children: ReactNode
|
|
197
233
|
}
|
|
198
234
|
|
|
199
235
|
/** Moving by the title bar, or resizing from the corner or from the left, right or bottom edge. */
|
|
236
|
+
/** Lays every open window out at once: what Window → Arrange does, and what a shrunken desk needs. */
|
|
237
|
+
function arrangeNow(desk: Desk, element: HTMLElement) {
|
|
238
|
+
const { windows, stack } = desk.getState()
|
|
239
|
+
const [focused, previous] = [stack.at(-1), stack.at(-2)]
|
|
240
|
+
if (windows.length === 1) {
|
|
241
|
+
if (focused) desk.fill(focused)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
const frames = arrangement(
|
|
245
|
+
windows.map(w => w.id),
|
|
246
|
+
focused ?? null,
|
|
247
|
+
previous ?? null,
|
|
248
|
+
arrangementArea(element),
|
|
249
|
+
{ minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT },
|
|
250
|
+
)
|
|
251
|
+
desk.placeAll(frames)
|
|
252
|
+
// Windows that cascade come in front of the half they cascade over; the focused window stays in front of all.
|
|
253
|
+
if (previous) desk.focus(previous)
|
|
254
|
+
windows.forEach(w => w.id !== focused && w.id !== previous && desk.focus(w.id))
|
|
255
|
+
if (focused) desk.focus(focused)
|
|
256
|
+
}
|
|
257
|
+
|
|
200
258
|
type Gesture = 'move' | 'resize' | 'left' | 'right' | 'bottom'
|
|
201
259
|
|
|
202
260
|
/** The frame a gesture makes of `origin` after the pointer has travelled dx, dy. */
|
|
@@ -218,7 +276,7 @@ function reshape(gesture: Gesture, origin: Frame, dx: number, dy: number): Frame
|
|
|
218
276
|
}
|
|
219
277
|
}
|
|
220
278
|
|
|
221
|
-
function WindowView({ window, layout, hidden, depth, focused, title, note, actions, children }: WindowViewProps) {
|
|
279
|
+
function WindowView({ window, layout, hidden, depth, focused, title, note, info, actions, children }: WindowViewProps) {
|
|
222
280
|
const desk = useDesk()
|
|
223
281
|
// While dragging, the frame lives here and commits once on release, so a drag
|
|
224
282
|
// re-renders one window rather than notifying every subscriber per pixel.
|
|
@@ -340,6 +398,15 @@ function WindowView({ window, layout, hidden, depth, focused, title, note, actio
|
|
|
340
398
|
{title}
|
|
341
399
|
</h2>
|
|
342
400
|
{note != null && note !== false && <p className="desk-window-note">{note}</p>}
|
|
401
|
+
{info != null && info !== false && (
|
|
402
|
+
<InfoTip
|
|
403
|
+
className="desk-window-info"
|
|
404
|
+
label={typeof title === 'string' ? `About ${title}` : 'About this window'}
|
|
405
|
+
align="end"
|
|
406
|
+
>
|
|
407
|
+
{info}
|
|
408
|
+
</InfoTip>
|
|
409
|
+
)}
|
|
343
410
|
{actions && <div className="desk-window-actions">{actions}</div>}
|
|
344
411
|
</header>
|
|
345
412
|
<div className="desk-body">{children}</div>
|