staffa 0.8.1 → 0.10.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 +130 -64
- package/dist/components/autocomplete.js +1 -1
- package/dist/components/box.d.ts +8 -16
- package/dist/components/box.js +21 -27
- package/dist/components/button.d.ts +40 -0
- package/dist/components/button.js +85 -12
- package/dist/components/buttonChooser.js +1 -1
- package/dist/components/checkbox.js +3 -3
- package/dist/components/field.js +3 -3
- package/dist/components/main.d.ts +187 -64
- package/dist/components/main.js +284 -158
- package/dist/components/menu.d.ts +72 -14
- package/dist/components/menu.js +235 -31
- package/dist/components/pages.d.ts +638 -0
- package/dist/components/pages.js +1510 -0
- package/dist/components/panels.d.ts +512 -206
- package/dist/components/panels.js +926 -414
- package/dist/components/tabs.d.ts +37 -0
- package/dist/components/tabs.js +128 -69
- package/dist/core.d.ts +1 -1
- package/dist/core.js +1 -1
- package/dist/glyphs.d.ts +24 -0
- package/dist/glyphs.js +25 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +4 -5
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +67 -0
- package/dist/theme.js +12 -2
- package/package.json +2 -2
- package/skill/AncestorTable.md +10 -0
- package/skill/BoxOptions.md +7 -12
- package/skill/IconButtonOptions.md +41 -0
- package/skill/MainOptions.md +143 -54
- package/skill/MenuItem.md +16 -1
- package/skill/MenuListOptions.md +24 -0
- package/skill/MenuOptions.md +3 -2
- package/skill/Panel.md +190 -0
- package/skill/PanelStack.md +106 -0
- package/skill/SKILL.md +214 -77
- package/skill/ScrollStripOptions.md +21 -0
- package/skill/box.md +1 -4
- package/skill/closeNav.md +23 -0
- package/skill/iconButton.md +27 -0
- package/skill/main.md +13 -9
- package/skill/menu.md +29 -0
- package/skill/scrollStrip.md +28 -0
- package/src/components/autocomplete.ts +1 -1
- package/src/components/box.ts +29 -39
- package/src/components/button.ts +109 -8
- package/src/components/buttonChooser.ts +1 -1
- package/src/components/checkbox.ts +3 -3
- package/src/components/field.ts +3 -3
- package/src/components/main.ts +459 -167
- package/src/components/menu.ts +268 -34
- package/src/components/panels.ts +1254 -497
- package/src/components/tabs.ts +134 -68
- package/src/core.ts +1 -1
- package/src/index.ts +5 -5
- package/src/theme.ts +14 -3
- package/skill/Page.md +0 -119
- package/skill/panels.md +0 -10
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import A from "aberdeen";
|
|
1
|
+
import A, { OPAQUE } from "aberdeen";
|
|
2
2
|
import * as route from "aberdeen/route";
|
|
3
|
-
import {
|
|
3
|
+
import { drawSlot } from "../core.js";
|
|
4
|
+
import { circle as dotIcon, externalLink as newTabIcon, link as linkIcon, pin as pinIcon, pinOff as pinOffIcon, slash as sepIcon, x as closeIcon, } from "../icons.js";
|
|
5
|
+
import { SURFACE_SHEEN } from "../theme.js";
|
|
6
|
+
import { addContextMenu } from "./menu.js";
|
|
7
|
+
import { scrollStrip, revealInStrip } from "./tabs.js";
|
|
8
|
+
import { toast } from "./toast.js";
|
|
4
9
|
/**
|
|
5
10
|
* The matchers a `[name=matcher]` segment can use. A matcher returns the param's
|
|
6
11
|
* value, or `undefined` to fail the match, in which case the path falls through
|
|
@@ -10,7 +15,7 @@ import { NARROW_PX } from "../core.js";
|
|
|
10
15
|
* back to the same URL: no leading zeroes ("007"), no "-0", no "1.5", "1e3" or
|
|
11
16
|
* "0x10", and nothing past `Number.MAX_SAFE_INTEGER` (where the number would no
|
|
12
17
|
* longer hold the id it came from). Two spellings of one id would otherwise be
|
|
13
|
-
* two different paths, so the same record could sit open in two
|
|
18
|
+
* two different paths, so the same record could sit open in two columns at once.
|
|
14
19
|
* Use a plain `[id]` for ids that aren't safe integers, such as snowflakes.
|
|
15
20
|
*/
|
|
16
21
|
const MATCHERS = {
|
|
@@ -33,11 +38,12 @@ function splitPath(path) {
|
|
|
33
38
|
return p === "/" ? [] : p.slice(1).split("/");
|
|
34
39
|
}
|
|
35
40
|
/**
|
|
36
|
-
* Turn a
|
|
41
|
+
* Turn a path template into segment tokens, throwing on malformed ones. A
|
|
37
42
|
* segment is a param only when it is *entirely* a bracket group, so a literal
|
|
38
|
-
* segment that merely contains brackets (`/v[1]beta`) stays literal.
|
|
43
|
+
* segment that merely contains brackets (`/v[1]beta`) stays literal. Used for
|
|
44
|
+
* both tables keyed by a path template: `routes` and `ancestors`.
|
|
39
45
|
*/
|
|
40
|
-
function
|
|
46
|
+
function compileKey(key) {
|
|
41
47
|
const parts = splitPath(key);
|
|
42
48
|
const segs = parts.map((part, i) => {
|
|
43
49
|
if (!part.startsWith("[") || !part.endsWith("]"))
|
|
@@ -57,7 +63,7 @@ function compileRoute(key, draw) {
|
|
|
57
63
|
}
|
|
58
64
|
return { kind: "param", name, matcher };
|
|
59
65
|
});
|
|
60
|
-
return { key, segs
|
|
66
|
+
return { key, segs };
|
|
61
67
|
}
|
|
62
68
|
/** Percent-decode a path segment, leaving it alone when it isn't valid encoding. */
|
|
63
69
|
function decodeSeg(value) {
|
|
@@ -104,22 +110,25 @@ function matchRoute(r, segments) {
|
|
|
104
110
|
}
|
|
105
111
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
106
112
|
/**
|
|
107
|
-
* The one duration every bit of
|
|
108
|
-
* `left` moves of columns shifting sideways,
|
|
109
|
-
*
|
|
110
|
-
* `--s-panel-ms` custom property below, so CSS
|
|
113
|
+
* The one duration every bit of shell motion shares: the enter/exit fades, the
|
|
114
|
+
* `left` moves of columns shifting sideways, the ensemble-width transition the
|
|
115
|
+
* chrome follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
|
|
116
|
+
* panel's slide. Published as the `--s-panel-ms` custom property below, so CSS
|
|
117
|
+
* and JS can't drift apart.
|
|
118
|
+
*
|
|
119
|
+
* Short enough to read as *the screen responded*, rather than as an animation
|
|
120
|
+
* being played at you: a panel arriving is navigation, and navigation should
|
|
121
|
+
* feel instant even when it moves.
|
|
111
122
|
*/
|
|
112
|
-
const
|
|
123
|
+
const PAGE_MS = 250;
|
|
113
124
|
/** How long a freshly pushed `loading` panel holds its enter animation. */
|
|
114
125
|
const LOADING_HOLD_MS = 300;
|
|
115
126
|
/**
|
|
116
127
|
* The standard page width: sidebar plus content area, capped by the window.
|
|
117
|
-
* `"
|
|
118
|
-
*
|
|
128
|
+
* `"full"` fills the content-area part of this exactly; only a `"screen"`
|
|
129
|
+
* page makes the shell grow past it.
|
|
119
130
|
*/
|
|
120
131
|
const SHELL_PX = 1280;
|
|
121
|
-
/** The gap between two `"small"` panels sitting two-up. */
|
|
122
|
-
const GUTTER_PX = 24;
|
|
123
132
|
/** Don't pair smalls when half the content area would be narrower than this. */
|
|
124
133
|
const PAIR_MIN_PX = 360;
|
|
125
134
|
/**
|
|
@@ -132,21 +141,25 @@ const PAIR_MIN_PX = 360;
|
|
|
132
141
|
const LAYER_STEP = 2;
|
|
133
142
|
// ─── Module-level styling ────────────────────────────────────────────────────
|
|
134
143
|
A.insertGlobalCss({
|
|
135
|
-
":root": `--s-panel-ms:${
|
|
144
|
+
":root": `--s-panel-ms:${PAGE_MS}ms`,
|
|
136
145
|
// The clipping viewport that the columns slide through. Panels are absolutely
|
|
137
146
|
// positioned inside it, with their width and x offset set from JS (see
|
|
138
147
|
// `layout()`), so they can animate between arrangements. `isolation` keeps the
|
|
139
148
|
// layers they stack themselves in (see LAYER_STEP) to themselves: the region
|
|
140
149
|
// as a whole still sits under the shell's own chrome — the sticky top bar, and
|
|
141
|
-
// the nav
|
|
142
|
-
|
|
150
|
+
// the nav panel that slides across the body — however deep the stack gets.
|
|
151
|
+
// The region paints the panel's sheen over its own box, and every panel shows
|
|
152
|
+
// a slice of that same gradient (see `.s-panel` below), so the columns and
|
|
153
|
+
// the ground beside them are one continuous surface.
|
|
154
|
+
".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate " +
|
|
155
|
+
SURFACE_SHEEN,
|
|
143
156
|
".s-panel": {
|
|
144
157
|
// A panel rests at a plain `left` offset and carries no transform: a
|
|
145
158
|
// transformed element is composited, which costs it subpixel text
|
|
146
159
|
// antialiasing. `transform` is used only to play the enter/exit slides,
|
|
147
160
|
// where the compositing is what makes them cheap. There is deliberately no
|
|
148
161
|
// `width` transition: a width changes only when the window resizes or when
|
|
149
|
-
// the
|
|
162
|
+
// the panel itself asks for another layout, and animating one would reflow
|
|
150
163
|
// the column's content on every frame of it.
|
|
151
164
|
// Every duration is `--s-panel-ms`, so a column's move, its neighbour's fade
|
|
152
165
|
// and the chrome recentering around them all run as one motion. The drift
|
|
@@ -154,21 +167,34 @@ A.insertGlobalCss({
|
|
|
154
167
|
// across the whole duration — an eased opacity spends its last stretch near
|
|
155
168
|
// zero, which looks like the panel vanishing rather than fading.
|
|
156
169
|
// No `overflow:hidden` here: the scroll container below clips the content
|
|
157
|
-
// itself
|
|
170
|
+
// itself.
|
|
158
171
|
// Layering is set from JS (`layout()` and `beginClose`) rather than left to
|
|
159
172
|
// DOM order: a closing panel is no longer part of the reactive list, so
|
|
160
173
|
// where its element sits among the live ones is Aberdeen's business, not a
|
|
161
174
|
// thing to depend on. `LAYER_*` says what the numbers mean.
|
|
175
|
+
//
|
|
176
|
+
// Every panel paints an opaque ground, because panels animate over one
|
|
177
|
+
// another — entering, leaving, being crowded out — and two transparent ones
|
|
178
|
+
// mean text sliding over text. It takes the panel's own sheen, the one
|
|
179
|
+
// `.s-s, body` paints in theme.ts, resolved here against the inherited
|
|
180
|
+
// `--s-bg` (a panel is not a surface, so it has to paint it itself).
|
|
181
|
+
//
|
|
182
|
+
// Painted per panel, over the panel's own box, which is as good as it
|
|
183
|
+
// needs to be: the sheen is a 9%-either-way wash over a whole column, so
|
|
184
|
+
// two columns' worth of it meeting at a hairline is not something the eye
|
|
185
|
+
// picks out. The region (`.s-panels` above) paints the same wash, so the
|
|
186
|
+
// ground beside a lone column matches it just as closely.
|
|
162
187
|
"&": "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
|
|
188
|
+
SURFACE_SHEEN + " " +
|
|
163
189
|
"visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
|
|
164
|
-
// The
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
"&.s-panel-sep::before":
|
|
190
|
+
// The hairline between two columns, fading out at both ends — the same
|
|
191
|
+
// treatment as the sidebar's `.s-nav-sep`. Columns tile the area with no
|
|
192
|
+
// gutter between them: each already brings its own `$3` of padding, which
|
|
193
|
+
// keeps two columns' *contents* comfortably apart, while a gutter on top
|
|
194
|
+
// of that only opened a strip of the panel's own ground between two columns
|
|
195
|
+
// painting theirs. So this sits exactly on the boundary — and an
|
|
196
|
+
// edge-to-edge column (`A("p:0")`) really does reach the line bounding it.
|
|
197
|
+
"&.s-panel-sep::before": "content:'' position:absolute left:0 top:0.6rem bottom:0.6rem width:1px z-index:1 " +
|
|
172
198
|
"background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
|
|
173
199
|
// One vocabulary for every arrival and departure: a gradual fade over a short,
|
|
174
200
|
// slow drift — 8cqw (`cqw`: `.s-main` is the container). Panels appear and
|
|
@@ -181,26 +207,74 @@ A.insertGlobalCss({
|
|
|
181
207
|
// and out of reach while it does. It leaves the DOM when the fade itself
|
|
182
208
|
// ends (see `playExit`), never part-way through it.
|
|
183
209
|
"&.s-panel-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// `
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
210
|
+
// Off screen but open: crowded out from under the visible run at the left
|
|
211
|
+
// edge (`hidden`), or right of the current panel, parked past the right
|
|
212
|
+
// edge (`parked`) — the two are mirror images. Either keeps its DOM (and
|
|
213
|
+
// thus its scroll position and half-typed forms), so `display:none` is out
|
|
214
|
+
// — `visibility` takes it out of the rendering instead, but only once the
|
|
215
|
+
// fade has played: a transitioned `visibility` counts as *visible* for the
|
|
216
|
+
// whole duration and flips at the very end. Revealing it again uses the
|
|
217
|
+
// rule above (`visibility 0s`), so it comes back instantly.
|
|
218
|
+
"&.s-panel-hidden, &.s-panel-parked": "opacity:0 visibility:hidden " +
|
|
191
219
|
"transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility var(--s-panel-ms);",
|
|
220
|
+
"&.s-panel-hidden": "transform: translateX(-8cqw);",
|
|
221
|
+
"&.s-panel-parked": "transform: translateX(8cqw);",
|
|
192
222
|
},
|
|
223
|
+
// The scroll container, with the column's own padding. A scrollbar here is
|
|
224
|
+
// left flush against the column's right edge — unlike content mode's, which
|
|
225
|
+
// insets one from the shell edge to line up with the bar above it. A column
|
|
226
|
+
// has something better to line up with: the hairline the next column starts
|
|
227
|
+
// at, or the edge of the content area. Both want the bar hard against them,
|
|
228
|
+
// and an inset would leave a strip of nothing between the two.
|
|
229
|
+
".s-panel > .s-content": "flex:1 min-height:0 overflow-y:auto overflow-x:hidden p:$3",
|
|
230
|
+
// A panel's actions on a wide shell: a quiet strip at the column's top-right,
|
|
231
|
+
// above the scroll area (never sticky inside it). On a narrow shell the top
|
|
232
|
+
// bar carries them instead, and no strip is drawn at all.
|
|
233
|
+
".s-panel-actions": "display:flex align-items:center justify-content:flex-end gap:$1 flex-shrink:0 padding: $3 $3 0;",
|
|
234
|
+
// The breadcrumb stack the top bar shows: every open panel, oldest first,
|
|
235
|
+
// the ones on screen right now in bold ink (see `drawCrumbs`). One row that
|
|
236
|
+
// scrolls sideways when the bar is tight — scrollbarless, with a fade at
|
|
237
|
+
// whichever edge has more stack behind it, so the cut-off reads as "keep
|
|
238
|
+
// going" rather than as the stack simply ending.
|
|
239
|
+
// The stack is a `.s-strip` (see tabs.ts), so the scrolling, the hidden
|
|
240
|
+
// scrollbar and the ‹ / › come with it; all that is left to say is the gap
|
|
241
|
+
// between a crumb and its chevron.
|
|
242
|
+
".s-crumbs > .s-strip-row": "gap:$m1",
|
|
243
|
+
".s-crumb": {
|
|
244
|
+
// Quiet ink for the stack, full ink and weight for the panels on screen:
|
|
245
|
+
// the weight change alone is ambiguous in a short crumb, the colour alone
|
|
246
|
+
// too subtle. No padding of its own — the first crumb has to start on the
|
|
247
|
+
// same pixel as the app's name above it, and the gap below spaces the row.
|
|
248
|
+
// `flex-shrink:0` because the crumbs are the strip's flex items and carry
|
|
249
|
+
// `overflow:hidden`, which resolves their automatic minimum size to zero:
|
|
250
|
+
// left to shrink they would ellipsise themselves down to stubs rather than
|
|
251
|
+
// overflow, and the stack would never scroll. One long title still caps at
|
|
252
|
+
// 14rem — that is this crumb's own business, not the row running out.
|
|
253
|
+
"&": "flex-shrink:0 font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
|
|
254
|
+
"white-space:nowrap max-width:14rem overflow:hidden text-overflow:ellipsis " +
|
|
255
|
+
"transition: color 0.12s;",
|
|
256
|
+
"&.s-crumb-on": "font-weight:600 fg:$s-text",
|
|
257
|
+
// The same hover treatment as a menu item. The panel you are on is a plain
|
|
258
|
+
// span rather than a link, so it needs no `:not()` guard here.
|
|
259
|
+
"a&:hover": "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
|
|
260
|
+
// The pin of a pinned panel, sitting inline just before its title. Filled:
|
|
261
|
+
// at crumb size the icon's hairline strokes alias into a wobble, while the
|
|
262
|
+
// body path filled solid reads as the classic pinned-tab pushpin.
|
|
263
|
+
"svg.s-crumb-pin": "vertical-align:-0.12em margin-right:0.3em opacity:0.8 fill:currentColor",
|
|
264
|
+
// The ● of a panel holding unsaved work — the editors' dirty mark, filled
|
|
265
|
+
// solid for the same reason as the pin.
|
|
266
|
+
"svg.s-crumb-unsaved": "vertical-align:0.08em margin-right:0.3em fill:currentColor",
|
|
267
|
+
},
|
|
268
|
+
// A slash, not a chevron: the stack is a path, and a path's separator is what
|
|
269
|
+
// the URL itself uses. It also has to stay clearly *unlike* the ‹ / › the
|
|
270
|
+
// strip grows when the stack overflows (see `scrollStrip` in tabs.ts) — two
|
|
271
|
+
// near-identical chevrons, one meaningful and one a button, read as a bug.
|
|
272
|
+
"svg.s-crumb-sep": "flex-shrink:0 opacity:0.4",
|
|
193
273
|
// A window resize (and the very first pass) must track the window instantly,
|
|
194
274
|
// not rubber-band 450ms behind it: the layout engine raises this class on the
|
|
195
275
|
// shell for exactly those passes, applies the new geometry, and drops it
|
|
196
276
|
// after a forced reflow. Beats the standing transitions on specificity.
|
|
197
277
|
".s-main.s-shell-snap .s-panel": "transition:none",
|
|
198
|
-
// On a narrow shell the single column is edge-to-edge, so there is no inset
|
|
199
|
-
// chrome for the scrollbar to line up with — cancel the `.s-scroll-y` margin
|
|
200
|
-
// (the twin of content mode's rule in main.ts).
|
|
201
|
-
[`@container (max-width: ${NARROW_PX}px)`]: {
|
|
202
|
-
".s-panel > .s-content.s-scroll-y": "margin-right:0",
|
|
203
|
-
},
|
|
204
278
|
// A minimal "still fetching" hint, centred over the panel's content (which
|
|
205
279
|
// stays mounted underneath, so it can fill in reactively).
|
|
206
280
|
".s-panel-loading": {
|
|
@@ -214,22 +288,64 @@ A.insertGlobalCss({
|
|
|
214
288
|
"50%": "opacity:0.7 transform:scale(1)",
|
|
215
289
|
},
|
|
216
290
|
});
|
|
217
|
-
/**
|
|
218
|
-
|
|
219
|
-
|
|
291
|
+
/**
|
|
292
|
+
* The URL is global, so two routed shells would fight over it. This is only a
|
|
293
|
+
* guard against that — the stack is reached through the object `main()` hands
|
|
294
|
+
* back, never through a module-level singleton.
|
|
295
|
+
*/
|
|
296
|
+
let mounted = false;
|
|
297
|
+
/**
|
|
298
|
+
* The controller behind a routed shell. It implements {@link PanelStack} —
|
|
299
|
+
* the app-facing face, and the only part of it that is Staffa API — and on
|
|
300
|
+
* top of that draws the columns and the breadcrumbs for `main()`, which
|
|
301
|
+
* constructs it.
|
|
302
|
+
*/
|
|
303
|
+
export class PanelStackController {
|
|
304
|
+
/**
|
|
305
|
+
* Kept out of Aberdeen's proxy wrapping: this is a class instance holding
|
|
306
|
+
* DOM nodes, timers and route handlers, and it rides inside every
|
|
307
|
+
* {@link Panel.stack}. Its reactivity doesn't need the wrapper — it comes
|
|
308
|
+
* from `$state` and the panels, which are proxies in their own right.
|
|
309
|
+
*/
|
|
310
|
+
[OPAQUE] = true;
|
|
220
311
|
compiled;
|
|
312
|
+
/** The `ancestors` table, compiled like the routes it is keyed by. */
|
|
313
|
+
ancestors;
|
|
221
314
|
opts;
|
|
222
|
-
/** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
|
|
223
|
-
live = [];
|
|
224
|
-
byId = new Map();
|
|
225
|
-
nextId = 1;
|
|
226
|
-
/** Drives rendering: panel id → its `order` (used only as the sort key). */
|
|
227
|
-
$ids = A.proxy({});
|
|
228
315
|
/**
|
|
229
|
-
* The live stack
|
|
230
|
-
*
|
|
316
|
+
* The live stack, oldest first (closing panels are no longer part of it),
|
|
317
|
+
* and which of its panels is current — the one reactive fact about the
|
|
318
|
+
* stack's *shape*. The getters, the crumbs and `document.title` subscribe
|
|
319
|
+
* to it simply by reading it; each commit publishes the next shape by
|
|
320
|
+
* assigning a fresh `live` array. The entries themselves are opaque (see
|
|
321
|
+
* {@link PanelEntry}), so the array carries their comings, goings and
|
|
322
|
+
* order — nothing deeper; a panel's own facts stay separately reactive on
|
|
323
|
+
* its `$panel`, which is what lets a panel rename itself without the
|
|
324
|
+
* stack redrawing.
|
|
325
|
+
*
|
|
326
|
+
* One rule makes this safe to touch from anywhere: **queries subscribe,
|
|
327
|
+
* commands peek**. The getters below are the queries. Every navigation
|
|
328
|
+
* entry point (`navigate`, `closePath`, `back`, …) wraps itself in
|
|
329
|
+
* `A.peek`, so an app calling one from inside a reactive scope (a
|
|
330
|
+
* redirect in a route handler, say) can't subscribe that scope to the
|
|
331
|
+
* very stack it is changing — and everything those commands call through
|
|
332
|
+
* to, `propose` and `commit` included, inherits the same guarantee and
|
|
333
|
+
* reads the stack plainly.
|
|
231
334
|
*/
|
|
232
|
-
$state = A.proxy({
|
|
335
|
+
$state = A.proxy({ live: [], focus: 0 });
|
|
336
|
+
/**
|
|
337
|
+
* The open panels again, keyed by path — the shape as the DOM consumes it.
|
|
338
|
+
* `drawColumns`' `onEach` mounts and unmounts panels by key, so a panel
|
|
339
|
+
* spliced out of the middle of the stack touches exactly one key, and the
|
|
340
|
+
* DOM of the retained columns — scroll positions, half-typed forms — is
|
|
341
|
+
* left alone. (Iterating `live` itself would key panels by array index,
|
|
342
|
+
* and a splice renumbers every index after it, redrawing them all.) A
|
|
343
|
+
* key's value is its entry, by reference, and is never reassigned, so a
|
|
344
|
+
* panel only ever redraws wholesale when its path closes.
|
|
345
|
+
*/
|
|
346
|
+
$open = A.proxy({});
|
|
347
|
+
/** Feeds {@link PanelEntry.order}: one shared counter, so keys never tie. */
|
|
348
|
+
nextOrder = 0;
|
|
233
349
|
containerEl;
|
|
234
350
|
/** The shell's measurements, shared by everything drawn since they were taken. */
|
|
235
351
|
geom;
|
|
@@ -237,46 +353,75 @@ export class PanelController {
|
|
|
237
353
|
lastBodyW = -1;
|
|
238
354
|
layoutQueued = false;
|
|
239
355
|
timers = new Set();
|
|
356
|
+
/** The arrangement the navigation in flight is heading for; see {@link intended}. */
|
|
357
|
+
intent = null;
|
|
358
|
+
/** The navigation the router hasn't settled yet, if any. */
|
|
359
|
+
settling = null;
|
|
360
|
+
/** The route last seen by the observer, for stashing a left panel's query. */
|
|
361
|
+
lastSeen = null;
|
|
362
|
+
/** The one navigation waiting behind it; see {@link issue}. */
|
|
363
|
+
queued = null;
|
|
240
364
|
constructor(opts) {
|
|
241
|
-
if (
|
|
365
|
+
if (mounted) {
|
|
242
366
|
throw new Error("Staffa: only one routed S.main() (one with `routes`) can be active at a time");
|
|
243
367
|
}
|
|
244
|
-
|
|
368
|
+
mounted = true;
|
|
245
369
|
this.opts = opts;
|
|
246
|
-
this.compiled = Object.entries(opts.routes).map(([key, draw]) =>
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
// exactly once, and a veto leaves the URL and the stack untouched (the
|
|
251
|
-
// router holds the route steady while an async guard is pending, and
|
|
252
|
-
// knows the exact history depth to restore on a vetoed popstate). A
|
|
253
|
-
// guard the app registered before mounting keeps working: it is chained
|
|
254
|
-
// in front of ours — an app veto (or redirect) wins without the panels
|
|
255
|
-
// being asked — and handed back when the shell unmounts.
|
|
256
|
-
const appGuard = route.setGuard((to, from) => {
|
|
257
|
-
const outer = appGuard ? appGuard(to, from) : true;
|
|
258
|
-
if (outer === false)
|
|
259
|
-
return false;
|
|
260
|
-
if (outer === true)
|
|
261
|
-
return this.checkChange(to);
|
|
262
|
-
return outer.then((ok) => (ok === false ? false : this.checkChange(to)));
|
|
263
|
-
});
|
|
370
|
+
this.compiled = Object.entries(opts.routes).map(([key, draw]) => ({ ...compileKey(key), draw }));
|
|
371
|
+
this.ancestors = Object.entries(opts.ancestors ?? {})
|
|
372
|
+
.filter((entry) => entry[1] != null)
|
|
373
|
+
.map(([key, fn]) => ({ ...compileKey(key), fn }));
|
|
264
374
|
// Commit the stack whenever the URL or its snapshot changes — the initial
|
|
265
|
-
// load, our own navigations, and browser back/forward.
|
|
266
|
-
//
|
|
375
|
+
// load, our own navigations, and browser back/forward. There is no route
|
|
376
|
+
// guard of the shell's own to pass first: closes are refused up front
|
|
377
|
+
// (`closePath` on an unsaved panel) or repaired at the commit (`propose`
|
|
378
|
+
// keeps unsaved panels a navigation would drop), so a guard the app
|
|
379
|
+
// itself registered with `route.setGuard` — an auth redirect, say — is
|
|
380
|
+
// left exactly where it is and keeps working untouched.
|
|
267
381
|
A(() => {
|
|
268
382
|
const target = this.computeTarget();
|
|
269
|
-
|
|
383
|
+
// Subscribed (not peeked) deliberately: a search/hash change with the
|
|
384
|
+
// path staying put must refresh `lastSeen` too, or the next stash would
|
|
385
|
+
// restore stale ones.
|
|
386
|
+
const search = { ...route.current.search };
|
|
387
|
+
const hash = route.current.hash;
|
|
388
|
+
A.peek(() => {
|
|
389
|
+
// Stash the query of the panel the URL just left on that panel, for
|
|
390
|
+
// when a crumb brings it back (see PanelEntry.search). Done here, on
|
|
391
|
+
// the shared pipeline, so every origin is covered alike: the stack's
|
|
392
|
+
// own navigations, an app's `route.go()`, and browser back/forward.
|
|
393
|
+
const prev = this.lastSeen;
|
|
394
|
+
if (prev && prev.path !== route.current.path) {
|
|
395
|
+
const entry = this.$state.live.find((e) => e.path === prev.path);
|
|
396
|
+
if (entry) {
|
|
397
|
+
entry.search = prev.search;
|
|
398
|
+
entry.hash = prev.hash;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
this.lastSeen = { path: route.current.path, search, hash };
|
|
402
|
+
this.propose(target);
|
|
403
|
+
// A history entry that doesn't describe an arrangement — the initial
|
|
404
|
+
// load, or an app's own `route.go()` — is stamped with the one it
|
|
405
|
+
// just produced: a reload restores the same columns, and a later
|
|
406
|
+
// `route.back()` can recognize the entry (its matching wants the
|
|
407
|
+
// `panels`/`parked` keys present, not merely compatible).
|
|
408
|
+
if (!Array.isArray(route.current.state.panels)) {
|
|
409
|
+
Object.assign(route.current.state, this.stateFor({ stack: this.paths(), focus: this.$state.focus }));
|
|
410
|
+
}
|
|
411
|
+
});
|
|
270
412
|
});
|
|
271
413
|
this.interceptLinks();
|
|
272
414
|
this.watchTitle();
|
|
415
|
+
this.guardTabClose();
|
|
273
416
|
A.clean(() => {
|
|
274
417
|
for (const t of this.timers)
|
|
275
418
|
clearTimeout(t);
|
|
276
419
|
this.timers.clear();
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
420
|
+
// Nothing is going to navigate a shell that isn't there: whatever was
|
|
421
|
+
// waiting its turn is answered rather than left hanging.
|
|
422
|
+
this.queued?.settle(false);
|
|
423
|
+
this.queued = null;
|
|
424
|
+
mounted = false;
|
|
280
425
|
});
|
|
281
426
|
}
|
|
282
427
|
// ── Stack derivation ───────────────────────────────────────────────────
|
|
@@ -295,65 +440,130 @@ export class PanelController {
|
|
|
295
440
|
return this.compiled.some((r) => matchRoute(r, segments) != null);
|
|
296
441
|
}
|
|
297
442
|
/**
|
|
298
|
-
* The
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
443
|
+
* The stack for origin-less navigation: a cold deep link, a nav item, a
|
|
444
|
+
* `route.go()` — anything arriving without a panel to build on and without a
|
|
445
|
+
* snapshot to restore.
|
|
446
|
+
*
|
|
447
|
+
* The app's {@link PanelStackOptions.ancestors} gets first say, since only it
|
|
448
|
+
* can know what belongs under a path that doesn't spell its own context out
|
|
449
|
+
* (a `/thread/[id]` reached from a notification). Failing that — or when it
|
|
450
|
+
* has no opinion — every prefix of the path is probed against the route table
|
|
451
|
+
* and the matching ones become the stack. Either way, a path with no route is
|
|
452
|
+
* skipped rather than opened as a "not found" column, so an app that doesn't
|
|
453
|
+
* want one screen stacked under another simply doesn't route it. The path
|
|
454
|
+
* itself always ends the derived stack, matched or not.
|
|
303
455
|
*/
|
|
304
456
|
deriveStack(path) {
|
|
305
|
-
const
|
|
457
|
+
const top = normalizePath(path);
|
|
458
|
+
const asked = this.askAncestors(top);
|
|
459
|
+
const beneath = asked ? asked.map(normalizePath) : this.prefixesOf(top);
|
|
306
460
|
const stack = [];
|
|
307
|
-
for (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
stack.push(prefix);
|
|
461
|
+
for (const ancestor of beneath) {
|
|
462
|
+
if (ancestor !== top && !stack.includes(ancestor) && this.matches(ancestor))
|
|
463
|
+
stack.push(ancestor);
|
|
311
464
|
}
|
|
312
|
-
stack.push(
|
|
465
|
+
stack.push(top);
|
|
313
466
|
return stack;
|
|
314
467
|
}
|
|
315
|
-
/**
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
468
|
+
/**
|
|
469
|
+
* Ask the `ancestors` table what belongs beneath `path`. The first key that
|
|
470
|
+
* matches answers — with its own matched params, so it never has to take the
|
|
471
|
+
* path apart itself — and `undefined` from it means "no opinion", leaving the
|
|
472
|
+
* path to the prefix derivation just as an unlisted one is.
|
|
473
|
+
*/
|
|
474
|
+
askAncestors(path) {
|
|
475
|
+
const segments = splitPath(path);
|
|
476
|
+
for (const entry of this.ancestors) {
|
|
477
|
+
const params = matchRoute(entry, segments);
|
|
478
|
+
if (params)
|
|
479
|
+
return entry.fn(params, path) ?? undefined;
|
|
480
|
+
}
|
|
481
|
+
return undefined;
|
|
320
482
|
}
|
|
321
|
-
/**
|
|
322
|
-
|
|
323
|
-
|
|
483
|
+
/** Every prefix of `path` that has a route, shallowest first. */
|
|
484
|
+
prefixesOf(path) {
|
|
485
|
+
const segments = splitPath(path);
|
|
486
|
+
const found = [];
|
|
487
|
+
for (let i = 1; i < segments.length; i++)
|
|
488
|
+
found.push("/" + segments.slice(0, i).join("/"));
|
|
489
|
+
return found;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* The pinned panels among `stack`, in order, minus `omit` — the ones a
|
|
493
|
+
* navigation must carry along rather than close. Pin flags live on the
|
|
494
|
+
* panels themselves, so a path without a live panel can't be pinned.
|
|
495
|
+
*/
|
|
496
|
+
pinnedIn(stack, omit) {
|
|
497
|
+
return stack.filter((path) => {
|
|
498
|
+
if (omit.includes(path))
|
|
499
|
+
return false;
|
|
500
|
+
return this.$state.live.find((e) => e.path === path)?.$panel.pinned === true;
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
/** Whether the panel open at `path` (if any) holds unsaved work. */
|
|
504
|
+
unsavedAt(path) {
|
|
505
|
+
return this.$state.live.find((e) => e.path === path)?.$panel.unsaved === true;
|
|
324
506
|
}
|
|
325
507
|
/**
|
|
326
|
-
* The route
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
508
|
+
* The arrangement a route implies: its snapshot around its path, or —
|
|
509
|
+
* without a snapshot — derived, with the new panel current at the end and
|
|
510
|
+
* any pinned panels carried along beneath it.
|
|
511
|
+
*
|
|
512
|
+
* The snapshot reads subscribe — they are the URL's, exactly what the
|
|
513
|
+
* route observer is for. The derivation is peeked instead: it reads the
|
|
514
|
+
* live stack for its pins, which is the very thing that observer rewrites,
|
|
515
|
+
* and subscribing to it would re-run the observer once per commit.
|
|
331
516
|
*/
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
517
|
+
targetFor(path, state) {
|
|
518
|
+
const before = Array.isArray(state?.panels) ? state.panels.map(String) : null;
|
|
519
|
+
if (before) {
|
|
520
|
+
const after = Array.isArray(state.parked) ? state.parked.map(String) : [];
|
|
521
|
+
// A stack never holds the same path twice: rendering reconciles by path,
|
|
522
|
+
// so a duplicate would leave a permanently element-less entry that stalls
|
|
523
|
+
// the layout. Our own states are clean, but `route.go` accepts
|
|
524
|
+
// hand-written ones — drop duplicates rather than wedge.
|
|
525
|
+
const cur = normalizePath(path);
|
|
526
|
+
const seen = new Set([cur]);
|
|
527
|
+
const uniq = (paths) => paths.map(normalizePath).filter((p) => !seen.has(p) && !!seen.add(p));
|
|
528
|
+
const beforeUnique = uniq(before);
|
|
529
|
+
return { stack: [...beforeUnique, cur, ...uniq(after)], focus: beforeUnique.length };
|
|
530
|
+
}
|
|
531
|
+
return A.peek(() => {
|
|
532
|
+
const base = this.deriveStack(path).slice(0, -1);
|
|
533
|
+
const stack = [...base, ...this.pinnedIn(this.paths(), [...base, normalizePath(path)]), normalizePath(path)];
|
|
534
|
+
return { stack, focus: stack.length - 1 };
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
/** The arrangement the current history entry asks for. Subscribes to path + snapshot. */
|
|
538
|
+
computeTarget() {
|
|
539
|
+
return this.targetFor(route.current.path, route.current.state);
|
|
335
540
|
}
|
|
336
541
|
// ── Commit pipeline ────────────────────────────────────────────────────
|
|
337
542
|
paths() {
|
|
338
|
-
return this.live.map((e) => e.path);
|
|
339
|
-
}
|
|
340
|
-
/** The live panels a target stack drops — by path, so a splice removes only its own column. */
|
|
341
|
-
removedBy(target) {
|
|
342
|
-
return this.live.filter((entry) => !target.includes(entry.path));
|
|
543
|
+
return this.$state.live.map((e) => e.path);
|
|
343
544
|
}
|
|
344
545
|
/**
|
|
345
|
-
* Adopt
|
|
346
|
-
*
|
|
347
|
-
*
|
|
546
|
+
* Adopt an arrangement proposed by the URL — after repairing it: panels
|
|
547
|
+
* holding unsaved work are never torn down by a navigation, wherever it
|
|
548
|
+
* came from — a link, a nav item, even a browser back to an entry from
|
|
549
|
+
* before the panel existed. Whatever the target drops, they stay, parked
|
|
550
|
+
* after the current panel and wearing the ● that says why. (They are
|
|
551
|
+
* deliberately not written into history entries: the work they protect
|
|
552
|
+
* lives in the page's DOM, which a reload clears anyway.)
|
|
348
553
|
*/
|
|
349
554
|
propose(target) {
|
|
350
|
-
|
|
555
|
+
const kept = this.$state.live
|
|
556
|
+
.filter((e) => !target.stack.includes(e.path) && e.$panel.unsaved)
|
|
557
|
+
.map((e) => e.path);
|
|
558
|
+
if (kept.length)
|
|
559
|
+
target = { stack: [...target.stack, ...kept], focus: target.focus };
|
|
560
|
+
if (sameStack(this.paths(), target.stack) && target.focus === this.$state.focus)
|
|
351
561
|
return;
|
|
352
|
-
this.commit(target,
|
|
562
|
+
this.commit(target, route.current.nav);
|
|
353
563
|
}
|
|
354
564
|
/**
|
|
355
|
-
* Apply a target
|
|
356
|
-
* difference.
|
|
565
|
+
* Apply a target arrangement: unmount what's gone, mount what's new, animate
|
|
566
|
+
* the difference.
|
|
357
567
|
*
|
|
358
568
|
* Reconciliation is BY PATH (a stack can't hold the same path twice, so that's
|
|
359
569
|
* well-defined): a panel present in both stacks stays mounted *even if its
|
|
@@ -366,9 +576,14 @@ export class PanelController {
|
|
|
366
576
|
// The panels this commit mounts size themselves as they draw, so make them
|
|
367
577
|
// measure the shell as it is now rather than trusting the last pass's numbers.
|
|
368
578
|
this.geom = undefined;
|
|
369
|
-
|
|
579
|
+
// Pin flags for panels this commit *creates* — a reload, or a cold
|
|
580
|
+
// restore. Live panels keep their own flag: a pin is the user's mark on
|
|
581
|
+
// the panel, not part of where back/forward travel.
|
|
582
|
+
const pinned = route.current.state.pinned;
|
|
583
|
+
const seedPins = new Set(Array.isArray(pinned) ? pinned.map(String) : []);
|
|
584
|
+
const existing = new Map(this.$state.live.map((entry) => [entry.path, entry]));
|
|
370
585
|
const next = [];
|
|
371
|
-
for (const path of target) {
|
|
586
|
+
for (const path of target.stack) {
|
|
372
587
|
const kept = existing.get(path);
|
|
373
588
|
if (kept) {
|
|
374
589
|
// Retained: it just takes its new place in the stack. Its `order` (the
|
|
@@ -377,43 +592,50 @@ export class PanelController {
|
|
|
377
592
|
next.push(kept);
|
|
378
593
|
continue;
|
|
379
594
|
}
|
|
380
|
-
const entry = this.createEntry(path, next.length);
|
|
595
|
+
const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
|
|
381
596
|
// An initial load just appears, and so do panels *revealed* by a back —
|
|
382
597
|
// they belong underneath the ones sliding away. Everything else enters at
|
|
383
598
|
// the right edge, a replacement exactly like a push.
|
|
384
599
|
if (nav !== "load" && nav !== "back")
|
|
385
600
|
entry.enter = true;
|
|
386
601
|
next.push(entry);
|
|
387
|
-
this
|
|
602
|
+
this.$open[path] = entry;
|
|
388
603
|
}
|
|
389
604
|
// Whatever the target no longer holds leaves the same way: fading out over
|
|
390
605
|
// the right edge, which is also where its replacement (if any) comes in from.
|
|
391
606
|
for (const entry of existing.values())
|
|
392
607
|
this.beginClose(entry);
|
|
393
|
-
this.live = next;
|
|
394
|
-
|
|
395
|
-
for (const entry of this.live)
|
|
396
|
-
this.$ids[String(entry.id)] = entry.order;
|
|
608
|
+
this.$state.live = next;
|
|
609
|
+
this.$state.focus = Math.min(target.focus, next.length - 1);
|
|
397
610
|
this.scheduleLayout();
|
|
398
611
|
}
|
|
399
|
-
createEntry(path,
|
|
612
|
+
createEntry(path, visible, pinned) {
|
|
400
613
|
const { draw, params } = this.resolve(path);
|
|
401
614
|
const entry = {
|
|
402
|
-
|
|
403
|
-
order
|
|
615
|
+
[OPAQUE]: true,
|
|
616
|
+
order: this.nextOrder++,
|
|
404
617
|
path,
|
|
405
618
|
draw,
|
|
406
619
|
$ui: A.proxy({ holding: false }),
|
|
407
|
-
|
|
620
|
+
maxWidth: "full",
|
|
408
621
|
width: 0,
|
|
409
622
|
};
|
|
410
|
-
// `close` closes *this* panel,
|
|
411
|
-
//
|
|
623
|
+
// `close` closes *this* panel, current or not. It resolves the panel's
|
|
624
|
+
// place in the stack at call time, so it keeps working after a splice has
|
|
412
625
|
// moved it — and quietly resolves false once the panel is gone.
|
|
413
|
-
|
|
626
|
+
//
|
|
627
|
+
// `visible` starts at what the panel's place implies: shown when it sits
|
|
628
|
+
// at or before the current panel (a pushed panel always does), hidden when
|
|
629
|
+
// it is restored already parked. `width` is filled in by the sizing scope
|
|
630
|
+
// in `drawPanel` before the handler draws.
|
|
631
|
+
entry.$panel = A.proxy({
|
|
632
|
+
stack: this,
|
|
414
633
|
params,
|
|
415
634
|
path,
|
|
416
|
-
|
|
635
|
+
width: 0,
|
|
636
|
+
visible,
|
|
637
|
+
pinned: pinned || undefined,
|
|
638
|
+
close: () => this.closePath(entry.path),
|
|
417
639
|
});
|
|
418
640
|
return entry;
|
|
419
641
|
}
|
|
@@ -427,14 +649,17 @@ export class PanelController {
|
|
|
427
649
|
*/
|
|
428
650
|
beginClose(entry) {
|
|
429
651
|
entry.closing = true;
|
|
652
|
+
// It is on its way out, so it is no longer "on screen" as far as anything
|
|
653
|
+
// hanging off `$panel.visible` is concerned — even though its element lingers
|
|
654
|
+
// to play the fade.
|
|
655
|
+
entry.$panel.visible = false;
|
|
430
656
|
// Frozen one layer below where it was, which is still above everything it
|
|
431
657
|
// was covering: it fades out over the panel it uncovers, and under the one
|
|
432
658
|
// that takes its place (see LAYER_STEP). Set here, while the element is
|
|
433
659
|
// still ours — a moment later the scope, and with it `entry.el`, is gone.
|
|
434
660
|
if (entry.el)
|
|
435
|
-
entry.el.style.zIndex = String(LAYER_STEP * this.live.indexOf(entry));
|
|
436
|
-
this
|
|
437
|
-
delete this.$ids[String(entry.id)];
|
|
661
|
+
entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
|
|
662
|
+
delete this.$open[entry.path];
|
|
438
663
|
}
|
|
439
664
|
/**
|
|
440
665
|
* A closed panel's send-off, run by Aberdeen once the panel's scope is gone (so
|
|
@@ -464,163 +689,495 @@ export class PanelController {
|
|
|
464
689
|
if (e.target === el && e.propertyName === "opacity")
|
|
465
690
|
drop();
|
|
466
691
|
});
|
|
467
|
-
const timer = setTimeout(drop,
|
|
692
|
+
const timer = setTimeout(drop, PAGE_MS + 80);
|
|
468
693
|
this.timers.add(timer);
|
|
469
694
|
}
|
|
470
695
|
// ── Navigation ─────────────────────────────────────────────────────────
|
|
471
696
|
/**
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
697
|
+
* The arrangement navigation works from: the one we're on the way to while a
|
|
698
|
+
* change is still settling, and the one on screen otherwise.
|
|
699
|
+
*
|
|
700
|
+
* Settling takes a moment more often than it looks: every `route.back()`
|
|
701
|
+
* travels through the browser's history and lands on a `popstate`, and an
|
|
702
|
+
* app-registered route guard may be async. Working from the committed
|
|
703
|
+
* arrangement in that window would make a second Escape aim at the panel the
|
|
704
|
+
* first one is already taking away — so two quick Escapes would peel one panel.
|
|
479
705
|
*/
|
|
480
|
-
|
|
481
|
-
return
|
|
706
|
+
intended() {
|
|
707
|
+
return this.intent ?? { stack: this.paths(), focus: this.$state.focus };
|
|
482
708
|
}
|
|
483
|
-
/**
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
709
|
+
/** The history `state` describing `arr` — exactly what `targetFor` reads back. */
|
|
710
|
+
stateFor(arr) {
|
|
711
|
+
return {
|
|
712
|
+
panels: arr.stack.slice(0, arr.focus),
|
|
713
|
+
parked: arr.stack.slice(arr.focus + 1),
|
|
714
|
+
pinned: this.pinnedPaths(),
|
|
715
|
+
};
|
|
488
716
|
}
|
|
489
|
-
/**
|
|
490
|
-
|
|
491
|
-
return this.
|
|
717
|
+
/** The paths of the pinned panels — the pin list history entries persist. */
|
|
718
|
+
pinnedPaths() {
|
|
719
|
+
return this.$state.live.filter((e) => e.$panel.pinned).map((e) => e.path);
|
|
492
720
|
}
|
|
493
721
|
/**
|
|
494
|
-
*
|
|
495
|
-
*
|
|
722
|
+
* Put a navigation to the router, or — while one is still settling — behind
|
|
723
|
+
* the one that is. Only the newest waits: each was worked out against
|
|
724
|
+
* {@link intended}, so the newest is the one that means what the user last
|
|
725
|
+
* asked for, and the one it displaces resolves `false`.
|
|
496
726
|
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
727
|
+
* A refusal empties the queue instead of running it: a navigation can still
|
|
728
|
+
* fail to land — an app-registered route guard vetoes it, or another one
|
|
729
|
+
* supersedes it — and what was queued behind it was worked out against the
|
|
730
|
+
* arrangement it would have produced.
|
|
731
|
+
*/
|
|
732
|
+
issue(target, run) {
|
|
733
|
+
this.intent = target;
|
|
734
|
+
if (this.settling) {
|
|
735
|
+
this.queued?.settle(false);
|
|
736
|
+
return new Promise((settle) => { this.queued = { run, settle }; });
|
|
737
|
+
}
|
|
738
|
+
return this.start(run);
|
|
739
|
+
}
|
|
740
|
+
start(run) {
|
|
741
|
+
const done = (ok) => {
|
|
742
|
+
this.settling = null;
|
|
743
|
+
const next = this.queued;
|
|
744
|
+
this.queued = null;
|
|
745
|
+
// The router applies a change (and runs Aberdeen's queue, so our own
|
|
746
|
+
// commit has happened) before it settles us, which is what lets the next
|
|
747
|
+
// one go straight out: it asks the guards of the panels it removes from
|
|
748
|
+
// the stack as it stands now, not the one it was queued against.
|
|
749
|
+
if (ok && next)
|
|
750
|
+
this.start(next.run).then(next.settle, () => next.settle(false));
|
|
751
|
+
else {
|
|
752
|
+
this.intent = null;
|
|
753
|
+
next?.settle(false);
|
|
754
|
+
}
|
|
755
|
+
return ok;
|
|
756
|
+
};
|
|
757
|
+
const settling = Promise.resolve(run()).then(done, (e) => { console.error(e); return done(false); });
|
|
758
|
+
this.settling = settling;
|
|
759
|
+
return settling;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Make the stack's `index`th panel current: the URL and the visible run move
|
|
763
|
+
* to it, while the panels right of it stay open, parked past the right edge
|
|
764
|
+
* of the viewport. Nothing closes; it is a history entry, so the browser's
|
|
765
|
+
* back button returns the focus to where it was. What a click on a
|
|
766
|
+
* breadcrumb — any link to an open panel — comes down to.
|
|
504
767
|
*/
|
|
505
|
-
|
|
506
|
-
|
|
768
|
+
focusAt(index, search, hash) {
|
|
769
|
+
const arr = this.intended();
|
|
770
|
+
if (index < 0 || index >= arr.stack.length || index === arr.focus)
|
|
507
771
|
return Promise.resolve(false);
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
hash: A.peek(route.current, "hash"),
|
|
517
|
-
state: { panels: target.slice(0, -1) },
|
|
518
|
-
}));
|
|
772
|
+
const target = { stack: arr.stack, focus: index };
|
|
773
|
+
const path = arr.stack[index];
|
|
774
|
+
return this.issue(target, () => {
|
|
775
|
+
// The panel gets its own last search and hash back, unless the link
|
|
776
|
+
// that brought us here carries its own.
|
|
777
|
+
const entry = this.$state.live.find((e) => e.path === path);
|
|
778
|
+
return route.go({ path, search: search ?? entry?.search, hash: hash ?? entry?.hash, state: this.stateFor(target) });
|
|
779
|
+
});
|
|
519
780
|
}
|
|
520
|
-
/**
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
781
|
+
/**
|
|
782
|
+
* One step back along the stack — what Escape does (`main()` calls this;
|
|
783
|
+
* it is not {@link PanelStack} API). At the stack's end this closes the
|
|
784
|
+
* current panel; mid-stack — with panels parked to the right — or when the
|
|
785
|
+
* panel holds {@link Panel.unsaved} work, the panel stays open and the
|
|
786
|
+
* focus just moves to the panel on its left, parking the one it leaves.
|
|
787
|
+
* Resolves `false` at the stack's start, where there is no left to go.
|
|
788
|
+
*/
|
|
789
|
+
back() {
|
|
790
|
+
return A.peek(() => {
|
|
791
|
+
const arr = this.intended();
|
|
792
|
+
if (arr.focus === arr.stack.length - 1 && !this.unsavedAt(arr.stack[arr.focus])) {
|
|
793
|
+
return this.closePath(arr.stack[arr.focus] ?? "");
|
|
794
|
+
}
|
|
795
|
+
if (arr.focus === 0)
|
|
796
|
+
return Promise.resolve(false);
|
|
797
|
+
return this.focusAt(arr.focus - 1);
|
|
798
|
+
});
|
|
524
799
|
}
|
|
525
|
-
/**
|
|
526
|
-
|
|
527
|
-
|
|
800
|
+
/**
|
|
801
|
+
* Close whichever panel is open at `path`, current or not — what
|
|
802
|
+
* {@link Panel.close}, {@link PanelStack.closePanel} and the crumb menu's
|
|
803
|
+
* Close come down to. `false` when that path isn't open, is the stack's
|
|
804
|
+
* only panel, or holds {@link Panel.unsaved} work — nothing may close an
|
|
805
|
+
* unsaved panel; the app clears the flag first, which is its explicit
|
|
806
|
+
* "this is now discardable".
|
|
807
|
+
*
|
|
808
|
+
* Closing the current panel at the stack's very end pops back through the
|
|
809
|
+
* browser's history to the entry beneath it, when it is there (restoring its
|
|
810
|
+
* scroll and search state); the arrangement is part of the match, so an
|
|
811
|
+
* entry where the closing panel was merely parked won't do. Every other
|
|
812
|
+
* close is a *splice*: the columns around the closed one keep their place
|
|
813
|
+
* and state (the commit reconciles by path). That still gets its own
|
|
814
|
+
* history entry, so the browser's back button restores the closed column
|
|
815
|
+
* like any other arrangement — which is why it goes through `route.go` here
|
|
816
|
+
* rather than through `navigate()`, whose "link to an open panel" check
|
|
817
|
+
* would turn it into a focus move.
|
|
818
|
+
*/
|
|
819
|
+
closePath(path) {
|
|
820
|
+
return A.peek(() => {
|
|
821
|
+
const arr = this.intended();
|
|
822
|
+
const index = arr.stack.indexOf(normalizePath(path));
|
|
823
|
+
if (index < 0 || arr.stack.length < 2 || this.unsavedAt(arr.stack[index]))
|
|
824
|
+
return Promise.resolve(false);
|
|
825
|
+
const stack = arr.stack.filter((_, i) => i !== index);
|
|
826
|
+
// Closing the current panel hands the focus to the panel on its left (or,
|
|
827
|
+
// at the stack's start, to the one that was parked beside it); closing
|
|
828
|
+
// any other panel moves the focus not at all.
|
|
829
|
+
const focus = index === arr.focus ? Math.max(0, index - 1) : arr.focus - (index < arr.focus ? 1 : 0);
|
|
830
|
+
const target = { stack, focus };
|
|
831
|
+
if (index === arr.focus && index === arr.stack.length - 1) {
|
|
832
|
+
// When no history entry matches and the current one is replaced
|
|
833
|
+
// instead, the panel beneath gets its stashed query back through the
|
|
834
|
+
// fallback (a match restores the matched entry's own). Pins can't
|
|
835
|
+
// ride the same way — a `state` in the fallback would be shadowed by
|
|
836
|
+
// the match target's — so they are re-stamped onto whatever entry we
|
|
837
|
+
// land on, the way `togglePin` writes them.
|
|
838
|
+
const beneath = this.$state.live.find((e) => e.path === stack[focus]);
|
|
839
|
+
const fallback = {};
|
|
840
|
+
if (beneath?.search)
|
|
841
|
+
fallback.search = beneath.search;
|
|
842
|
+
if (beneath?.hash)
|
|
843
|
+
fallback.hash = beneath.hash;
|
|
844
|
+
const pinned = stack.filter((p) => this.$state.live.find((e) => e.path === p)?.$panel.pinned === true);
|
|
845
|
+
return this.issue(target, () => Promise.resolve(route.back({ path: stack[focus], state: { panels: stack.slice(0, focus), parked: [] } }, fallback)).then((ok) => {
|
|
846
|
+
if (ok)
|
|
847
|
+
route.current.state.pinned = pinned;
|
|
848
|
+
return ok;
|
|
849
|
+
}));
|
|
850
|
+
}
|
|
851
|
+
const current = stack[focus];
|
|
852
|
+
const moved = current !== arr.stack[arr.focus];
|
|
853
|
+
return this.issue(target, () => {
|
|
854
|
+
// The current panel keeps its search params and hash: it isn't going
|
|
855
|
+
// anywhere, and `go()` would otherwise default them away. When the
|
|
856
|
+
// close *did* move the focus, the newly current panel gets its own back.
|
|
857
|
+
const entry = moved ? this.$state.live.find((e) => e.path === current) : undefined;
|
|
858
|
+
return route.go({
|
|
859
|
+
path: current,
|
|
860
|
+
search: moved ? entry?.search : { ...route.current.search },
|
|
861
|
+
hash: moved ? entry?.hash : route.current.hash,
|
|
862
|
+
state: this.stateFor(target),
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
});
|
|
528
866
|
}
|
|
529
867
|
/**
|
|
530
|
-
* Navigate to `href`. `
|
|
531
|
-
*
|
|
532
|
-
* the whole stack instead). `replace` swaps the
|
|
533
|
-
* stacking on top of it
|
|
868
|
+
* Navigate to `href`. `origin` is the path of the panel the link lives in, or
|
|
869
|
+
* `null` when it has none — a nav item, or a programmatic call, which builds
|
|
870
|
+
* the whole stack instead (see {@link deriveStack}). `replace` swaps the
|
|
871
|
+
* originating panel rather than stacking on top of it, and `beneath` says what
|
|
872
|
+
* the stack under the target is outright, for callers that know.
|
|
873
|
+
*
|
|
874
|
+
* Resolves the way every {@link PanelStack} method does: `true` once the
|
|
875
|
+
* navigation lands, `false` when it doesn't (already there counts as
|
|
876
|
+
* landed).
|
|
534
877
|
*/
|
|
535
|
-
navigate(href,
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
878
|
+
navigate(href, origin, replace = false, beneath) {
|
|
879
|
+
return A.peek(() => {
|
|
880
|
+
let url;
|
|
881
|
+
try {
|
|
882
|
+
url = new URL(href, location.href);
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
return Promise.resolve(false);
|
|
886
|
+
}
|
|
887
|
+
const path = normalizePath(url.pathname);
|
|
888
|
+
const search = Object.fromEntries(new URLSearchParams(url.search));
|
|
889
|
+
const hash = url.hash;
|
|
890
|
+
const arr = this.intended();
|
|
891
|
+
// A link to a panel that is already open is a return, not a navigation —
|
|
892
|
+
// a stack never holds the same path twice. Returning just moves the
|
|
893
|
+
// focus: the panels right of the target stay open, parked past the right
|
|
894
|
+
// edge, and nothing closes. That is the whole behaviour of a breadcrumb,
|
|
895
|
+
// which is exactly such a link.
|
|
896
|
+
const open = beneath ? -1 : arr.stack.indexOf(path);
|
|
897
|
+
if (open >= 0 && open !== arr.focus) {
|
|
898
|
+
return this.focusAt(open, url.search ? search : undefined, hash || undefined);
|
|
899
|
+
}
|
|
900
|
+
if (open >= 0) {
|
|
901
|
+
// The target is the panel we're already on. Going nowhere — but the link
|
|
902
|
+
// may still carry a different search or hash, which belong to the
|
|
903
|
+
// current panel: record that as a history entry, leaving the stack alone
|
|
904
|
+
// (the panel reconciles by path, so it isn't even redrawn).
|
|
905
|
+
if (url.search === location.search && (url.hash || "") === (location.hash || ""))
|
|
906
|
+
return Promise.resolve(true);
|
|
907
|
+
return this.issue(arr, () => route.go({ path, search, hash, state: this.stateFor(arr) }));
|
|
908
|
+
}
|
|
909
|
+
// A new panel: it opens at the stack's end and becomes current. The
|
|
910
|
+
// panels after the origin close — except pinned ones, which ride along,
|
|
911
|
+
// keeping their order, beneath the new panel (and unsaved ones, which
|
|
912
|
+
// the commit itself keeps, parked — see `propose`). Without an
|
|
913
|
+
// originating panel there is no stack to build on, so derive one — a
|
|
914
|
+
// nav click and a deep link to the same URL land identically (bar the
|
|
915
|
+
// pins, which a fresh tab doesn't have).
|
|
916
|
+
const originIndex = origin == null ? -1 : arr.stack.indexOf(origin);
|
|
917
|
+
// A stack never holds the same path twice (rendering reconciles by
|
|
918
|
+
// path), so a caller-supplied `beneath` is deduplicated, not just
|
|
919
|
+
// filtered against the target.
|
|
920
|
+
const base = beneath
|
|
921
|
+
? beneath.map(normalizePath).filter((p, i, all) => p !== path && all.indexOf(p) === i)
|
|
922
|
+
: originIndex < 0
|
|
923
|
+
? this.deriveStack(path).slice(0, -1)
|
|
924
|
+
: arr.stack.slice(0, replace ? originIndex : originIndex + 1);
|
|
925
|
+
// A replaced origin closes, pin or no pin: replacing is the panel's own
|
|
926
|
+
// doing, not somewhere else navigating over it.
|
|
927
|
+
const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
|
|
928
|
+
const target = { stack: [...under, path], focus: under.length };
|
|
929
|
+
return this.issue(target, () => route.go({ path, search, hash, state: this.stateFor(target) }));
|
|
930
|
+
});
|
|
572
931
|
}
|
|
573
|
-
/** Programmatic push/replace, with the
|
|
932
|
+
/** Programmatic push/replace, with the current panel as the implied origin. */
|
|
574
933
|
pushPath(path, replace) {
|
|
575
|
-
|
|
934
|
+
return A.peek(() => {
|
|
935
|
+
const arr = this.intended();
|
|
936
|
+
return this.navigate(path, arr.stack[arr.focus] ?? null, replace);
|
|
937
|
+
});
|
|
576
938
|
}
|
|
577
939
|
// ── Link interception ──────────────────────────────────────────────────
|
|
578
940
|
/**
|
|
579
941
|
* Link handling through `route.interceptLinks()`, whose handler hook hands us
|
|
580
942
|
* the anchor so we can decide what the click *means*: the originating
|
|
581
|
-
* `.s-panel` (which decides what the click truncates), `data-panel
|
|
582
|
-
* and return-to-an-open-panel semantics. The exclusion rules
|
|
583
|
-
* downloads, modified clicks, external URLs) live in Aberdeen; the
|
|
584
|
-
* guards run in `checkChange` when our navigation reaches the router.
|
|
943
|
+
* `.s-panel` (which decides what the click truncates), the `data-panel`
|
|
944
|
+
* attribute, and return-to-an-open-panel semantics. The exclusion rules
|
|
945
|
+
* (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
|
|
946
|
+
* close guards run in `checkChange` when our navigation reaches the router.
|
|
947
|
+
*
|
|
948
|
+
* `data-panel` names which of the three {@link PanelStack} navigations the
|
|
949
|
+
* click is: `push` (the default), `replace`, or `open`, which drops the
|
|
950
|
+
* originating panel so the target arrives with its own stack beneath it,
|
|
951
|
+
* exactly as a nav item's link does. An unrecognised value is a `push`.
|
|
585
952
|
*/
|
|
586
953
|
interceptLinks() {
|
|
587
954
|
route.interceptLinks((url, anchor) => {
|
|
588
|
-
const
|
|
589
|
-
const
|
|
590
|
-
this.
|
|
955
|
+
const mode = anchor.getAttribute("data-panel");
|
|
956
|
+
const panel = mode === "open" ? null : anchor.closest(".s-panel");
|
|
957
|
+
const origin = panel ? this.$state.live.find((entry) => entry.el === panel) : undefined;
|
|
958
|
+
void this.navigate(url.href, origin?.path ?? null, mode === "replace");
|
|
591
959
|
return true;
|
|
592
960
|
});
|
|
593
961
|
}
|
|
962
|
+
// ── What the shell's top bar asks ──────────────────────────────────────
|
|
963
|
+
// The {@link PanelStack} face, where all the documentation lives. These are
|
|
964
|
+
// the *queries* of the "queries subscribe, commands peek" rule on `$state`:
|
|
965
|
+
// read one in a scope and that scope follows the stack's shape.
|
|
966
|
+
get currentPanel() {
|
|
967
|
+
return this.$state.live[this.$state.focus]?.$panel;
|
|
968
|
+
}
|
|
969
|
+
get panels() {
|
|
970
|
+
return this.$state.live.map((e) => e.$panel);
|
|
971
|
+
}
|
|
972
|
+
get currentPanelIndex() {
|
|
973
|
+
return this.$state.focus;
|
|
974
|
+
}
|
|
975
|
+
pushPanel(path) {
|
|
976
|
+
return this.pushPath(path, false);
|
|
977
|
+
}
|
|
978
|
+
replacePanel(path) {
|
|
979
|
+
return this.pushPath(path, true);
|
|
980
|
+
}
|
|
981
|
+
openPanelStack(path, beneath) {
|
|
982
|
+
return this.navigate(path, null, false, beneath);
|
|
983
|
+
}
|
|
984
|
+
closePanel(path) {
|
|
985
|
+
return A.peek(() => {
|
|
986
|
+
const arr = this.intended();
|
|
987
|
+
return this.closePath(path ?? arr.stack[arr.focus] ?? "");
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* The breadcrumb stack, drawn by `main()` into the top bar: every open
|
|
992
|
+
* panel, oldest first, the ones on screen right now in bold, pinned ones
|
|
993
|
+
* wearing their pin. Every crumb but the current panel's is a plain link to
|
|
994
|
+
* that panel, and a link to an open panel is a focus move (see `navigate`) —
|
|
995
|
+
* so clicking along the stack closes nothing, in either direction, and the
|
|
996
|
+
* panels right of the current one wait just past the viewport's edge.
|
|
997
|
+
* Right-click (or long-press) offers pinning, and closing just that one
|
|
998
|
+
* panel — the close that splices it out of the middle when it isn't last.
|
|
999
|
+
*/
|
|
1000
|
+
drawCrumbs() {
|
|
1001
|
+
// The very same row `S.tabs` puts its tab strip in: it scrolls when the
|
|
1002
|
+
// stack outgrows the bar, and shows a ‹ / › over whichever end still has
|
|
1003
|
+
// crumbs to reach — which a mouse can use, unlike a bare scroll area.
|
|
1004
|
+
scrollStrip({
|
|
1005
|
+
attrs: ".s-crumbs role=navigation aria-label=Breadcrumbs",
|
|
1006
|
+
content: () => {
|
|
1007
|
+
A(() => {
|
|
1008
|
+
const paths = this.panels.map((p) => p.path);
|
|
1009
|
+
const focus = this.currentPanelIndex;
|
|
1010
|
+
let currentEl;
|
|
1011
|
+
for (let i = 0; i < paths.length; i++) {
|
|
1012
|
+
if (i)
|
|
1013
|
+
sepIcon({ size: "0.85em", attrs: ".s-crumb-sep" });
|
|
1014
|
+
const el = this.drawCrumb(paths[i], i, i === focus);
|
|
1015
|
+
if (i === focus)
|
|
1016
|
+
currentEl = el;
|
|
1017
|
+
}
|
|
1018
|
+
// Keep the panel you are on in view once this pass has been laid out.
|
|
1019
|
+
requestAnimationFrame(() => { if (currentEl)
|
|
1020
|
+
revealInStrip(currentEl); });
|
|
1021
|
+
});
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
drawCrumb(path, index, current) {
|
|
1026
|
+
// Safe to close over: the crumb list is rebuilt whenever the stack (or
|
|
1027
|
+
// its focus) changes, so this entry is `paths[index]`'s for the crumb's
|
|
1028
|
+
// whole life.
|
|
1029
|
+
const entry = this.$state.live[index];
|
|
1030
|
+
// A real link, for the panel it names — so it has an address to hover, to
|
|
1031
|
+
// middle-click, to copy. No click handling of its own: the shell's link
|
|
1032
|
+
// handling already makes any link to an open panel the focus move a crumb
|
|
1033
|
+
// should be (see `navigate`). The panel you are on is a span: nowhere to go.
|
|
1034
|
+
return A(current ? "span.s-crumb aria-current=page" : "a.s-crumb", () => {
|
|
1035
|
+
if (!current)
|
|
1036
|
+
A("href=", path);
|
|
1037
|
+
// Bold = on screen right now, so the stack also says which of its panels
|
|
1038
|
+
// are the visible columns — not just which one is current.
|
|
1039
|
+
A(() => { if (entry?.$panel.visible)
|
|
1040
|
+
A(".s-crumb-on"); });
|
|
1041
|
+
// The ● of unsaved work — the mark that nothing can close this panel —
|
|
1042
|
+
// and the pin of a panel that navigation elsewhere won't close.
|
|
1043
|
+
A(() => { if (entry?.$panel.unsaved)
|
|
1044
|
+
dotIcon({ size: "0.45em", attrs: ".s-crumb-unsaved" }); });
|
|
1045
|
+
A(() => { if (entry?.$panel.pinned)
|
|
1046
|
+
pinIcon({ size: "0.85em", attrs: ".s-crumb-pin" }); });
|
|
1047
|
+
// `||`, not `??`: the root path's last segment is the empty string.
|
|
1048
|
+
A(() => { A("#", entry?.$panel.title ?? entry?.$ui.fallback ?? (path.split("/").pop() || path)); });
|
|
1049
|
+
// Taking over right-click means taking the browser's link menu away, so
|
|
1050
|
+
// the two entries anyone actually reaches for on a link come first,
|
|
1051
|
+
// where that menu would have had them, and the shell's own verbs sit
|
|
1052
|
+
// below the rule.
|
|
1053
|
+
addContextMenu({ items: [
|
|
1054
|
+
{
|
|
1055
|
+
// A real new tab, so it arrives cold and builds its own stack
|
|
1056
|
+
// from the path — exactly what the same link middle-clicked does.
|
|
1057
|
+
label: "Open in new tab",
|
|
1058
|
+
icon: newTabIcon,
|
|
1059
|
+
click: () => { window.open(path, "_blank", "noopener"); },
|
|
1060
|
+
},
|
|
1061
|
+
{
|
|
1062
|
+
label: "Copy link",
|
|
1063
|
+
icon: linkIcon,
|
|
1064
|
+
click: () => void copyLink(path),
|
|
1065
|
+
},
|
|
1066
|
+
{ separator: true },
|
|
1067
|
+
{
|
|
1068
|
+
label: () => { A(() => { A("#", entry?.$panel.pinned ? "Unpin" : "Pin"); }); },
|
|
1069
|
+
icon: () => { A(() => { (entry?.$panel.pinned ? pinOffIcon : pinIcon)(); }); },
|
|
1070
|
+
click: () => { if (entry)
|
|
1071
|
+
this.togglePin(entry); },
|
|
1072
|
+
},
|
|
1073
|
+
{
|
|
1074
|
+
label: "Close",
|
|
1075
|
+
icon: closeIcon,
|
|
1076
|
+
// Greyed out while the panel holds unsaved work: nothing may
|
|
1077
|
+
// close it (`closePath` would refuse anyway). Read here, in the
|
|
1078
|
+
// crumb's own scope, so the flag flipping redraws the crumb —
|
|
1079
|
+
// the ● above and this menu entry stay one truth.
|
|
1080
|
+
disabled: entry?.$panel.unsaved === true,
|
|
1081
|
+
click: () => void this.closePath(path),
|
|
1082
|
+
},
|
|
1083
|
+
] });
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Flip a panel's pin (see {@link Panel.pinned}). The flag lives on the panel;
|
|
1088
|
+
* the current history entry's snapshot is rewritten too, so a reload keeps
|
|
1089
|
+
* the pin — a same-panel state tweak, which the router applies unguarded.
|
|
1090
|
+
*/
|
|
1091
|
+
togglePin(entry) {
|
|
1092
|
+
entry.$panel.pinned = !entry.$panel.pinned || undefined;
|
|
1093
|
+
route.current.state.pinned = this.pinnedPaths();
|
|
1094
|
+
}
|
|
594
1095
|
// ── document.title ─────────────────────────────────────────────────────
|
|
595
|
-
/**
|
|
1096
|
+
/**
|
|
1097
|
+
* `"<panel title> · <app title>"`, kept in sync with the current panel — and
|
|
1098
|
+
* prefixed `"• "` while *any* open panel holds unsaved work, the way editors
|
|
1099
|
+
* mark a dirty document. Any panel, not just the current one: the risk of
|
|
1100
|
+
* losing the work is tab-wide, so the mark on the tab is too.
|
|
1101
|
+
*/
|
|
596
1102
|
watchTitle() {
|
|
597
1103
|
const original = document.title;
|
|
598
1104
|
A(() => {
|
|
599
|
-
const entry = this.
|
|
600
|
-
const
|
|
1105
|
+
const entry = this.$state.live[this.$state.focus];
|
|
1106
|
+
const panelTitle = entry?.$panel.title ?? entry?.$ui.fallback;
|
|
601
1107
|
const appTitle = typeof this.opts.title === "string" ? this.opts.title : undefined;
|
|
602
|
-
|
|
1108
|
+
// Reading the stack re-runs this when its shape changes; each panel's
|
|
1109
|
+
// own `unsaved` (up to the first dirty one) does the rest.
|
|
1110
|
+
const dirty = this.$state.live.some((e) => e.$panel.unsaved);
|
|
1111
|
+
const title = panelTitle && appTitle ? `${panelTitle} · ${appTitle}` : panelTitle || appTitle;
|
|
603
1112
|
if (title)
|
|
604
|
-
document.title = title;
|
|
1113
|
+
document.title = (dirty ? "• " : "") + title;
|
|
605
1114
|
});
|
|
606
1115
|
A.clean(() => { document.title = original; });
|
|
607
1116
|
}
|
|
1117
|
+
/**
|
|
1118
|
+
* While any open panel holds unsaved work, closing the tab — or navigating
|
|
1119
|
+
* the whole browser away — runs into the browser's own are-you-sure. When
|
|
1120
|
+
* the user stays, the unsaved panel is brought back on screen if it wasn't,
|
|
1121
|
+
* so what held the tab is in front of them rather than parked out of sight.
|
|
1122
|
+
*/
|
|
1123
|
+
guardTabClose() {
|
|
1124
|
+
if (typeof window === "undefined")
|
|
1125
|
+
return;
|
|
1126
|
+
let leaving = false;
|
|
1127
|
+
const onHide = () => { leaving = true; };
|
|
1128
|
+
const onBeforeUnload = (e) => {
|
|
1129
|
+
// Being asked again means we weren't gone after all (a bfcache restore).
|
|
1130
|
+
leaving = false;
|
|
1131
|
+
const dirty = this.$state.live.find((entry) => entry.$panel.unsaved);
|
|
1132
|
+
if (!dirty)
|
|
1133
|
+
return;
|
|
1134
|
+
e.preventDefault();
|
|
1135
|
+
e.returnValue = true; // Chrome/Edge < 119
|
|
1136
|
+
// This task only ever amounts to anything if the user cancels: a
|
|
1137
|
+
// confirmed leave unloads the document (`pagehide`) first.
|
|
1138
|
+
const path = dirty.path;
|
|
1139
|
+
setTimeout(() => {
|
|
1140
|
+
if (leaving)
|
|
1141
|
+
return;
|
|
1142
|
+
const entry = this.$state.live.find((live) => live.path === path);
|
|
1143
|
+
if (entry && !entry.$panel.visible)
|
|
1144
|
+
void this.focusAt(this.intended().stack.indexOf(path));
|
|
1145
|
+
}, 0);
|
|
1146
|
+
};
|
|
1147
|
+
// Registered only while a panel actually holds unsaved work: a page with a
|
|
1148
|
+
// `beforeunload` listener is shut out of the browser's back/forward cache,
|
|
1149
|
+
// and that is a tax every navigation in the app would otherwise pay — for a
|
|
1150
|
+
// guard that almost never has anything to guard.
|
|
1151
|
+
A(() => {
|
|
1152
|
+
if (!this.$state.live.some((entry) => entry.$panel.unsaved))
|
|
1153
|
+
return;
|
|
1154
|
+
window.addEventListener("beforeunload", onBeforeUnload);
|
|
1155
|
+
window.addEventListener("pagehide", onHide);
|
|
1156
|
+
A.clean(() => {
|
|
1157
|
+
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
1158
|
+
window.removeEventListener("pagehide", onHide);
|
|
1159
|
+
});
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
608
1162
|
// ── Rendering ──────────────────────────────────────────────────────────
|
|
609
1163
|
/**
|
|
610
|
-
* Draw the
|
|
1164
|
+
* Draw the column viewport into the current element. Called by `main()`.
|
|
611
1165
|
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
1166
|
+
* A column is the panel's own content, plus the one bit of chrome the shell
|
|
1167
|
+
* places for it: its {@link Panel.actions}, in a strip on wide shells and in
|
|
1168
|
+
* the top bar on narrow ones (see {@link drawActions}).
|
|
615
1169
|
*/
|
|
616
|
-
|
|
1170
|
+
drawColumns() {
|
|
617
1171
|
const container = A("div.s-panels role=main", () => {
|
|
618
1172
|
// Published before the first panel draws, rather than from the return
|
|
619
1173
|
// value below: a panel sizes itself from the shell's measurements (see
|
|
620
1174
|
// `measure`), and the first ones do that while this very call is still
|
|
621
1175
|
// running. `A()` without arguments is "the element we're in".
|
|
622
1176
|
this.containerEl = A();
|
|
623
|
-
|
|
1177
|
+
// Mounted and unmounted by path (see `$open`); a panel's DOM position
|
|
1178
|
+
// among its siblings is its creation order, which is all the layering
|
|
1179
|
+
// needs — `layout()` places and stacks the columns itself.
|
|
1180
|
+
A.onEach(this.$open, (entry) => this.drawPanel(entry), (entry) => entry.order);
|
|
624
1181
|
});
|
|
625
1182
|
if (typeof ResizeObserver !== "undefined") {
|
|
626
1183
|
const ro = new ResizeObserver(() => this.layout());
|
|
@@ -636,45 +1193,58 @@ export class PanelController {
|
|
|
636
1193
|
this.containerEl = undefined; });
|
|
637
1194
|
this.scheduleLayout();
|
|
638
1195
|
}
|
|
639
|
-
drawPanel(
|
|
640
|
-
const entry = this.byId.get(id);
|
|
641
|
-
if (!entry)
|
|
642
|
-
return;
|
|
1196
|
+
drawPanel(entry) {
|
|
643
1197
|
let el;
|
|
644
1198
|
// How much room the panel wants, resolved *before* its content is drawn: an
|
|
645
1199
|
// element that arrives without a width has no box for its content to measure
|
|
646
1200
|
// itself against until the next frame's layout pass, which is a frame too
|
|
647
1201
|
// late for anything that sizes itself from its container. So the panel is
|
|
648
|
-
// created at the width the window gives
|
|
649
|
-
// says otherwise. Reactively, too: a
|
|
1202
|
+
// created at the width the window gives it — the "full" width until the panel
|
|
1203
|
+
// says otherwise. Reactively, too: a panel that changes its mind later (when
|
|
650
1204
|
// its data arrives, say) reflows in place rather than being redrawn, and the
|
|
651
1205
|
// columns beside it slide over to make room.
|
|
652
1206
|
A(() => {
|
|
653
|
-
const asked = entry.$
|
|
654
|
-
entry.
|
|
655
|
-
const width = this.roomFor(entry.
|
|
1207
|
+
const asked = entry.$panel.maxWidth;
|
|
1208
|
+
entry.maxWidth = asked === "half" || asked === "screen" ? asked : "full";
|
|
1209
|
+
const width = this.roomFor(entry.maxWidth);
|
|
656
1210
|
if (!width)
|
|
657
1211
|
return;
|
|
658
1212
|
entry.width = width;
|
|
1213
|
+
// Published to the panel too, so a handler can read the box it is about
|
|
1214
|
+
// to draw into without measuring it.
|
|
1215
|
+
if (A.peek(entry.$panel, "width") !== width)
|
|
1216
|
+
entry.$panel.width = width;
|
|
659
1217
|
// The first run has no element to put it on yet — it's created with this
|
|
660
|
-
// width, just below. Later runs are the
|
|
1218
|
+
// width, just below. Later runs are the panel changing its mind.
|
|
661
1219
|
if (!el)
|
|
662
1220
|
return;
|
|
663
1221
|
el.style.width = `${width}px`;
|
|
664
1222
|
this.scheduleLayout();
|
|
665
1223
|
});
|
|
666
1224
|
el = A(`section.s-panel${entry.width ? ` w:${entry.width}px` : ""}`, "destroy=", (node) => this.playExit(entry, node), () => {
|
|
667
|
-
|
|
668
|
-
|
|
1225
|
+
// The actions strip, in its own scope: chrome may redraw freely — when
|
|
1226
|
+
// the panel changes its actions, when the shell crosses the narrow
|
|
1227
|
+
// threshold — but the body below never may.
|
|
1228
|
+
A(() => this.drawActions(entry));
|
|
1229
|
+
A("div.s-content", () => {
|
|
1230
|
+
entry.draw(entry.$panel);
|
|
669
1231
|
// After the content, so there is something to scroll when restoring.
|
|
670
1232
|
route.persistScroll(entry.path);
|
|
1233
|
+
// A panel that named itself is done; one that didn't lends its
|
|
1234
|
+
// first line of text (the DOM is already built — Aberdeen draws
|
|
1235
|
+
// synchronously) to the crumbs and document.title, so neither
|
|
1236
|
+
// ever goes blank. Peeked: a rename must not redraw the panel.
|
|
1237
|
+
if (A.peek(entry.$panel, "title") == null) {
|
|
1238
|
+
const text = firstText(A());
|
|
1239
|
+
if (text && A.peek(entry.$ui, "fallback") !== text)
|
|
1240
|
+
entry.$ui.fallback = text;
|
|
1241
|
+
}
|
|
671
1242
|
});
|
|
672
|
-
watchVerticalOverflow(contentEl);
|
|
673
1243
|
// The loading hint, in its own scope so flipping the flag doesn't
|
|
674
1244
|
// redraw the panel's content. Held-back panels show nothing yet: they
|
|
675
1245
|
// are still parked off screen, waiting to slide in with real content.
|
|
676
1246
|
A(() => {
|
|
677
|
-
if (!entry.$
|
|
1247
|
+
if (!entry.$panel.loading || entry.$ui.holding)
|
|
678
1248
|
return;
|
|
679
1249
|
A("div.s-panel-loading aria-hidden=true", () => { A("i"); A("i"); A("i"); });
|
|
680
1250
|
});
|
|
@@ -691,11 +1261,23 @@ export class PanelController {
|
|
|
691
1261
|
entry.el = undefined; });
|
|
692
1262
|
// A held-back panel that finishes loading gets to play its enter animation.
|
|
693
1263
|
A(() => {
|
|
694
|
-
void entry.$
|
|
1264
|
+
void entry.$panel.loading;
|
|
695
1265
|
this.scheduleLayout();
|
|
696
1266
|
});
|
|
697
1267
|
this.scheduleLayout();
|
|
698
1268
|
}
|
|
1269
|
+
/**
|
|
1270
|
+
* The one bit of column chrome the shell draws: the panel's actions, in a
|
|
1271
|
+
* quiet strip above the scroll area — and only while the shell is wide, the
|
|
1272
|
+
* top bar carrying them otherwise. Everything else in a column is the panel's
|
|
1273
|
+
* own content: a screen that wants a heading or a card draws them itself.
|
|
1274
|
+
* Going back isn't here either — that is the breadcrumbs' job, in the bar.
|
|
1275
|
+
*/
|
|
1276
|
+
drawActions(entry) {
|
|
1277
|
+
if (this.opts.$shell.narrow || entry.$panel.actions == null)
|
|
1278
|
+
return;
|
|
1279
|
+
A("div.s-panel-actions", () => drawSlot(entry.$panel.actions));
|
|
1280
|
+
}
|
|
699
1281
|
// ── Layout engine ──────────────────────────────────────────────────────
|
|
700
1282
|
scheduleLayout() {
|
|
701
1283
|
if (this.layoutQueued)
|
|
@@ -708,7 +1290,7 @@ export class PanelController {
|
|
|
708
1290
|
}
|
|
709
1291
|
/**
|
|
710
1292
|
* Measure the shell, and with it the width the window gives a panel of each
|
|
711
|
-
* layout. Measured on the *shell*, not on the
|
|
1293
|
+
* layout. Measured on the *shell*, not on the column region: the region's width
|
|
712
1294
|
* is the layout engine's own output, so reading it back would nail the layout
|
|
713
1295
|
* to whatever it happened to be a frame ago. Fractional widths throughout — a
|
|
714
1296
|
* rounded column edge would drift a pixel away from the chrome above it.
|
|
@@ -732,24 +1314,24 @@ export class PanelController {
|
|
|
732
1314
|
if (child !== container)
|
|
733
1315
|
chrome += child.getBoundingClientRect().width;
|
|
734
1316
|
}
|
|
735
|
-
// The standard
|
|
1317
|
+
// The standard panel is SHELL_PX wide, capped by the window; what it leaves
|
|
736
1318
|
// beside the sidebar is the *standard* content area. Widths are a pure
|
|
737
1319
|
// function of the window — never of what else is open — so a panel NEVER
|
|
738
1320
|
// resizes because a neighbour came or went; only a window resize (the
|
|
739
1321
|
// snap pass in `layout`) changes them:
|
|
740
|
-
// - "
|
|
741
|
-
// - "
|
|
742
|
-
//
|
|
743
|
-
// - "
|
|
1322
|
+
// - "full" fills the standard content area exactly;
|
|
1323
|
+
// - "half" is half of it whenever that half is still a usable column, and
|
|
1324
|
+
// the whole of it on narrower screens;
|
|
1325
|
+
// - "screen" ignores the standard width and takes everything the window
|
|
744
1326
|
// has — which also means nothing ever fits beside it.
|
|
745
|
-
const
|
|
746
|
-
const
|
|
1327
|
+
const full = Math.max(0, Math.min(SHELL_PX, total) - chrome);
|
|
1328
|
+
const halved = full / 2;
|
|
747
1329
|
return {
|
|
748
1330
|
total,
|
|
749
1331
|
chrome,
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
1332
|
+
half: halved >= PAIR_MIN_PX ? halved : full,
|
|
1333
|
+
full,
|
|
1334
|
+
screen: Math.max(0, total - chrome),
|
|
753
1335
|
};
|
|
754
1336
|
}
|
|
755
1337
|
/**
|
|
@@ -761,9 +1343,9 @@ export class PanelController {
|
|
|
761
1343
|
geometry() {
|
|
762
1344
|
return (this.geom ??= this.measure());
|
|
763
1345
|
}
|
|
764
|
-
/** How wide a panel
|
|
765
|
-
roomFor(
|
|
766
|
-
return this.geometry()?.[
|
|
1346
|
+
/** How wide a panel asking for this is, right now; 0 while the shell can't be measured. */
|
|
1347
|
+
roomFor(maxWidth) {
|
|
1348
|
+
return this.geometry()?.[maxWidth] ?? 0;
|
|
767
1349
|
}
|
|
768
1350
|
/**
|
|
769
1351
|
* Size and position every panel, and publish the width of the whole ensemble
|
|
@@ -778,11 +1360,15 @@ export class PanelController {
|
|
|
778
1360
|
const shell = container?.closest(".s-main");
|
|
779
1361
|
if (!container || !shell)
|
|
780
1362
|
return;
|
|
781
|
-
|
|
1363
|
+
// This pass always runs from a fresh frame (rAF, a ResizeObserver) — no
|
|
1364
|
+
// reactive scope is active, so the stack and the panels are read plainly:
|
|
1365
|
+
// nothing here can subscribe to anything.
|
|
1366
|
+
const live = this.$state.live;
|
|
1367
|
+
const n = live.length;
|
|
782
1368
|
// A panel that hasn't drawn yet has no width to contribute, which would make
|
|
783
1369
|
// this pass's arithmetic (and any enter animation it triggers) meaningless.
|
|
784
1370
|
// Every mount schedules another pass, so simply wait for it.
|
|
785
|
-
if (!n ||
|
|
1371
|
+
if (!n || live.some((entry) => !entry.el))
|
|
786
1372
|
return;
|
|
787
1373
|
// This pass measures afresh — it is the one thing that runs after a resize.
|
|
788
1374
|
this.geom = undefined;
|
|
@@ -799,32 +1385,34 @@ export class PanelController {
|
|
|
799
1385
|
this.lastBodyW = geom.total;
|
|
800
1386
|
shell.classList.add("s-shell-snap");
|
|
801
1387
|
}
|
|
802
|
-
const width = (entry) => geom[entry.
|
|
803
|
-
// The visible run: as many
|
|
804
|
-
//
|
|
805
|
-
|
|
806
|
-
|
|
1388
|
+
const width = (entry) => geom[entry.maxWidth];
|
|
1389
|
+
// The visible run: as many columns as the window fits, at the sizes the
|
|
1390
|
+
// window gives them, ending at the current panel — which always shows.
|
|
1391
|
+
// Panels beyond it are parked past the right edge (see phase 1).
|
|
1392
|
+
const cur = Math.min(this.$state.focus, n - 1);
|
|
1393
|
+
let first = cur;
|
|
1394
|
+
let runSum = width(live[cur]);
|
|
807
1395
|
if (stacking) {
|
|
808
|
-
for (let i =
|
|
809
|
-
const sum = runSum +
|
|
810
|
-
if (sum > geom.
|
|
1396
|
+
for (let i = cur - 1; i >= 0; i--) {
|
|
1397
|
+
const sum = runSum + width(live[i]);
|
|
1398
|
+
if (sum > geom.screen)
|
|
811
1399
|
break;
|
|
812
1400
|
runSum = sum;
|
|
813
1401
|
first = i;
|
|
814
1402
|
}
|
|
815
1403
|
}
|
|
816
1404
|
// The content area holds the run, but is never smaller than the standard
|
|
817
|
-
//
|
|
1405
|
+
// panel (a lone small leaves its other half open — which is exactly where
|
|
818
1406
|
// the next small lands, without anything on screen moving) and never
|
|
819
|
-
// wider than the window. So the
|
|
1407
|
+
// wider than the window. So the panel is the familiar 1280px until extra
|
|
820
1408
|
// columns genuinely fit, and stretches — centred — to hold the ones that
|
|
821
|
-
// do; with a "
|
|
822
|
-
const area = Math.min(geom.
|
|
823
|
-
for (let i = first; i
|
|
824
|
-
|
|
1409
|
+
// do; with a "screen" up that's the window's edges.
|
|
1410
|
+
const area = Math.min(geom.screen, Math.max(geom.full, runSum));
|
|
1411
|
+
for (let i = first; i <= cur; i++)
|
|
1412
|
+
live[i].width = width(live[i]);
|
|
825
1413
|
// Panels that have never been visible get their would-be width too, so a
|
|
826
1414
|
// reveal doesn't start from nothing.
|
|
827
|
-
for (const entry of
|
|
1415
|
+
for (const entry of live) {
|
|
828
1416
|
if (!entry.width)
|
|
829
1417
|
entry.width = width(entry);
|
|
830
1418
|
}
|
|
@@ -841,27 +1429,37 @@ export class PanelController {
|
|
|
841
1429
|
const fresh = [];
|
|
842
1430
|
let x = 0;
|
|
843
1431
|
for (let i = 0; i < n; i++) {
|
|
844
|
-
const entry =
|
|
1432
|
+
const entry = live[i];
|
|
845
1433
|
const el = entry.el;
|
|
846
|
-
const shown = i >= first;
|
|
847
|
-
// Visible
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
|
|
1434
|
+
const shown = i >= first && i <= cur;
|
|
1435
|
+
// Visible columns tile the content area, left to right. Panels crowded
|
|
1436
|
+
// out from under the run rest at its left edge; panels beyond the
|
|
1437
|
+
// current panel park just past its right edge — both keep their last
|
|
1438
|
+
// width. Deeper panels layer over shallower ones, each on the odd
|
|
1439
|
+
// layer for its depth (see LAYER_STEP).
|
|
1440
|
+
place(el, shown ? x : i > cur ? area : 0, entry.width, LAYER_STEP * i + 1);
|
|
1441
|
+
// What `$panel.visible` and `$panel.width` report: this pass is the one
|
|
1442
|
+
// thing that knows them, window resizes included. Written only on a
|
|
1443
|
+
// change, so per-panel UI hanging off them isn't rebuilt by every pass.
|
|
1444
|
+
if (entry.$panel.visible !== shown)
|
|
1445
|
+
entry.$panel.visible = shown;
|
|
1446
|
+
if (entry.$panel.width !== entry.width)
|
|
1447
|
+
entry.$panel.width = entry.width;
|
|
852
1448
|
if (shown)
|
|
853
|
-
x += entry.width
|
|
1449
|
+
x += entry.width;
|
|
854
1450
|
el.classList.toggle("s-panel-sep", shown && i > first);
|
|
855
|
-
//
|
|
856
|
-
// rendered at all — but they keep their DOM, and
|
|
857
|
-
|
|
1451
|
+
// Off-screen panels fade out over the edge they park at and, once
|
|
1452
|
+
// faded, stop being rendered at all — but they keep their DOM, and
|
|
1453
|
+
// their scroll position.
|
|
1454
|
+
el.classList.toggle("s-panel-hidden", i < first);
|
|
1455
|
+
el.classList.toggle("s-panel-parked", i > cur);
|
|
858
1456
|
el.toggleAttribute("inert", !shown);
|
|
859
1457
|
if (entry.placed)
|
|
860
1458
|
continue;
|
|
861
1459
|
fresh.push(entry);
|
|
862
1460
|
// A panel that mounts while still fetching holds here for a moment, so
|
|
863
1461
|
// it can enter with real content instead of an empty column.
|
|
864
|
-
if (!
|
|
1462
|
+
if (!entry.$panel.loading || entry.holdDone)
|
|
865
1463
|
entry.$ui.holding = false;
|
|
866
1464
|
else if (!entry.$ui.holding) {
|
|
867
1465
|
entry.$ui.holding = true;
|
|
@@ -908,124 +1506,38 @@ function place(el, x, width, z) {
|
|
|
908
1506
|
el.style.width = `${width}px`;
|
|
909
1507
|
el.style.zIndex = String(z);
|
|
910
1508
|
}
|
|
911
|
-
// ─── Guards ──────────────────────────────────────────────────────────────────
|
|
912
|
-
/**
|
|
913
|
-
* Ask every panel being removed, deepest first, whether it may go. Returns a
|
|
914
|
-
* plain boolean when no guard needs awaiting, so the common case stays
|
|
915
|
-
* synchronous (and screenshots stay deterministic).
|
|
916
|
-
*/
|
|
917
|
-
function runGuards(removed) {
|
|
918
|
-
const list = [...removed].reverse();
|
|
919
|
-
let i = 0;
|
|
920
|
-
const step = () => {
|
|
921
|
-
while (i < list.length) {
|
|
922
|
-
const guard = A.peek(list[i++].$page, "requestClose");
|
|
923
|
-
if (!guard)
|
|
924
|
-
continue;
|
|
925
|
-
let verdict;
|
|
926
|
-
try {
|
|
927
|
-
verdict = guard();
|
|
928
|
-
}
|
|
929
|
-
catch (e) {
|
|
930
|
-
console.error(e);
|
|
931
|
-
return false;
|
|
932
|
-
}
|
|
933
|
-
if (verdict === false)
|
|
934
|
-
return false;
|
|
935
|
-
if (verdict !== true)
|
|
936
|
-
return Promise.resolve(verdict).then((ok) => (ok === false ? false : step()));
|
|
937
|
-
}
|
|
938
|
-
return true;
|
|
939
|
-
};
|
|
940
|
-
return step();
|
|
941
|
-
}
|
|
942
1509
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
943
1510
|
function sameStack(a, b) {
|
|
944
1511
|
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
945
1512
|
}
|
|
946
|
-
function drawDefaultNotFound($page) {
|
|
947
|
-
A("p fg:$s-muted", () => A("#", `No page at ${$page.path}`));
|
|
948
|
-
}
|
|
949
|
-
/**
|
|
950
|
-
* Toggle `.s-scroll-y` on `el` whenever a vertical scrollbar is eating into its
|
|
951
|
-
* width, so CSS can inset the bar from the panel's edge. Same trick (and the
|
|
952
|
-
* same reasoning) as content mode's `watchVerticalOverflow` in main.ts.
|
|
953
|
-
*/
|
|
954
|
-
function watchVerticalOverflow(el) {
|
|
955
|
-
if (typeof ResizeObserver === "undefined")
|
|
956
|
-
return;
|
|
957
|
-
const update = () => el.classList.toggle("s-scroll-y", el.offsetWidth > el.clientWidth);
|
|
958
|
-
const ro = new ResizeObserver(update);
|
|
959
|
-
ro.observe(el);
|
|
960
|
-
if (el.firstElementChild)
|
|
961
|
-
ro.observe(el.firstElementChild);
|
|
962
|
-
update();
|
|
963
|
-
A.clean(() => ro.disconnect());
|
|
964
|
-
}
|
|
965
|
-
// ─── Public helpers ──────────────────────────────────────────────────────────
|
|
966
1513
|
/**
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
*
|
|
970
|
-
* The same rules as a link click apply: pushing a path that is already open
|
|
971
|
-
* goes back to it rather than opening it twice, and anything that would close a
|
|
972
|
-
* panel asks its {@link Page.requestClose} first.
|
|
973
|
-
*
|
|
974
|
-
* @example
|
|
975
|
-
* ```ts
|
|
976
|
-
* S.button({ content: "New task", click: async () => {
|
|
977
|
-
* const task = await createTask();
|
|
978
|
-
* S.panels.push(`/tasks/${task.id}`);
|
|
979
|
-
* }});
|
|
980
|
-
* ```
|
|
1514
|
+
* The first non-empty text inside `el`, trimmed and capped at a name-like
|
|
1515
|
+
* length — the stand-in title for a panel that never set one.
|
|
981
1516
|
*/
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
* {@link Page.requestClose} first). The panels beneath it stay as they are.
|
|
990
|
-
*/
|
|
991
|
-
replace(path) {
|
|
992
|
-
requireActive().pushPath(path, true);
|
|
993
|
-
},
|
|
994
|
-
/**
|
|
995
|
-
* Closes the top panel, or, given a `path`, whichever panel is open at it,
|
|
996
|
-
* asking {@link Page.requestClose} first. A panel that isn't on top is taken
|
|
997
|
-
* out on its own, leaving the columns to its right exactly as they are.
|
|
998
|
-
*
|
|
999
|
-
* Resolves `false` if the panel didn't close: `requestClose` said no, `path`
|
|
1000
|
-
* isn't open, or another navigation got there first.
|
|
1001
|
-
*/
|
|
1002
|
-
close(path) {
|
|
1003
|
-
const ctl = requireActive();
|
|
1004
|
-
return path == null ? ctl.closeTop() : ctl.closeByPath(path);
|
|
1005
|
-
},
|
|
1006
|
-
/** The paths of the open panels, oldest first. Reactive: safe to read in a scope. */
|
|
1007
|
-
get stack() {
|
|
1008
|
-
return active ? active.$state.paths : [];
|
|
1009
|
-
},
|
|
1010
|
-
};
|
|
1011
|
-
function requireActive() {
|
|
1012
|
-
if (!active)
|
|
1013
|
-
throw new Error("Staffa: S.panels needs a routed S.main() (one with `routes`) to be mounted");
|
|
1014
|
-
return active;
|
|
1517
|
+
function firstText(el) {
|
|
1518
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
1519
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1520
|
+
const t = n.textContent.trim();
|
|
1521
|
+
if (t)
|
|
1522
|
+
return t.length > 48 ? `${t.slice(0, 47).trimEnd()}…` : t;
|
|
1523
|
+
}
|
|
1015
1524
|
}
|
|
1016
1525
|
/**
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
* Outside a routed shell (or outside any panel, such as a box in a dialog) there is
|
|
1022
|
-
* nothing to close: it warns and resolves `false`.
|
|
1526
|
+
* Put a panel's address on the clipboard, as the absolute URL someone can paste
|
|
1527
|
+
* anywhere — which is what the browser's own "Copy link" would have given them.
|
|
1528
|
+
* Confirmed with a toast, since a silent copy leaves you wondering; `writeText`
|
|
1529
|
+
* needs a secure context, so a failure says so rather than lying.
|
|
1023
1530
|
*/
|
|
1024
|
-
|
|
1025
|
-
const
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1531
|
+
async function copyLink(path) {
|
|
1532
|
+
const url = new URL(path, location.href).href;
|
|
1533
|
+
try {
|
|
1534
|
+
await navigator.clipboard.writeText(url);
|
|
1535
|
+
toast({ message: "Link copied." });
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
toast({ message: "Couldn't copy the link.", type: "danger" });
|
|
1029
1539
|
}
|
|
1030
|
-
|
|
1540
|
+
}
|
|
1541
|
+
function drawDefaultNotFound($panel) {
|
|
1542
|
+
A("p fg:$s-muted", () => A("#", `No panel at ${$panel.path}`));
|
|
1031
1543
|
}
|