@liberation-data/desk 0.1.0 → 0.3.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 +126 -1
- package/dist/core/desk.d.ts.map +1 -1
- package/dist/core/desk.js +19 -2
- package/dist/core/desk.js.map +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/layouts.d.ts +26 -0
- package/dist/core/layouts.d.ts.map +1 -0
- package/dist/core/layouts.js +70 -0
- package/dist/core/layouts.js.map +1 -0
- package/dist/core/types.d.ts +3 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/desk.css +252 -1
- package/dist/react/Desktop.d.ts +7 -1
- package/dist/react/Desktop.d.ts.map +1 -1
- package/dist/react/Desktop.js +4 -4
- package/dist/react/Desktop.js.map +1 -1
- package/dist/react/Dock.d.ts +16 -1
- package/dist/react/Dock.d.ts.map +1 -1
- package/dist/react/Dock.js +44 -6
- package/dist/react/Dock.js.map +1 -1
- package/dist/react/MenuBar.d.ts +16 -0
- package/dist/react/MenuBar.d.ts.map +1 -1
- package/dist/react/MenuBar.js +36 -27
- package/dist/react/MenuBar.js.map +1 -1
- package/dist/react/context.d.ts +2 -0
- package/dist/react/context.d.ts.map +1 -1
- package/dist/react/context.js +2 -0
- package/dist/react/context.js.map +1 -1
- package/dist/react/contextMenu.d.ts +22 -0
- package/dist/react/contextMenu.d.ts.map +1 -0
- package/dist/react/contextMenu.js +146 -0
- package/dist/react/contextMenu.js.map +1 -0
- package/dist/react/iconView.d.ts +34 -0
- package/dist/react/iconView.d.ts.map +1 -0
- package/dist/react/iconView.js +112 -0
- package/dist/react/iconView.js.map +1 -0
- package/dist/react/index.d.ts +10 -2
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +5 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/infoTip.d.ts +11 -0
- package/dist/react/infoTip.d.ts.map +1 -0
- package/dist/react/infoTip.js +8 -0
- package/dist/react/infoTip.js.map +1 -0
- package/dist/react/pane.d.ts +28 -0
- package/dist/react/pane.d.ts.map +1 -0
- package/dist/react/pane.js +9 -0
- package/dist/react/pane.js.map +1 -0
- package/llms.txt +34 -4
- package/package.json +1 -1
- package/src/core/desk.ts +17 -2
- package/src/core/index.ts +2 -0
- package/src/core/layouts.ts +88 -0
- package/src/core/types.ts +4 -0
- package/src/desk.css +252 -1
- package/src/react/Desktop.tsx +11 -2
- package/src/react/Dock.tsx +74 -5
- package/src/react/MenuBar.tsx +43 -30
- package/src/react/context.tsx +3 -0
- package/src/react/contextMenu.tsx +198 -0
- package/src/react/iconView.tsx +206 -0
- package/src/react/index.ts +10 -2
- package/src/react/infoTip.tsx +48 -0
- package/src/react/pane.tsx +65 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
export interface InfoTipProps {
|
|
3
|
+
/** What it explains, e.g. "About these logs" — the button's name and the popover's. */
|
|
4
|
+
readonly label: string;
|
|
5
|
+
readonly children: ReactNode;
|
|
6
|
+
readonly placement?: 'below' | 'above';
|
|
7
|
+
readonly align?: 'start' | 'end';
|
|
8
|
+
readonly className?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function InfoTip({ label, children, placement, align, className }: InfoTipProps): import("react").JSX.Element;
|
|
11
|
+
//# sourceMappingURL=infoTip.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"infoTip.d.ts","sourceRoot":"","sources":["../../src/react/infoTip.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAYtC,MAAM,WAAW,YAAY;IAC3B,uFAAuF;IACvF,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,GAAG,OAAO,CAAA;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,CAAA;IAChC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED,wBAAgB,OAAO,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAmB,EAAE,KAAe,EAAE,SAAS,EAAE,EAAE,YAAY,+BAyBzG"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Popover } from './overlays.js';
|
|
4
|
+
export function InfoTip({ label, children, placement = 'below', align = 'start', className }) {
|
|
5
|
+
const [open, setOpen] = useState(false);
|
|
6
|
+
return (_jsx(Popover, { open: open, onOpenChange: setOpen, label: label, placement: placement, align: align, trigger: props => (_jsx("button", { ...props, ref: props.ref, type: "button", className: ['desk-infotip', className].filter(Boolean).join(' '), "aria-label": label, "data-open": open || undefined, children: _jsx("span", { "aria-hidden": "true", children: "i" }) })), children: _jsx("div", { className: "desk-infotip-body", children: children }) }));
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=infoTip.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"infoTip.js","sourceRoot":"","sources":["../../src/react/infoTip.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAEhC,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AAoBvC,MAAM,UAAU,OAAO,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,GAAG,OAAO,EAAE,KAAK,GAAG,OAAO,EAAE,SAAS,EAAgB;IACxG,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IACvC,OAAO,CACL,KAAC,OAAO,IACN,IAAI,EAAE,IAAI,EACV,YAAY,EAAE,OAAO,EACrB,KAAK,EAAE,KAAK,EACZ,SAAS,EAAE,SAAS,EACpB,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,KAAK,CAAC,EAAE,CAAC,CAChB,oBACM,KAAK,EACT,GAAG,EAAE,KAAK,CAAC,GAAG,EACd,IAAI,EAAC,QAAQ,EACb,SAAS,EAAE,CAAC,cAAc,EAAE,SAAS,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,gBACpD,KAAK,eACN,IAAI,IAAI,SAAS,YAE5B,8BAAkB,MAAM,kBAAS,GAC1B,CACV,YAED,cAAK,SAAS,EAAC,mBAAmB,YAAE,QAAQ,GAAO,GAC3C,CACX,CAAA;AACH,CAAC"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Ref, UIEvent, ReactNode } from 'react';
|
|
2
|
+
export interface ToolbarProps {
|
|
3
|
+
/** Controls, in reading order. Anything given `className="desk-grow"` takes the spare room. */
|
|
4
|
+
readonly children: ReactNode;
|
|
5
|
+
/** Pushed to the trailing end: what acts on everything here, or the state of it. */
|
|
6
|
+
readonly trailing?: ReactNode;
|
|
7
|
+
/** Names the row for assistive technology when it holds more than one thing: "Log controls". */
|
|
8
|
+
readonly label?: string;
|
|
9
|
+
readonly className?: string;
|
|
10
|
+
}
|
|
11
|
+
/** One aligned row of controls. Every item sits on the same centre line, whatever its height. */
|
|
12
|
+
export declare function Toolbar({ children, trailing, label, className }: ToolbarProps): import("react").JSX.Element;
|
|
13
|
+
export interface PaneProps {
|
|
14
|
+
/** Stays put at the top: a Toolbar, usually. */
|
|
15
|
+
readonly header?: ReactNode;
|
|
16
|
+
/** Stays put at the bottom: a count, a status line, the buttons of a form. */
|
|
17
|
+
readonly footer?: ReactNode;
|
|
18
|
+
/** Fills what is left and scrolls, on its own. */
|
|
19
|
+
readonly children: ReactNode;
|
|
20
|
+
/** The scrolling part is the thing being read, so it can be named and reached by the keyboard. */
|
|
21
|
+
readonly label?: string;
|
|
22
|
+
/** The scrolling element itself: for reading where it sits, or sending it to the bottom. */
|
|
23
|
+
readonly bodyRef?: Ref<HTMLDivElement>;
|
|
24
|
+
readonly onScroll?: (event: UIEvent<HTMLDivElement>) => void;
|
|
25
|
+
readonly className?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function Pane({ header, footer, children, label, bodyRef, onScroll, className }: PaneProps): import("react").JSX.Element;
|
|
28
|
+
//# sourceMappingURL=pane.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pane.d.ts","sourceRoot":"","sources":["../../src/react/pane.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAWpD,MAAM,WAAW,YAAY;IAC3B,+FAA+F;IAC/F,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAA;IAC5B,oFAAoF;IACpF,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAA;IAC7B,gGAAgG;IAChG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED,iGAAiG;AACjG,wBAAgB,OAAO,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,YAAY,+BAU7E;AAED,MAAM,WAAW,SAAS;IACxB,gDAAgD;IAChD,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAA;IAC3B,8EAA8E;IAC9E,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAA;IAC3B,kDAAkD;IAClD,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAA;IAC5B,kGAAkG;IAClG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,4FAA4F;IAC5F,QAAQ,CAAC,OAAO,CAAC,EAAE,GAAG,CAAC,cAAc,CAAC,CAAA;IACtC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,cAAc,CAAC,KAAK,IAAI,CAAA;IAC5D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED,wBAAgB,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,EAAE,SAAS,+BAehG"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/** One aligned row of controls. Every item sits on the same centre line, whatever its height. */
|
|
3
|
+
export function Toolbar({ children, trailing, label, className }) {
|
|
4
|
+
return (_jsxs("div", { ...(label ? { role: 'group', 'aria-label': label } : {}), className: ['desk-toolbar', className].filter(Boolean).join(' '), children: [children, trailing != null && _jsx("div", { className: "desk-toolbar-trailing", children: trailing })] }));
|
|
5
|
+
}
|
|
6
|
+
export function Pane({ header, footer, children, label, bodyRef, onScroll, className }) {
|
|
7
|
+
return (_jsxs("div", { className: ['desk-pane', className].filter(Boolean).join(' '), children: [header != null && _jsx("div", { className: "desk-pane-header", children: header }), _jsx("div", { ref: bodyRef, className: "desk-pane-body", onScroll: onScroll, ...(label ? { role: 'region', 'aria-label': label, tabIndex: 0 } : {}), children: children }), footer != null && _jsx("div", { className: "desk-pane-footer", children: footer })] }));
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=pane.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pane.js","sourceRoot":"","sources":["../../src/react/pane.tsx"],"names":[],"mappings":";AAqBA,iGAAiG;AACjG,MAAM,UAAU,OAAO,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,SAAS,EAAgB;IAC5E,OAAO,CACL,kBACM,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,YAAY,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EACzD,SAAS,EAAE,CAAC,cAAc,EAAE,SAAS,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,aAE/D,QAAQ,EACR,QAAQ,IAAI,IAAI,IAAI,cAAK,SAAS,EAAC,uBAAuB,YAAE,QAAQ,GAAO,IACxE,CACP,CAAA;AACH,CAAC;AAiBD,MAAM,UAAU,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAa;IAC/F,OAAO,CACL,eAAK,SAAS,EAAE,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,aAC/D,MAAM,IAAI,IAAI,IAAI,cAAK,SAAS,EAAC,kBAAkB,YAAE,MAAM,GAAO,EACnE,cACE,GAAG,EAAE,OAAO,EACZ,SAAS,EAAC,gBAAgB,EAC1B,QAAQ,EAAE,QAAQ,KACd,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,YAAY,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,YAEtE,QAAQ,GACL,EACL,MAAM,IAAI,IAAI,IAAI,cAAK,SAAS,EAAC,kBAAkB,YAAE,MAAM,GAAO,IAC/D,CACP,CAAA;AACH,CAAC"}
|
package/llms.txt
CHANGED
|
@@ -46,7 +46,12 @@ Rules that are easy to get wrong:
|
|
|
46
46
|
|
|
47
47
|
```ts
|
|
48
48
|
createDesk(options?: DeskOptions): Desk
|
|
49
|
-
interface DeskOptions { cascade?: Partial<CascadeOptions>; stage?: () => Size; initial?: DeskState }
|
|
49
|
+
interface DeskOptions { cascade?: Partial<CascadeOptions>; stage?: () => Size; initial?: DeskState; layouts?: LayoutStore }
|
|
50
|
+
// layouts: remembers each window type's last mode/frame (saved by float/fill/zoom, not placeAll or open frames);
|
|
51
|
+
// open() without mode/frame restores it, fitted to the stage (fills if too small); more instances step +x,+y.
|
|
52
|
+
localLayoutStore(key): LayoutStore // localStorage 'desk.layouts.<key>'
|
|
53
|
+
interface LayoutStore { load(type): SavedLayout | undefined; save(type, layout: SavedLayout): void }
|
|
54
|
+
fitFrame(frame, stage, min?): Frame | null; stepFrom(frame, open: Frame[], stage, step, margin): Frame
|
|
50
55
|
|
|
51
56
|
interface Desk {
|
|
52
57
|
getState(): DeskState
|
|
@@ -177,6 +182,7 @@ useWindowId(): string | null // inside a window's content
|
|
|
177
182
|
<Desktop
|
|
178
183
|
renderWindow={(id) => ReactNode} // memoised: chrome re-renders, content does not
|
|
179
184
|
title={(id) => ReactNode}
|
|
185
|
+
note?={(id) => ReactNode} // quiet text in the title bar: what this window is looking at
|
|
180
186
|
actions?={(id) => ReactNode} // window-wide controls in the title bar
|
|
181
187
|
loading?={(id) => ReactNode} // inside a window while its code loads
|
|
182
188
|
failed?={(id, error, reload) => ReactNode} // inside a window that could not open
|
|
@@ -196,17 +202,32 @@ Every window has its own Suspense and error boundary: one window loading or thro
|
|
|
196
202
|
the menu bar or the dock. A failure is logged with `console.error` and shows Reload, which remounts the
|
|
197
203
|
window; `lazyWindow` retries its import on reload, unlike `React.lazy`, which caches a failed import.
|
|
198
204
|
|
|
205
|
+
### Inside a window
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
<Pane label?={string} header?={ReactNode} footer?={ReactNode} bodyRef?={Ref} onScroll?={fn}>{scrolling content}</Pane>
|
|
209
|
+
<Toolbar label?={string} trailing?={ReactNode}>{controls}</Toolbar> // className="desk-grow" takes spare room
|
|
210
|
+
<InfoTip label={string} placement?={'below'|'above'} align?={'start'|'end'}>{explanation}</InfoTip>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
A pane fills its window and scrolls only its middle, so the window never scrolls and the toolbar stays put.
|
|
214
|
+
A named pane is a focusable region, so it scrolls from the keyboard. InfoTip is an (i) popover for
|
|
215
|
+
explanation only — never a warning, which belongs in front of people.
|
|
216
|
+
|
|
199
217
|
### Dock
|
|
200
218
|
|
|
201
219
|
```tsx
|
|
202
|
-
<Dock entries={DockEntry[]} label?="Dock" placement?={'overlay' | 'inline'}
|
|
203
|
-
|
|
220
|
+
<Dock entries={DockEntry[]} label?="Dock" placement?={'overlay' | 'inline'}
|
|
221
|
+
pins?={{ accepts, onPin: (drag, before: id | null) => void, onMove?: (id, before: id | null) => void }} />
|
|
222
|
+
dockItem({ id, label, icon, window?, description?, badge?, onSelect?, disabled?, contextMenu?: () => MenuItem[], movable? })
|
|
204
223
|
dockStack({ id, label, items: DockItem[], icon? })
|
|
205
224
|
dockSeparator(id)
|
|
206
225
|
```
|
|
207
226
|
|
|
208
227
|
An item opens `window ?? id` and shows a dot while that window is open, accent when it is key. A stack fans
|
|
209
228
|
out above the dock. One tab stop; arrow keys along it; Escape folds a stack away.
|
|
229
|
+
Pins: the app stores kept items and passes them back as entries. A drop on the dock keeps at the end; on a
|
|
230
|
+
`movable` item, in front of it. Movable items drag along the dock (type DOCK_ITEM, payload id).
|
|
210
231
|
|
|
211
232
|
### Menu bar
|
|
212
233
|
|
|
@@ -218,6 +239,15 @@ menuCommand(label, command, { shortcut?, checked?, detail?, args? })
|
|
|
218
239
|
menuAction(label, onSelect, { disabled?, shortcut?, checked?, detail? })
|
|
219
240
|
menuSeparator(); menuHeader(label)
|
|
220
241
|
windowMenuItems(state, focus, title): MenuItem[]
|
|
242
|
+
|
|
243
|
+
const { target, menu, open } = useContextMenu({ label, items: () => MenuItem[], disabled? })
|
|
244
|
+
// spread `target` (onContextMenu, onKeyDown) on the element; render `menu` anywhere (portal).
|
|
245
|
+
// Right-click opens at the pointer; Menu key / Shift+F10 at the element. Commands run from that element.
|
|
246
|
+
|
|
247
|
+
<IconView label items={{id, label, icon, subtitle?, badge?, disabled?}[]} onOpen={item => …}
|
|
248
|
+
openOn?="double"|"single" selected? onSelectionChange? contextMenu?={item => MenuItem[]}
|
|
249
|
+
drag?={{ type, payload: item => unknown }} empty? className? />
|
|
250
|
+
// click selects, double-click / Return opens (single: click opens); arrows, Home/End, type-ahead. Needs a DeskProvider.
|
|
221
251
|
```
|
|
222
252
|
|
|
223
253
|
Command items are enabled via `canPerform` as the menu opens, and menus bind their own shortcuts. Opening a
|
|
@@ -406,5 +436,5 @@ in the accessibility tree.
|
|
|
406
436
|
- A `DeskProvider` already carries an event bus; `BusProvider` is only for supplying or sharing one.
|
|
407
437
|
- `renderWindow` must be stable (module scope or `useCallback`) for the memoisation to hold.
|
|
408
438
|
- `parse`/`syncWithLocation` warn once when no `isKnown` is given: unknown ids from a URL are then trusted.
|
|
409
|
-
- `npm run size` enforces a gzipped budget: core
|
|
439
|
+
- `npm run size` enforces a gzipped budget: core 11 KiB, react 46 KiB, css 10 KiB.
|
|
410
440
|
- The package is ESM only, with `"sideEffects": ["**/*.css"]`.
|
package/package.json
CHANGED
package/src/core/desk.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
Size,
|
|
9
9
|
WindowId,
|
|
10
10
|
} from './types.js'
|
|
11
|
+
import { fitFrame, stepFrom } from './layouts.js'
|
|
11
12
|
|
|
12
13
|
export interface Desk {
|
|
13
14
|
getState(): DeskState
|
|
@@ -106,6 +107,7 @@ export function createDesk(options: DeskOptions = {}): Desk {
|
|
|
106
107
|
let stage = options.stage ?? (() => DEFAULT_STAGE)
|
|
107
108
|
let state = normalise(options.initial ?? EMPTY)
|
|
108
109
|
const listeners = new Set<(state: DeskState) => void>()
|
|
110
|
+
const layouts = options.layouts
|
|
109
111
|
|
|
110
112
|
const commit = (next: DeskState) => {
|
|
111
113
|
if (next === state) return
|
|
@@ -142,6 +144,7 @@ export function createDesk(options: DeskOptions = {}): Desk {
|
|
|
142
144
|
if (window.mode === 'floating' && !frame) return
|
|
143
145
|
const next = frame ?? fits(remembered.get(id)) ?? nextFrame(state)
|
|
144
146
|
if (frame) remembered.set(id, frame)
|
|
147
|
+
layouts?.save(windowType(id), { mode: 'floating', frame: next })
|
|
145
148
|
commit(toFront(replace(state, { id, mode: 'floating', frame: next }), id))
|
|
146
149
|
}
|
|
147
150
|
|
|
@@ -149,6 +152,7 @@ export function createDesk(options: DeskOptions = {}): Desk {
|
|
|
149
152
|
const window = find(id)
|
|
150
153
|
if (!window || window.mode === 'filled') return
|
|
151
154
|
remembered.set(id, window.frame)
|
|
155
|
+
layouts?.save(windowType(id), { mode: 'filled' })
|
|
152
156
|
commit(toFront(replace(state, { id, mode: 'filled' }), id))
|
|
153
157
|
}
|
|
154
158
|
|
|
@@ -167,8 +171,19 @@ export function createDesk(options: DeskOptions = {}): Desk {
|
|
|
167
171
|
commit(toFront(state, id))
|
|
168
172
|
return
|
|
169
173
|
}
|
|
170
|
-
const
|
|
171
|
-
const
|
|
174
|
+
const saved = opts.mode || opts.frame ? undefined : layouts?.load(windowType(id))
|
|
175
|
+
const kept = saved?.mode === 'floating' ? fitFrame(saved.frame, stage()) : null
|
|
176
|
+
const mode = opts.mode ?? (opts.frame || kept ? 'floating' : 'filled')
|
|
177
|
+
const frame = () => {
|
|
178
|
+
if (opts.frame) return opts.frame
|
|
179
|
+
if (!kept) return nextFrame(state)
|
|
180
|
+
const siblings = state.stack.flatMap(other => {
|
|
181
|
+
const w = find(other)
|
|
182
|
+
return w?.mode === 'floating' && windowType(w.id) === windowType(id) ? [w.frame] : []
|
|
183
|
+
})
|
|
184
|
+
return stepFrom(kept, siblings, stage(), cascade.step, cascade.margin)
|
|
185
|
+
}
|
|
186
|
+
const window: DeskWindow = mode === 'filled' ? { id, mode } : { id, mode, frame: frame() }
|
|
172
187
|
commit({ windows: [...state.windows, window], stack: [...state.stack, id] })
|
|
173
188
|
},
|
|
174
189
|
|
package/src/core/index.ts
CHANGED
|
@@ -29,6 +29,8 @@ export { bindShortcuts, formatShortcut, isApplePlatform, isInstalledApp, matches
|
|
|
29
29
|
export type { Keymap, Shortcut, ShortcutOptions } from './shortcuts.js'
|
|
30
30
|
export { parse, serialize, syncWithLocation } from './location.js'
|
|
31
31
|
export { isTitleCase, titleCase } from './titles.js'
|
|
32
|
+
export { fitFrame, localLayoutStore, stepFrom } from './layouts.js'
|
|
33
|
+
export type { LayoutStore, SavedLayout } from './layouts.js'
|
|
32
34
|
export type { LocationEnv, LocationOptions } from './location.js'
|
|
33
35
|
export type {
|
|
34
36
|
CascadeOptions,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Frame, Size } from './types.js'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Windows that come back the way they were left. Close a window you had made small and put in a
|
|
5
|
+
* corner, open it tomorrow, and it is small and in the corner again; one you zoomed to fill the desk
|
|
6
|
+
* fills it again.
|
|
7
|
+
*
|
|
8
|
+
* What is remembered is per kind of window, not per id: `query#2` opens where `query` was last left.
|
|
9
|
+
* A remembered frame is a promise about a screen that may have changed, so it is fitted to the
|
|
10
|
+
* current one: moved in until it is on the desk, made smaller if the desk is now smaller, and given
|
|
11
|
+
* up for filling if it would be too small to use. A second window of a kind already open steps down
|
|
12
|
+
* and to the right of it, as a cascade does, rather than opening exactly on top and hiding it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type SavedLayout = { readonly mode: 'filled' } | { readonly mode: 'floating'; readonly frame: Frame }
|
|
16
|
+
|
|
17
|
+
/** Where layouts are kept. Synchronous: a window opens now, not after a round trip. */
|
|
18
|
+
export interface LayoutStore {
|
|
19
|
+
load(type: string): SavedLayout | undefined
|
|
20
|
+
save(type: string, layout: SavedLayout): void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MIN: Size = { width: 240, height: 160 }
|
|
24
|
+
|
|
25
|
+
/** Keeps layouts in this browser, under `desk.layouts.<key>`. */
|
|
26
|
+
export function localLayoutStore(key: string): LayoutStore {
|
|
27
|
+
const name = `desk.layouts.${key}`
|
|
28
|
+
const read = (): Record<string, SavedLayout> => {
|
|
29
|
+
try {
|
|
30
|
+
const raw = globalThis.localStorage?.getItem(name)
|
|
31
|
+
const parsed: unknown = raw ? JSON.parse(raw) : {}
|
|
32
|
+
return parsed && typeof parsed === 'object' ? (parsed as Record<string, SavedLayout>) : {}
|
|
33
|
+
} catch {
|
|
34
|
+
return {}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
load: type => valid(read()[type]),
|
|
39
|
+
save(type, layout) {
|
|
40
|
+
try {
|
|
41
|
+
globalThis.localStorage?.setItem(name, JSON.stringify({ ...read(), [type]: layout }))
|
|
42
|
+
} catch {
|
|
43
|
+
// Private windows and full storage: windows still open, just not where they were.
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const finite = (...values: unknown[]) => values.every(v => typeof v === 'number' && Number.isFinite(v))
|
|
50
|
+
|
|
51
|
+
/** Storage is outside the program: anything that is not a layout is treated as none. */
|
|
52
|
+
function valid(layout: unknown): SavedLayout | undefined {
|
|
53
|
+
const l = layout as SavedLayout | undefined
|
|
54
|
+
if (l?.mode === 'filled') return l
|
|
55
|
+
if (l?.mode === 'floating' && l.frame && finite(l.frame.x, l.frame.y, l.frame.width, l.frame.height)) return l
|
|
56
|
+
return undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A remembered frame on today's desk: on screen, no bigger than the desk, or null if too small to use. */
|
|
60
|
+
export function fitFrame(frame: Frame, stage: Size, min: Size = MIN): Frame | null {
|
|
61
|
+
const width = Math.min(frame.width, stage.width)
|
|
62
|
+
const height = Math.min(frame.height, stage.height)
|
|
63
|
+
if (width < min.width || height < min.height) return null
|
|
64
|
+
return {
|
|
65
|
+
x: Math.max(0, Math.min(frame.x, stage.width - width)),
|
|
66
|
+
y: Math.max(0, Math.min(frame.y, stage.height - height)),
|
|
67
|
+
width,
|
|
68
|
+
height,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Where another window of a kind goes when some are already free on the desk: a step down and right
|
|
74
|
+
* of the one in front, back to the top-left when that would run off the desk, and never exactly on
|
|
75
|
+
* top of one already there.
|
|
76
|
+
*/
|
|
77
|
+
export function stepFrom(frame: Frame, open: readonly Frame[], stage: Size, step: { x: number; y: number }, margin: number): Frame {
|
|
78
|
+
const front = open.at(-1)
|
|
79
|
+
if (!front) return frame
|
|
80
|
+
const taken = (f: Frame) => open.some(o => o.x === f.x && o.y === f.y)
|
|
81
|
+
let next = { ...frame, x: front.x + step.x, y: front.y + step.y }
|
|
82
|
+
for (let tries = 0; tries <= open.length; tries++) {
|
|
83
|
+
if (next.x + next.width > stage.width || next.y + next.height > stage.height) next = { ...next, x: margin, y: margin }
|
|
84
|
+
if (!taken(next)) return next
|
|
85
|
+
next = { ...next, x: next.x + step.x, y: next.y + step.y }
|
|
86
|
+
}
|
|
87
|
+
return next
|
|
88
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { LayoutStore } from './layouts.js'
|
|
2
|
+
|
|
1
3
|
export type WindowId = string
|
|
2
4
|
|
|
3
5
|
export interface Frame {
|
|
@@ -40,6 +42,8 @@ export interface DeskOptions {
|
|
|
40
42
|
/** Reports the stage's size, so floating windows open inside it. */
|
|
41
43
|
readonly stage?: () => Size
|
|
42
44
|
readonly initial?: DeskState
|
|
45
|
+
/** Remembers how each kind of window was left, and opens it that way again. */
|
|
46
|
+
readonly layouts?: LayoutStore
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
export interface OpenOptions {
|
package/src/desk.css
CHANGED
|
@@ -216,6 +216,23 @@
|
|
|
216
216
|
color: var(--desk-muted);
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
.desk-window-note {
|
|
220
|
+
margin: 0 0 0 auto;
|
|
221
|
+
min-width: 0;
|
|
222
|
+
/* The title is what the window IS; the note is what it is looking at. In a narrow window the note
|
|
223
|
+
gives up its room first, and is cut before the title is. */
|
|
224
|
+
flex: 0 999 auto;
|
|
225
|
+
overflow: hidden;
|
|
226
|
+
text-overflow: ellipsis;
|
|
227
|
+
white-space: nowrap;
|
|
228
|
+
font-size: 11.5px;
|
|
229
|
+
color: var(--desk-muted);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.desk-window-note + .desk-window-actions {
|
|
233
|
+
margin-left: var(--desk-space-1);
|
|
234
|
+
}
|
|
235
|
+
|
|
219
236
|
.desk-window-actions {
|
|
220
237
|
margin-left: auto;
|
|
221
238
|
display: flex;
|
|
@@ -372,7 +389,8 @@
|
|
|
372
389
|
transition: transform .18s var(--desk-ease), margin .18s var(--desk-ease);
|
|
373
390
|
}
|
|
374
391
|
|
|
375
|
-
.desk-dock-item:disabled
|
|
392
|
+
.desk-dock-item:disabled,
|
|
393
|
+
.desk-dock-item[aria-disabled="true"] {
|
|
376
394
|
opacity: .45;
|
|
377
395
|
cursor: not-allowed;
|
|
378
396
|
}
|
|
@@ -658,6 +676,13 @@
|
|
|
658
676
|
height: 15px;
|
|
659
677
|
}
|
|
660
678
|
|
|
679
|
+
/* A context menu is the same menu, opened where the pointer is rather than under a title. */
|
|
680
|
+
.desk-context-menu {
|
|
681
|
+
position: fixed;
|
|
682
|
+
z-index: 1300;
|
|
683
|
+
outline: none;
|
|
684
|
+
}
|
|
685
|
+
|
|
661
686
|
.desk-menubar-slot[data-align="end"] .desk-menu {
|
|
662
687
|
left: auto;
|
|
663
688
|
right: 0;
|
|
@@ -2548,3 +2573,229 @@
|
|
|
2548
2573
|
border: 0;
|
|
2549
2574
|
background: var(--desk-surface);
|
|
2550
2575
|
}
|
|
2576
|
+
|
|
2577
|
+
/* Icon view: a Finder's icons, in rows that follow the window's width. */
|
|
2578
|
+
.desk-icon-view {
|
|
2579
|
+
list-style: none;
|
|
2580
|
+
margin: 0;
|
|
2581
|
+
padding: 12px;
|
|
2582
|
+
display: grid;
|
|
2583
|
+
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
|
|
2584
|
+
align-content: start;
|
|
2585
|
+
gap: 8px;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
.desk-icon-cell {
|
|
2589
|
+
display: grid;
|
|
2590
|
+
justify-items: center;
|
|
2591
|
+
align-content: start;
|
|
2592
|
+
gap: 4px;
|
|
2593
|
+
padding: 10px 6px;
|
|
2594
|
+
border-radius: 10px;
|
|
2595
|
+
text-align: center;
|
|
2596
|
+
cursor: default;
|
|
2597
|
+
user-select: none;
|
|
2598
|
+
outline: none;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
.desk-icon-cell[aria-disabled="true"] {
|
|
2602
|
+
opacity: .45;
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
.desk-icon-cell:focus-visible {
|
|
2606
|
+
box-shadow: 0 0 0 2px var(--desk-accent);
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
.desk-icon-glyph {
|
|
2610
|
+
position: relative;
|
|
2611
|
+
display: grid;
|
|
2612
|
+
place-items: center;
|
|
2613
|
+
width: 56px;
|
|
2614
|
+
height: 56px;
|
|
2615
|
+
border-radius: 14px;
|
|
2616
|
+
border: 1px solid var(--desk-rule);
|
|
2617
|
+
background: var(--desk-dock-icon);
|
|
2618
|
+
color: var(--desk-ink);
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
.desk-icon-glyph > svg,
|
|
2622
|
+
.desk-icon-glyph > img {
|
|
2623
|
+
width: 55%;
|
|
2624
|
+
height: 55%;
|
|
2625
|
+
object-fit: contain;
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
.desk-icon-badge {
|
|
2629
|
+
position: absolute;
|
|
2630
|
+
top: -5px;
|
|
2631
|
+
right: -5px;
|
|
2632
|
+
min-width: 18px;
|
|
2633
|
+
padding: 0 5px;
|
|
2634
|
+
border-radius: 9px;
|
|
2635
|
+
background: var(--desk-accent);
|
|
2636
|
+
color: #fff;
|
|
2637
|
+
font-size: 11px;
|
|
2638
|
+
line-height: 18px;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
.desk-icon-label {
|
|
2642
|
+
max-width: 100%;
|
|
2643
|
+
padding: 1px 6px;
|
|
2644
|
+
border-radius: 5px;
|
|
2645
|
+
font-size: 12.5px;
|
|
2646
|
+
line-height: 1.3;
|
|
2647
|
+
overflow-wrap: anywhere;
|
|
2648
|
+
display: -webkit-box;
|
|
2649
|
+
-webkit-line-clamp: 2;
|
|
2650
|
+
-webkit-box-orient: vertical;
|
|
2651
|
+
overflow: hidden;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
.desk-icon-subtitle {
|
|
2655
|
+
max-width: 100%;
|
|
2656
|
+
font-size: 11px;
|
|
2657
|
+
color: var(--desk-muted);
|
|
2658
|
+
overflow: hidden;
|
|
2659
|
+
text-overflow: ellipsis;
|
|
2660
|
+
white-space: nowrap;
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
.desk-icon-cell[aria-selected="true"] .desk-icon-glyph {
|
|
2664
|
+
background: color-mix(in srgb, var(--desk-accent) 16%, transparent);
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
.desk-icon-cell[aria-selected="true"] .desk-icon-label {
|
|
2668
|
+
background: var(--desk-accent);
|
|
2669
|
+
color: #fff;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
.desk-icon-view-empty {
|
|
2673
|
+
display: grid;
|
|
2674
|
+
place-items: center;
|
|
2675
|
+
padding: 32px 16px;
|
|
2676
|
+
color: var(--desk-muted);
|
|
2677
|
+
text-align: center;
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
.desk-icon-drag {
|
|
2681
|
+
display: grid;
|
|
2682
|
+
place-items: center;
|
|
2683
|
+
width: 40px;
|
|
2684
|
+
height: 40px;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
/* Something carried to the dock: the dock lights up, and a kept item shows where it would go in front. */
|
|
2688
|
+
.desk-dock-item[data-desk-drop][data-ready] {
|
|
2689
|
+
outline: none;
|
|
2690
|
+
background: var(--desk-dock-icon);
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
.desk-dock-item[data-desk-drop][data-over] {
|
|
2694
|
+
box-shadow: -6px 0 0 -2px var(--desk-accent);
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
/* ── The inside of a window: controls that stay, content that scrolls ── */
|
|
2698
|
+
|
|
2699
|
+
.desk-pane {
|
|
2700
|
+
display: flex;
|
|
2701
|
+
flex-direction: column;
|
|
2702
|
+
height: 100%;
|
|
2703
|
+
min-height: 0;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
.desk-pane-header,
|
|
2707
|
+
.desk-pane-footer {
|
|
2708
|
+
flex: none;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
/* The only part that scrolls, so the toolbar never leaves with the content. */
|
|
2712
|
+
.desk-pane-body {
|
|
2713
|
+
flex: 1;
|
|
2714
|
+
min-height: 0;
|
|
2715
|
+
overflow: auto;
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
.desk-pane-body:focus-visible {
|
|
2719
|
+
outline: 2px solid var(--desk-accent);
|
|
2720
|
+
outline-offset: -2px;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
/* A window whose whole content is a pane does not scroll itself: the pane's middle does. */
|
|
2724
|
+
.desk-body:has(> .desk-pane) {
|
|
2725
|
+
overflow: hidden;
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
.desk-toolbar {
|
|
2729
|
+
display: flex;
|
|
2730
|
+
align-items: center;
|
|
2731
|
+
flex-wrap: wrap;
|
|
2732
|
+
gap: var(--desk-space-2);
|
|
2733
|
+
/* One centre line for everything in the row, whatever each control's own height is. */
|
|
2734
|
+
min-height: calc(var(--desk-control-height) + var(--desk-space-2) * 2);
|
|
2735
|
+
padding: var(--desk-space-2) var(--desk-space-3);
|
|
2736
|
+
border-bottom: 1px solid var(--desk-rule);
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
.desk-toolbar > *,
|
|
2740
|
+
.desk-toolbar-trailing > * {
|
|
2741
|
+
flex: none;
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
.desk-toolbar .desk-grow {
|
|
2745
|
+
flex: 1 1 140px;
|
|
2746
|
+
min-width: 0;
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
.desk-toolbar-trailing {
|
|
2750
|
+
margin-left: auto;
|
|
2751
|
+
display: flex;
|
|
2752
|
+
align-items: center;
|
|
2753
|
+
gap: var(--desk-space-2);
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
/* ── An (i): what a view would explain if asked ── */
|
|
2757
|
+
|
|
2758
|
+
.desk-infotip {
|
|
2759
|
+
flex: none;
|
|
2760
|
+
display: inline-grid;
|
|
2761
|
+
place-items: center;
|
|
2762
|
+
width: 18px;
|
|
2763
|
+
height: 18px;
|
|
2764
|
+
padding: 0;
|
|
2765
|
+
border: 1px solid var(--desk-rule);
|
|
2766
|
+
border-radius: 50%;
|
|
2767
|
+
background: none;
|
|
2768
|
+
color: var(--desk-muted);
|
|
2769
|
+
font-family: var(--desk-font);
|
|
2770
|
+
font-size: 11px;
|
|
2771
|
+
font-style: italic;
|
|
2772
|
+
font-weight: 700;
|
|
2773
|
+
line-height: 1;
|
|
2774
|
+
cursor: pointer;
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
.desk-infotip:hover,
|
|
2778
|
+
.desk-infotip[data-open] {
|
|
2779
|
+
border-color: var(--desk-accent);
|
|
2780
|
+
color: var(--desk-accent);
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
.desk-infotip:focus-visible {
|
|
2784
|
+
outline: 2px solid var(--desk-accent);
|
|
2785
|
+
outline-offset: 2px;
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
.desk-infotip-body {
|
|
2789
|
+
max-width: 34ch;
|
|
2790
|
+
font-size: 12.5px;
|
|
2791
|
+
line-height: 1.45;
|
|
2792
|
+
color: var(--desk-muted);
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
.desk-infotip-body > :first-child {
|
|
2796
|
+
margin-top: 0;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
.desk-infotip-body > :last-child {
|
|
2800
|
+
margin-bottom: 0;
|
|
2801
|
+
}
|
package/src/react/Desktop.tsx
CHANGED
|
@@ -13,6 +13,12 @@ export type DeskLayout = 'desktop' | 'fullscreen'
|
|
|
13
13
|
export interface DesktopProps {
|
|
14
14
|
readonly renderWindow: (id: WindowId) => ReactNode
|
|
15
15
|
readonly title: (id: WindowId) => ReactNode
|
|
16
|
+
/**
|
|
17
|
+
* A quiet line on the trailing side of the title bar, saying what this window is looking at: the
|
|
18
|
+
* request behind it, the file it is editing, how many rows it found. Text, not a control — it
|
|
19
|
+
* gives way to the actions and disappears before the title does when the window is narrow.
|
|
20
|
+
*/
|
|
21
|
+
readonly note?: (id: WindowId) => ReactNode
|
|
16
22
|
/** Window-wide controls on the trailing side of the title bar. One or two; more belongs in a toolbar. */
|
|
17
23
|
readonly actions?: (id: WindowId) => ReactNode
|
|
18
24
|
/** Shown when no window is open. */
|
|
@@ -70,7 +76,7 @@ const halfFrame = (stage: HTMLElement, side: 'start' | 'end'): Frame => {
|
|
|
70
76
|
return { x: side === 'start' ? area.x : area.x + width + area.gap, y: area.y, width, height: area.height }
|
|
71
77
|
}
|
|
72
78
|
|
|
73
|
-
export function Desktop({ renderWindow, title, actions, empty, loading, failed, layout = 'auto', className }: DesktopProps) {
|
|
79
|
+
export function Desktop({ renderWindow, title, note, actions, empty, loading, failed, layout = 'auto', className }: DesktopProps) {
|
|
74
80
|
const desk = useDesk()
|
|
75
81
|
const state = useDeskState()
|
|
76
82
|
const stage = useRef<HTMLDivElement>(null)
|
|
@@ -150,6 +156,7 @@ export function Desktop({ renderWindow, title, actions, empty, loading, failed,
|
|
|
150
156
|
// One window at a time: the rest stay mounted, keeping their state, and simply wait offstage.
|
|
151
157
|
hidden={mode === 'fullscreen' && window.id !== focused}
|
|
152
158
|
title={title(window.id)}
|
|
159
|
+
note={note?.(window.id)}
|
|
153
160
|
actions={actions?.(window.id)}
|
|
154
161
|
>
|
|
155
162
|
{/* Memoised on the id and the render function: moving or focusing a window
|
|
@@ -184,6 +191,7 @@ interface WindowViewProps {
|
|
|
184
191
|
readonly depth: number
|
|
185
192
|
readonly focused: boolean
|
|
186
193
|
readonly title: ReactNode
|
|
194
|
+
readonly note?: ReactNode
|
|
187
195
|
readonly actions?: ReactNode
|
|
188
196
|
readonly children: ReactNode
|
|
189
197
|
}
|
|
@@ -210,7 +218,7 @@ function reshape(gesture: Gesture, origin: Frame, dx: number, dy: number): Frame
|
|
|
210
218
|
}
|
|
211
219
|
}
|
|
212
220
|
|
|
213
|
-
function WindowView({ window, layout, hidden, depth, focused, title, actions, children }: WindowViewProps) {
|
|
221
|
+
function WindowView({ window, layout, hidden, depth, focused, title, note, actions, children }: WindowViewProps) {
|
|
214
222
|
const desk = useDesk()
|
|
215
223
|
// While dragging, the frame lives here and commits once on release, so a drag
|
|
216
224
|
// re-renders one window rather than notifying every subscriber per pixel.
|
|
@@ -331,6 +339,7 @@ function WindowView({ window, layout, hidden, depth, focused, title, actions, ch
|
|
|
331
339
|
<h2 id={titleId} className="desk-title">
|
|
332
340
|
{title}
|
|
333
341
|
</h2>
|
|
342
|
+
{note != null && note !== false && <p className="desk-window-note">{note}</p>}
|
|
334
343
|
{actions && <div className="desk-window-actions">{actions}</div>}
|
|
335
344
|
</header>
|
|
336
345
|
<div className="desk-body">{children}</div>
|