staffa 0.13.0 → 0.14.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 +19 -16
- package/dist/components/dialog.js +3 -1
- package/dist/components/main.d.ts +37 -48
- package/dist/components/main.js +72 -63
- package/dist/components/menu.d.ts +8 -0
- package/dist/components/menu.js +48 -13
- package/dist/components/panels.d.ts +103 -82
- package/dist/components/panels.js +180 -227
- package/dist/components/select.js +4 -2
- package/dist/components/tabs.js +9 -5
- package/dist/components/toast.js +3 -1
- package/dist/components/tooltip.js +12 -5
- package/dist/core.d.ts +15 -0
- package/dist/core.js +17 -0
- package/dist/index.d.ts +1 -1
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +13 -65
- package/dist/theme.js +21 -8
- package/package.json +1 -1
- package/skill/FloatingMenuOptions.md +10 -0
- package/skill/MainOptions.md +35 -49
- package/skill/Panel.md +31 -32
- package/skill/PanelStack.md +4 -4
- package/skill/SKILL.md +29 -16
- package/skill/main.md +2 -1
- package/src/components/dialog.ts +3 -1
- package/src/components/main.ts +109 -110
- package/src/components/menu.ts +56 -12
- package/src/components/panels.ts +247 -287
- package/src/components/select.ts +4 -2
- package/src/components/tabs.ts +7 -3
- package/src/components/toast.ts +3 -1
- package/src/components/tooltip.ts +12 -5
- package/src/core.ts +19 -0
- package/src/index.ts +1 -1
- package/src/theme.ts +24 -9
package/src/components/panels.ts
CHANGED
|
@@ -1,33 +1,36 @@
|
|
|
1
1
|
import A, { OPAQUE } from "aberdeen";
|
|
2
2
|
import * as route from "aberdeen/route";
|
|
3
|
-
import { type Slot, drawSlot } from "../core.js";
|
|
3
|
+
import { type Slot, drawSlot, cssZoom, MIN_PX } from "../core.js";
|
|
4
4
|
import {
|
|
5
|
-
circle as dotIcon,
|
|
6
|
-
|
|
5
|
+
circle as dotIcon, pin as pinIcon, pinOff as pinOffIcon,
|
|
6
|
+
slash as sepIcon, x as closeIcon,
|
|
7
7
|
} from "../icons.js";
|
|
8
|
-
import {
|
|
8
|
+
import { PANEL_SHEEN } from "../theme.js";
|
|
9
9
|
import { addContextMenu } from "./menu.js";
|
|
10
10
|
import { scrollStrip, revealInStrip } from "./tabs.js";
|
|
11
|
-
import { toast } from "./toast.js";
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
13
|
* Routed, multi-column panel navigation for {@link main}.
|
|
15
14
|
*
|
|
16
15
|
* Each route draws one screen of the app, called a *panel*. The open panels
|
|
17
|
-
* form a **stack**,
|
|
16
|
+
* form a **stack**, whose last panel is the **current** one: the panel the URL
|
|
18
17
|
* names, and the rightmost column on screen. As many panels as fit are shown,
|
|
19
18
|
* ending at the current one — on a phone that is one at a time, on a wider
|
|
20
19
|
* screen the panels that would have covered each other sit side by side
|
|
21
20
|
* instead. The app's own code is the same either way.
|
|
22
21
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
22
|
+
* Whatever a navigation lands on becomes the top of that stack, and the same
|
|
23
|
+
* path is never in it twice. Opening a new panel closes everything after the
|
|
24
|
+
* panel it came from; a plain link to a panel that is already open — a
|
|
25
|
+
* breadcrumb, a nav item for the section you are in — returns to its own
|
|
26
|
+
* place, closing whatever was stacked on top, while a `replace` or `open`
|
|
27
|
+
* applies its usual shape, the open panel moving into it alive.
|
|
28
|
+
* Two kinds of panel survive that: the ones the user pinned, which ride along,
|
|
29
|
+
* and the ones holding unsaved work, which no navigation ever tears down.
|
|
30
|
+
* Those wait parked out of sight past the rightmost column, which is the only
|
|
31
|
+
* way a panel ever sits *after* the current one. Escape closes the current
|
|
32
|
+
* panel — or just steps left, when it holds unsaved work or panels sit parked
|
|
33
|
+
* beyond it.
|
|
31
34
|
*
|
|
32
35
|
* Navigation runs through `aberdeen/route`: the URL holds the current panel,
|
|
33
36
|
* and the rest of the arrangement — the panels before it, the ones parked
|
|
@@ -101,6 +104,15 @@ export type AncestorTable<R> = {
|
|
|
101
104
|
|
|
102
105
|
// ─── The Panel object ─────────────────────────────────────────────────────────
|
|
103
106
|
|
|
107
|
+
/**
|
|
108
|
+
* How wide a panel asks to be — a ceiling the shell never exceeds; see
|
|
109
|
+
* {@link Panel.maxWidth}. `"small"` is the column the content area is divided
|
|
110
|
+
* into; `"medium"` and `"large"` are two and three of those, and `"none"` is
|
|
111
|
+
* the whole area. Each is capped at the content area, so on a narrow window
|
|
112
|
+
* they all come to the same thing.
|
|
113
|
+
*/
|
|
114
|
+
export type PanelSize = "small" | "medium" | "large" | "none";
|
|
115
|
+
|
|
104
116
|
/**
|
|
105
117
|
* What a route handler gets: the params from its route, plus everything the
|
|
106
118
|
* shell needs to know about the panel it is drawing. It's an Aberdeen proxy, so
|
|
@@ -171,35 +183,35 @@ export interface Panel<P = Record<string, string | number | string[]>> {
|
|
|
171
183
|
*/
|
|
172
184
|
readonly visible: boolean;
|
|
173
185
|
/**
|
|
174
|
-
* The widest this panel can usefully be
|
|
175
|
-
*
|
|
176
|
-
* *
|
|
186
|
+
* The widest this panel can usefully be — a ceiling the shell never
|
|
187
|
+
* exceeds, so the draw function never has to look right past it. It is
|
|
188
|
+
* counted in the shell's *columns*: the content area divides into the
|
|
189
|
+
* narrowest whole number of columns of at least 360px each (three columns
|
|
190
|
+
* of 360 in a 1080px area, four of 380 in 1520px), and a column is never
|
|
191
|
+
* wider than 540 — where the area holds just one, a small centres in it
|
|
192
|
+
* rather than stretching. So an ask never exceeds its column count × 540:
|
|
177
193
|
*
|
|
178
|
-
* - `"
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* - `"
|
|
182
|
-
*
|
|
183
|
-
* - `"screen"` — the whole window, unbounded: boards, wide tables, dense
|
|
184
|
-
* dashboards. While one is open the columns stretch to the screen edges
|
|
185
|
-
* instead of stopping at the standard page; the top bar and footer hold
|
|
186
|
-
* the standard width throughout.
|
|
194
|
+
* - `"small"` — one column, never above 540px: lists, detail forms.
|
|
195
|
+
* - `"medium"` (the default) — two columns, never above 1080px.
|
|
196
|
+
* - `"large"` — three columns, never above 1620px: wide tables.
|
|
197
|
+
* - `"none"` — the whole content area, unbounded: boards, dashboards.
|
|
198
|
+
* Bound it with the shell's own `maxWidth` where that matters.
|
|
187
199
|
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* else is open, so opening or closing a panel never
|
|
200
|
+
* Every size is capped at the content area, so on a phone they all come to
|
|
201
|
+
* the same thing: one screen at a time. And a width depends only on the
|
|
202
|
+
* window, never on what else is open, so opening or closing a panel never
|
|
203
|
+
* resizes another — the run of columns just recentres in the area.
|
|
191
204
|
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* room that would have let another column fit beside it.
|
|
205
|
+
* Ask only for what your content can actually use: a panel that would cap
|
|
206
|
+
* its own content narrower than its ask is holding room that would have
|
|
207
|
+
* let another column fit beside it.
|
|
196
208
|
*
|
|
197
209
|
* Set it at the top of your handler and the panel is already that wide when
|
|
198
210
|
* you draw (see {@link Panel.width}); set it later — when your data tells you
|
|
199
211
|
* — and the panel reflows without being redrawn, keeping its state, while
|
|
200
212
|
* the columns beside it move over.
|
|
201
213
|
*/
|
|
202
|
-
maxWidth?:
|
|
214
|
+
maxWidth?: PanelSize;
|
|
203
215
|
/**
|
|
204
216
|
* Set this while you're fetching what the panel needs, and back to `false`
|
|
205
217
|
* when you're done. A new panel waits a moment before sliding in, so it can
|
|
@@ -210,10 +222,10 @@ export interface Panel<P = Record<string, string | number | string[]>> {
|
|
|
210
222
|
loading?: boolean;
|
|
211
223
|
/**
|
|
212
224
|
* Keeps this panel from being closed by navigation happening *elsewhere*.
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* click away. The user toggles it from the crumb's context menu
|
|
225
|
+
* A navigation normally closes everything after the panel it came from (or
|
|
226
|
+
* returned to); a pinned panel survives that, staying in the stack — slotted
|
|
227
|
+
* in beneath the new panel, or parked out of sight when the new panel was
|
|
228
|
+
* already beneath it. Either way it is one crumb click away. The user toggles it from the crumb's context menu
|
|
217
229
|
* (right-click or long-press), which is also where the pin shows; setting
|
|
218
230
|
* it from code does the same thing.
|
|
219
231
|
*
|
|
@@ -225,12 +237,11 @@ export interface Panel<P = Record<string, string | number | string[]>> {
|
|
|
225
237
|
/**
|
|
226
238
|
* Set this while the panel holds work that must not be lost — a dirty form,
|
|
227
239
|
* an upload in flight. An unsaved panel cannot be closed, by anything:
|
|
228
|
-
* navigation that would prune it parks it instead,
|
|
229
|
-
*
|
|
230
|
-
* only parks it. {@link Panel.close} and the crumb menu's Close refuse,
|
|
240
|
+
* navigation that would prune it parks it out of sight instead, wearing a
|
|
241
|
+
* ● in its crumb — even the browser's back button only parks it. {@link Panel.close} and the crumb menu's Close refuse,
|
|
231
242
|
* Escape on it steps left along the stack rather than closing, and closing
|
|
232
|
-
* the browser tab runs into the browser's own are-you-sure
|
|
233
|
-
*
|
|
243
|
+
* the browser tab runs into the browser's own are-you-sure, the unsaved
|
|
244
|
+
* panel brought on screen as the question is raised.
|
|
234
245
|
*
|
|
235
246
|
* Only the app clears it; the user has no toggle. A Save or Discard button
|
|
236
247
|
* clears it and then closes:
|
|
@@ -406,10 +417,9 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
|
|
|
406
417
|
|
|
407
418
|
/**
|
|
408
419
|
* The one duration every bit of shell motion shares: the enter/exit fades, the
|
|
409
|
-
* `left` moves of columns shifting sideways, the
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
* and JS can't drift apart.
|
|
420
|
+
* `left` moves of columns shifting sideways, and the narrow-screen nav panel's
|
|
421
|
+
* slide. Published as the `--s-panel-ms` custom property below, so CSS and JS
|
|
422
|
+
* can't drift apart.
|
|
413
423
|
*
|
|
414
424
|
* Short enough to read as *the screen responded*, rather than as an animation
|
|
415
425
|
* being played at you: a panel arriving is navigation, and navigation should
|
|
@@ -418,8 +428,15 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
|
|
|
418
428
|
const PAGE_MS = 250;
|
|
419
429
|
/** How long a freshly pushed `loading` panel holds its enter animation. */
|
|
420
430
|
const LOADING_HOLD_MS = 300;
|
|
421
|
-
/**
|
|
422
|
-
|
|
431
|
+
/**
|
|
432
|
+
* The bounds of a column. At least 360px — the phone width every panel must
|
|
433
|
+
* handle anyway. And at most 540: that is the width the multi-column regime
|
|
434
|
+
* never reaches (`area / floor(area / 360)` stays under it), so capping the
|
|
435
|
+
* lone column of a 540–720px area to it too — centred, rather than stretched —
|
|
436
|
+
* makes an ask's ceiling uniform: never wider than its column count × 540.
|
|
437
|
+
*/
|
|
438
|
+
const SMALL_MIN_PX = MIN_PX;
|
|
439
|
+
export const SMALL_MAX_PX = 540;
|
|
423
440
|
/**
|
|
424
441
|
* Panels are layered by their depth in the stack, two `z-index` steps per panel:
|
|
425
442
|
* a panel sits on the odd layer for its depth, and a *closing* one drops to the
|
|
@@ -439,9 +456,11 @@ A.insertGlobalCss({
|
|
|
439
456
|
// layers they stack themselves in (see LAYER_STEP) to themselves: the region
|
|
440
457
|
// as a whole still sits under the shell's own chrome — the sticky top bar, and
|
|
441
458
|
// the nav panel that slides across the body — however deep the stack gets.
|
|
442
|
-
// The region paints the
|
|
443
|
-
//
|
|
444
|
-
//
|
|
459
|
+
// The region paints the columns' sheen over its own box, and every panel
|
|
460
|
+
// paints the very same one (see `.s-panel` below) — which, being a straight
|
|
461
|
+
// vertical wash over boxes of one height, comes out identical whatever a
|
|
462
|
+
// column's width, so the columns and the ground beside them are one
|
|
463
|
+
// continuous surface.
|
|
445
464
|
// `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
|
|
446
465
|
// and anything that ever scrolls it — find-in-page reaching for text in a
|
|
447
466
|
// parked column, an in-page anchor, an extension — shifts every column
|
|
@@ -449,7 +468,7 @@ A.insertGlobalCss({
|
|
|
449
468
|
// `clip` clips without being scrollable at all, closing the whole class.
|
|
450
469
|
".s-panels":
|
|
451
470
|
"flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
|
|
452
|
-
|
|
471
|
+
PANEL_SHEEN,
|
|
453
472
|
".s-panel": {
|
|
454
473
|
// A panel rests at a plain `left` offset and carries no transform: a
|
|
455
474
|
// transformed element is composited, which costs it subpixel text
|
|
@@ -472,18 +491,17 @@ A.insertGlobalCss({
|
|
|
472
491
|
//
|
|
473
492
|
// Every panel paints an opaque ground, because panels animate over one
|
|
474
493
|
// another — entering, leaving, being crowded out — and two transparent ones
|
|
475
|
-
// mean text sliding over text. It takes
|
|
476
|
-
//
|
|
477
|
-
//
|
|
494
|
+
// mean text sliding over text. It takes {@link PANEL_SHEEN}, resolved here
|
|
495
|
+
// against the inherited `--s-bg` (a panel is not a surface, so it has to
|
|
496
|
+
// paint it itself).
|
|
478
497
|
//
|
|
479
|
-
// Painted per panel, over the panel's own box
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
// ground beside a lone column matches it just as closely.
|
|
498
|
+
// Painted per panel, over the panel's own box — and yet seamless with its
|
|
499
|
+
// neighbours and with the ground beside them, because that wash runs
|
|
500
|
+
// straight down: it takes its extent from the height these boxes all share,
|
|
501
|
+
// never from their differing widths. See PANEL_SHEEN for why that matters.
|
|
484
502
|
"&":
|
|
485
503
|
"position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
|
|
486
|
-
|
|
504
|
+
PANEL_SHEEN + " " +
|
|
487
505
|
"visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
|
|
488
506
|
// The hairline between two columns, fading out at both ends — the same
|
|
489
507
|
// treatment as the sidebar's `.s-nav-sep`. Columns tile the area with no
|
|
@@ -657,39 +675,34 @@ interface PanelEntry {
|
|
|
657
675
|
/** Whether its `loading` hold has already expired, so it can't hold again. */
|
|
658
676
|
holdDone?: boolean;
|
|
659
677
|
/** What the panel asks for, kept in step with its `$panel.maxWidth`. */
|
|
660
|
-
maxWidth:
|
|
678
|
+
maxWidth: PanelSize;
|
|
661
679
|
/**
|
|
662
680
|
* The width it was last laid out at. Set before the panel's content is first
|
|
663
681
|
* drawn, so that content has a real box to measure itself against. Visible
|
|
664
|
-
* panels get a fresh value every pass (
|
|
665
|
-
* content area
|
|
666
|
-
*
|
|
682
|
+
* panels get a fresh value every pass (a width is a pure function of the
|
|
683
|
+
* content area); hidden and closing panels keep this, so nothing invisible
|
|
684
|
+
* ever reflows.
|
|
667
685
|
*/
|
|
668
686
|
width: number;
|
|
669
687
|
}
|
|
670
688
|
|
|
671
689
|
/**
|
|
672
690
|
* What the shell measures out to, and with it the width every panel size gets.
|
|
673
|
-
* A pure function of the
|
|
691
|
+
* A pure function of the content area, so it is the same for every panel in a
|
|
692
|
+
* pass — and a panel never resizes because a neighbour came or went.
|
|
674
693
|
*/
|
|
675
694
|
interface Geometry {
|
|
676
|
-
/** The
|
|
677
|
-
|
|
678
|
-
/** What
|
|
679
|
-
|
|
680
|
-
/** Half the standard content area, or all of it when a half would be too narrow. */
|
|
681
|
-
half: number;
|
|
682
|
-
/** The standard content area: what the app asked a `"full"` panel to be. */
|
|
683
|
-
full: number;
|
|
684
|
-
/** Everything the window has beside the chrome, with no upper limit. */
|
|
685
|
-
screen: number;
|
|
695
|
+
/** The content area: all the room the columns have between them. */
|
|
696
|
+
area: number;
|
|
697
|
+
/** What each {@link PanelSize} comes to in that area. */
|
|
698
|
+
size: Record<PanelSize, number>;
|
|
686
699
|
}
|
|
687
700
|
|
|
688
701
|
/**
|
|
689
702
|
* One state of the stack: the open paths, oldest first, and which of them is
|
|
690
703
|
* the current panel. The panels before `focus` sit (or are crowded out) to the
|
|
691
|
-
* current panel's left; the ones after it are parked
|
|
692
|
-
*
|
|
704
|
+
* current panel's left; the ones after it are parked out of sight — panels
|
|
705
|
+
* that refused to be closed, which is the only way anything ends up there. What a history entry describes, and what every navigation is
|
|
693
706
|
* expressed as a change to.
|
|
694
707
|
*/
|
|
695
708
|
interface Arrangement {
|
|
@@ -709,8 +722,6 @@ export interface PanelStackOptions {
|
|
|
709
722
|
columns?: "auto" | "single";
|
|
710
723
|
/** What a bare link does. See {@link MainOptions.linkNavigation}. */
|
|
711
724
|
linkNavigation?: "push" | "replace" | "open";
|
|
712
|
-
/** How wide a `"full"` panel gets, in px. See {@link MainOptions.fullWidth}. */
|
|
713
|
-
fullWidth: number;
|
|
714
725
|
/** The shell's own title, used as the suffix of `document.title`. */
|
|
715
726
|
title?: unknown;
|
|
716
727
|
/**
|
|
@@ -774,10 +785,10 @@ export interface PanelStack {
|
|
|
774
785
|
* the new panel).
|
|
775
786
|
*
|
|
776
787
|
* The same rules as a link click apply: pushing a path that is already open
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
788
|
+
* returns to it — closing whatever was stacked on top — rather than opening
|
|
789
|
+
* it twice, and a panel holding {@link Panel.unsaved} work is never closed,
|
|
790
|
+
* only parked. That's what a plain link does, and what `data-panel=push`
|
|
791
|
+
* says outright.
|
|
781
792
|
*
|
|
782
793
|
* Note that a link builds on the panel it is *drawn in*, which is the
|
|
783
794
|
* current panel only while no column beside it has the focus. Code
|
|
@@ -1158,7 +1169,7 @@ export class PanelStackController implements PanelStack {
|
|
|
1158
1169
|
path,
|
|
1159
1170
|
draw,
|
|
1160
1171
|
$ui: A.proxy({ holding: false }),
|
|
1161
|
-
maxWidth: "
|
|
1172
|
+
maxWidth: "medium" as const,
|
|
1162
1173
|
width: 0,
|
|
1163
1174
|
} as PanelEntry;
|
|
1164
1175
|
// `close` closes *this* panel, current or not, and `open` navigates
|
|
@@ -1304,31 +1315,32 @@ export class PanelStackController implements PanelStack {
|
|
|
1304
1315
|
}
|
|
1305
1316
|
|
|
1306
1317
|
/**
|
|
1307
|
-
* Make the stack's `index`th panel current
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1318
|
+
* Make the stack's `index`th panel current without closing anything, leaving
|
|
1319
|
+
* the panels right of it parked out of sight.
|
|
1320
|
+
*
|
|
1321
|
+
* Only ever a step around a panel that refuses to close — nothing else is
|
|
1322
|
+
* left sitting after the current one — so this is Escape's way past an
|
|
1323
|
+
* unsaved panel, and the way back to one. It is a history entry, so the
|
|
1324
|
+
* browser's back button returns the focus to where it was.
|
|
1312
1325
|
*/
|
|
1313
|
-
private focusAt(index: number
|
|
1326
|
+
private focusAt(index: number): Promise<boolean> {
|
|
1314
1327
|
const arr = this.intended();
|
|
1315
1328
|
if (index < 0 || index >= arr.stack.length || index === arr.focus) return Promise.resolve(false);
|
|
1316
1329
|
const target = { stack: arr.stack, focus: index };
|
|
1317
1330
|
const path = arr.stack[index];
|
|
1318
1331
|
return this.issue(target, () => {
|
|
1319
|
-
// The panel gets its own last search and hash back
|
|
1320
|
-
// that brought us here carries its own.
|
|
1332
|
+
// The panel gets its own last search and hash back (see PanelEntry.search).
|
|
1321
1333
|
const entry = this.$state.live.find((e) => e.path === path);
|
|
1322
|
-
return route.go({ path, search:
|
|
1334
|
+
return route.go({ path, search: entry?.search, hash: entry?.hash, state: this.stateFor(target) });
|
|
1323
1335
|
});
|
|
1324
1336
|
}
|
|
1325
1337
|
|
|
1326
1338
|
/**
|
|
1327
1339
|
* One step back along the stack — what Escape does (`main()` calls this;
|
|
1328
|
-
* it is not {@link PanelStack} API).
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1331
|
-
*
|
|
1340
|
+
* it is not {@link PanelStack} API). Normally that closes the current panel,
|
|
1341
|
+
* which is the stack's end. When it holds {@link Panel.unsaved} work — or
|
|
1342
|
+
* panels sit parked beyond it — it stays open instead, and the focus
|
|
1343
|
+
* simply moves to the panel on its left.
|
|
1332
1344
|
* Resolves `false` at the stack's start, where there is no left to go.
|
|
1333
1345
|
*/
|
|
1334
1346
|
back(): Promise<boolean> {
|
|
@@ -1424,11 +1436,14 @@ export class PanelStackController implements PanelStack {
|
|
|
1424
1436
|
* that know it.
|
|
1425
1437
|
*
|
|
1426
1438
|
* `how` is the link's `data-panel` attribute (or the caller's word for
|
|
1427
|
-
* it)
|
|
1428
|
-
*
|
|
1429
|
-
*
|
|
1430
|
-
* `"open"`
|
|
1431
|
-
*
|
|
1439
|
+
* it), picking how much of `from`'s context the target keeps: a push (the
|
|
1440
|
+
* default, and what unrecognised values fall back to) keeps `from` and
|
|
1441
|
+
* builds on it, `"replace"` keeps only what is beneath `from`, and
|
|
1442
|
+
* `"open"` keeps nothing — the target arrives with its own stack, the way
|
|
1443
|
+
* a nav item's link does. Absent, it is the shell's `linkNavigation`
|
|
1444
|
+
* default, like a link without the attribute. A target that is already
|
|
1445
|
+
* open is returned to by a push, and *moved* — alive, state intact — by
|
|
1446
|
+
* the other two: the stack never holds a path twice.
|
|
1432
1447
|
*
|
|
1433
1448
|
* Resolves the way every {@link PanelStack} method does: `true` once the
|
|
1434
1449
|
* navigation lands, `false` when it doesn't (already there counts as
|
|
@@ -1442,48 +1457,61 @@ export class PanelStackController implements PanelStack {
|
|
|
1442
1457
|
let url: URL;
|
|
1443
1458
|
try { url = new URL(href, location.href); } catch { return Promise.resolve(false); }
|
|
1444
1459
|
const path = normalizePath(url.pathname);
|
|
1445
|
-
const search = Object.fromEntries(new URLSearchParams(url.search));
|
|
1446
|
-
const hash = url.hash;
|
|
1447
1460
|
const arr = this.intended();
|
|
1448
1461
|
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
1451
|
-
// focus: the panels right of the target stay open, parked past the right
|
|
1452
|
-
// edge, and nothing closes. That is the whole behaviour of a breadcrumb,
|
|
1453
|
-
// which is exactly such a link.
|
|
1462
|
+
// Whatever we navigate to ends up on top of the stack; all that differs
|
|
1463
|
+
// is what it lands on.
|
|
1454
1464
|
const open = beneath ? -1 : arr.stack.indexOf(path);
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1461
|
-
//
|
|
1462
|
-
//
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
+
let target: Arrangement;
|
|
1466
|
+
if (open >= 0 && mode !== "replace" && mode !== "open") {
|
|
1467
|
+
// A push to a path that is already open is a return: the panel takes
|
|
1468
|
+
// back its own place, and whatever was stacked on top of it closes.
|
|
1469
|
+
// (A stack never holds the same path twice, so there is no second
|
|
1470
|
+
// copy to open — and a breadcrumb is exactly such a link.) Pinned
|
|
1471
|
+
// panels above it are the exception, as ever: they stay in their
|
|
1472
|
+
// order, parked past the panel we return to, one crumb click away.
|
|
1473
|
+
const above = this.pinnedIn(arr.stack.slice(open + 1), []);
|
|
1474
|
+
target = { stack: [...arr.stack.slice(0, open + 1), ...above], focus: open };
|
|
1475
|
+
} else {
|
|
1476
|
+
// The target opens on top of the panel the link sits in, or in its
|
|
1477
|
+
// place for a `replace`, closing the panels after it. Without an
|
|
1478
|
+
// originating panel there is no stack to build on, so it is the
|
|
1479
|
+
// caller's own `beneath` or one derived from the path — which is what
|
|
1480
|
+
// makes a nav click and a deep link to the same URL land identically
|
|
1481
|
+
// (bar the pins, which a fresh tab doesn't have).
|
|
1482
|
+
const originIndex = origin == null ? -1 : arr.stack.indexOf(origin);
|
|
1483
|
+
const raw = beneath
|
|
1484
|
+
? beneath.map(normalizePath)
|
|
1485
|
+
: originIndex < 0
|
|
1486
|
+
? this.deriveStack(path).slice(0, -1)
|
|
1487
|
+
: arr.stack.slice(0, replace ? originIndex : originIndex + 1);
|
|
1488
|
+
// A stack never holds the same path twice (rendering reconciles by
|
|
1489
|
+
// path), so the target is dropped from the base — a `replace` or
|
|
1490
|
+
// `open` may well aim at a path that is open mid-stack, whose panel
|
|
1491
|
+
// then simply *moves* to the top, alive — and a caller-supplied
|
|
1492
|
+
// `beneath` is deduplicated for the same reason.
|
|
1493
|
+
const base = raw.filter((p, i, all) => p !== path && all.indexOf(p) === i);
|
|
1494
|
+
// Pinned panels ride along, keeping their order, beneath the new one
|
|
1495
|
+
// (unsaved ones the commit itself keeps, parked — see `propose`). A
|
|
1496
|
+
// replaced origin closes, pin or no pin: replacing is the panel's own
|
|
1497
|
+
// doing, not somewhere else navigating over it.
|
|
1498
|
+
const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
|
|
1499
|
+
target = { stack: [...under, path], focus: under.length };
|
|
1465
1500
|
}
|
|
1466
1501
|
|
|
1467
|
-
//
|
|
1468
|
-
//
|
|
1469
|
-
//
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
//
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
: originIndex < 0
|
|
1481
|
-
? this.deriveStack(path).slice(0, -1)
|
|
1482
|
-
: arr.stack.slice(0, replace ? originIndex : originIndex + 1);
|
|
1483
|
-
// A replaced origin closes, pin or no pin: replacing is the panel's own
|
|
1484
|
-
// doing, not somewhere else navigating over it.
|
|
1485
|
-
const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
|
|
1486
|
-
const target = { stack: [...under, path], focus: under.length };
|
|
1502
|
+
// Search and hash belong to the current panel only, so a panel we return
|
|
1503
|
+
// to gets its own back (see PanelEntry.search) — unless the link carries
|
|
1504
|
+
// its own, which win.
|
|
1505
|
+
const returning = open >= 0 ? this.$state.live.find((e) => e.path === path) : undefined;
|
|
1506
|
+
const search = url.search ? Object.fromEntries(new URLSearchParams(url.search)) : returning?.search ?? {};
|
|
1507
|
+
const hash = url.hash || returning?.hash || "";
|
|
1508
|
+
|
|
1509
|
+
// Going nowhere at all: this panel, on this arrangement, with the query
|
|
1510
|
+
// the link asks for. Not even a history entry.
|
|
1511
|
+
if (target.focus === arr.focus && sameStack(target.stack, arr.stack)
|
|
1512
|
+
&& url.search === location.search && (url.hash || "") === (location.hash || "")) {
|
|
1513
|
+
return Promise.resolve(true);
|
|
1514
|
+
}
|
|
1487
1515
|
return this.issue(target, () => route.go({ path, search, hash, state: this.stateFor(target) }));
|
|
1488
1516
|
});
|
|
1489
1517
|
}
|
|
@@ -1570,7 +1598,9 @@ export class PanelStackController implements PanelStack {
|
|
|
1570
1598
|
// app that reads them off a proxy (or through a getter) can change them at
|
|
1571
1599
|
// runtime and the shell adapts in place — nothing is redrawn, no panel
|
|
1572
1600
|
// loses its state. Not {@link PanelStack} API: the app talks to `main()`'s
|
|
1573
|
-
// options; these are how `main()` talks to the stack.
|
|
1601
|
+
// options; these are how `main()` talks to the stack. The shell's *widths*
|
|
1602
|
+
// need no counterpart here: `navWidth` and `maxWidth` both resize the
|
|
1603
|
+
// column region, which the layout engine is already observing.
|
|
1574
1604
|
|
|
1575
1605
|
/** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
|
|
1576
1606
|
setColumns(columns: "auto" | "single" | undefined): void {
|
|
@@ -1584,26 +1614,15 @@ export class PanelStackController implements PanelStack {
|
|
|
1584
1614
|
this.opts.linkNavigation = mode;
|
|
1585
1615
|
}
|
|
1586
1616
|
|
|
1587
|
-
/**
|
|
1588
|
-
* Adopt a changed `fullWidth`: one layout pass, nothing redrawn. A changed
|
|
1589
|
-
* `navWidth` needs no counterpart — resizing the sidebar resizes the column
|
|
1590
|
-
* region, which the layout engine is already observing.
|
|
1591
|
-
*/
|
|
1592
|
-
setFullWidth(px: number): void {
|
|
1593
|
-
if (this.opts.fullWidth === px) return;
|
|
1594
|
-
this.opts.fullWidth = px;
|
|
1595
|
-
this.scheduleLayout();
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
1617
|
/**
|
|
1599
1618
|
* The breadcrumb stack, drawn by `main()` into the top bar: every open
|
|
1600
1619
|
* panel, oldest first, the ones on screen right now in bold, pinned ones
|
|
1601
1620
|
* wearing their pin. Every crumb but the current panel's is a plain link to
|
|
1602
|
-
* that panel, and a link to an open panel
|
|
1603
|
-
* so clicking
|
|
1604
|
-
*
|
|
1605
|
-
*
|
|
1606
|
-
*
|
|
1621
|
+
* that panel, and a link to an open panel returns to it (see `navigate`) —
|
|
1622
|
+
* so clicking a crumb goes back to that panel and closes what was stacked on
|
|
1623
|
+
* top of it, pinned and unsaved panels excepted. Right-click (or long-press)
|
|
1624
|
+
* offers pinning, and closing just that one panel — the close that splices
|
|
1625
|
+
* it out of the middle when it isn't last.
|
|
1607
1626
|
*/
|
|
1608
1627
|
drawCrumbs(): void {
|
|
1609
1628
|
// The very same row `S.tabs` puts its tab strip in: it scrolls when the
|
|
@@ -1648,24 +1667,9 @@ export class PanelStackController implements PanelStack {
|
|
|
1648
1667
|
A(() => { if (entry?.$panel.pinned) pinIcon({ size: "0.85em", attrs: ".s-crumb-pin" }); });
|
|
1649
1668
|
// `||`, not `??`: the root path's last segment is the empty string.
|
|
1650
1669
|
A(() => { A("#", entry?.$panel.title ?? entry?.$ui.fallback ?? (path.split("/").pop() || path)); });
|
|
1651
|
-
//
|
|
1652
|
-
//
|
|
1653
|
-
|
|
1654
|
-
// below the rule.
|
|
1655
|
-
addContextMenu({ items: [
|
|
1656
|
-
{
|
|
1657
|
-
// A real new tab, so it arrives cold and builds its own stack
|
|
1658
|
-
// from the path — exactly what the same link middle-clicked does.
|
|
1659
|
-
label: "Open in new tab",
|
|
1660
|
-
icon: newTabIcon,
|
|
1661
|
-
click: () => { window.open(path, "_blank", "noopener"); },
|
|
1662
|
-
},
|
|
1663
|
-
{
|
|
1664
|
-
label: "Copy link",
|
|
1665
|
-
icon: linkIcon,
|
|
1666
|
-
click: () => void copyLink(path),
|
|
1667
|
-
},
|
|
1668
|
-
{ separator: true },
|
|
1670
|
+
// `link` puts the browser's own link entries — Open in new tab, Copy
|
|
1671
|
+
// link — above the rule; the shell's own verbs sit below it.
|
|
1672
|
+
addContextMenu({ link: path, items: [
|
|
1669
1673
|
{
|
|
1670
1674
|
label: () => { A(() => { A("#", entry?.$panel.pinned ? "Unpin" : "Pin"); }); },
|
|
1671
1675
|
icon: () => { A(() => { (entry?.$panel.pinned ? pinOffIcon : pinIcon)(); }); },
|
|
@@ -1720,29 +1724,24 @@ export class PanelStackController implements PanelStack {
|
|
|
1720
1724
|
|
|
1721
1725
|
/**
|
|
1722
1726
|
* While any open panel holds unsaved work, closing the tab — or navigating
|
|
1723
|
-
* the whole browser away — runs into the browser's own are-you-sure
|
|
1724
|
-
* the
|
|
1725
|
-
*
|
|
1727
|
+
* the whole browser away — runs into the browser's own are-you-sure, with
|
|
1728
|
+
* the unsaved panel brought on screen as the question is raised, so what is
|
|
1729
|
+
* holding the tab is in front of the user rather than parked out of sight.
|
|
1726
1730
|
*/
|
|
1727
1731
|
private guardTabClose(): void {
|
|
1728
1732
|
if (typeof window === "undefined") return;
|
|
1729
|
-
let leaving = false;
|
|
1730
|
-
const onHide = () => { leaving = true; };
|
|
1731
1733
|
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
1732
|
-
// Being asked again means we weren't gone after all (a bfcache restore).
|
|
1733
|
-
leaving = false;
|
|
1734
1734
|
const dirty = this.$state.live.find((entry) => entry.$panel.unsaved);
|
|
1735
1735
|
if (!dirty) return;
|
|
1736
1736
|
e.preventDefault();
|
|
1737
1737
|
e.returnValue = true; // Chrome/Edge < 119
|
|
1738
|
-
//
|
|
1739
|
-
//
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
}, 0);
|
|
1738
|
+
// Bring the unsaved panel on screen right here, so what is holding the
|
|
1739
|
+
// tab is in front of the user — behind the browser's dialog where the
|
|
1740
|
+
// browser paints that early, and the moment they choose to stay
|
|
1741
|
+
// otherwise. A confirmed leave unloads the document before any of it
|
|
1742
|
+
// is seen; the history entry the move makes is then where a back
|
|
1743
|
+
// navigation returns to, which is right: the panel that held the tab.
|
|
1744
|
+
if (!dirty.$panel.visible) void this.focusAt(this.intended().stack.indexOf(dirty.path));
|
|
1746
1745
|
};
|
|
1747
1746
|
// Registered only while a panel actually holds unsaved work: a page with a
|
|
1748
1747
|
// `beforeunload` listener is shut out of the browser's back/forward cache,
|
|
@@ -1751,11 +1750,7 @@ export class PanelStackController implements PanelStack {
|
|
|
1751
1750
|
A(() => {
|
|
1752
1751
|
if (!this.$state.live.some((entry) => entry.$panel.unsaved)) return;
|
|
1753
1752
|
window.addEventListener("beforeunload", onBeforeUnload);
|
|
1754
|
-
window.
|
|
1755
|
-
A.clean(() => {
|
|
1756
|
-
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
1757
|
-
window.removeEventListener("pagehide", onHide);
|
|
1758
|
-
});
|
|
1753
|
+
A.clean(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|
1759
1754
|
});
|
|
1760
1755
|
}
|
|
1761
1756
|
|
|
@@ -1786,12 +1781,11 @@ export class PanelStackController implements PanelStack {
|
|
|
1786
1781
|
}) as HTMLElement;
|
|
1787
1782
|
|
|
1788
1783
|
if (typeof ResizeObserver !== "undefined") {
|
|
1784
|
+
// The region *is* the content area every width is measured from (see
|
|
1785
|
+
// `measure`), so watching it catches the lot: a window resize, the
|
|
1786
|
+
// sidebar coming or going, the shell's own `maxWidth` changing.
|
|
1789
1787
|
const ro = new ResizeObserver(() => this.layout());
|
|
1790
|
-
// The region *and* the body it sits in: the region alone misses a shell
|
|
1791
|
-
// resize that the columns happen to absorb, which still re-resolves widths.
|
|
1792
1788
|
ro.observe(container);
|
|
1793
|
-
const body = container.parentElement?.parentElement;
|
|
1794
|
-
if (body) ro.observe(body);
|
|
1795
1789
|
A.clean(() => ro.disconnect());
|
|
1796
1790
|
}
|
|
1797
1791
|
A.clean(() => { if (this.containerEl === container) this.containerEl = undefined; });
|
|
@@ -1805,13 +1799,13 @@ export class PanelStackController implements PanelStack {
|
|
|
1805
1799
|
// element that arrives without a width has no box for its content to measure
|
|
1806
1800
|
// itself against until the next frame's layout pass, which is a frame too
|
|
1807
1801
|
// late for anything that sizes itself from its container. So the panel is
|
|
1808
|
-
// created at the width the window gives it — the "
|
|
1809
|
-
// says otherwise. Reactively, too: a panel that changes its mind later
|
|
1810
|
-
// its data arrives, say) reflows in place rather than being redrawn,
|
|
1811
|
-
// columns beside it slide over to make room.
|
|
1802
|
+
// created at the width the window gives it — the "medium" width until the
|
|
1803
|
+
// panel says otherwise. Reactively, too: a panel that changes its mind later
|
|
1804
|
+
// (when its data arrives, say) reflows in place rather than being redrawn,
|
|
1805
|
+
// and the columns beside it slide over to make room.
|
|
1812
1806
|
A(() => {
|
|
1813
1807
|
const asked = entry.$panel.maxWidth;
|
|
1814
|
-
entry.maxWidth = asked === "
|
|
1808
|
+
entry.maxWidth = asked === "small" || asked === "large" || asked === "none" ? asked : "medium";
|
|
1815
1809
|
const width = this.roomFor(entry.maxWidth);
|
|
1816
1810
|
if (!width) return;
|
|
1817
1811
|
entry.width = width;
|
|
@@ -1897,45 +1891,38 @@ export class PanelStackController implements PanelStack {
|
|
|
1897
1891
|
}
|
|
1898
1892
|
|
|
1899
1893
|
/**
|
|
1900
|
-
* Measure the
|
|
1901
|
-
*
|
|
1902
|
-
*
|
|
1903
|
-
*
|
|
1904
|
-
*
|
|
1894
|
+
* Measure the content area, and with it the width a panel of each size gets.
|
|
1895
|
+
*
|
|
1896
|
+
* The column region *is* the content area: it takes whatever the shell has
|
|
1897
|
+
* left beside the sidebar, capped by the shell's own `maxWidth` — all of it
|
|
1898
|
+
* CSS's doing, so there is nothing to add up here and nothing that could
|
|
1899
|
+
* drift from the width the bars above and below line up with. Fractional
|
|
1900
|
+
* widths throughout: a rounded column edge would drift a pixel away from that
|
|
1901
|
+
* chrome.
|
|
1902
|
+
*
|
|
1903
|
+
* The area divides into the narrowest whole number of columns that keeps each
|
|
1904
|
+
* at least {@link SMALL_MIN_PX} wide — the `"small"` unit every other size is
|
|
1905
|
+
* a multiple of, capped at the area itself. So 1080px is three columns of 360
|
|
1906
|
+
* and 1520px four of 380. An area too narrow for two is a single column,
|
|
1907
|
+
* itself capped at {@link SMALL_MAX_PX}: a small centres there instead of
|
|
1908
|
+
* stretching toward 720, so its ceiling holds, while the larger sizes still
|
|
1909
|
+
* take the whole area. A width is thus a pure function of the window: a panel
|
|
1910
|
+
* NEVER resizes because a neighbour came or went, and only a window resize
|
|
1911
|
+
* (the snap pass in `layout`) changes one.
|
|
1905
1912
|
*
|
|
1906
1913
|
* `undefined` while the shell has no width to speak of (it isn't in a document
|
|
1907
1914
|
* yet, or it's `display:none`); the next pass tries again.
|
|
1908
1915
|
*/
|
|
1909
1916
|
private measure(): Geometry | undefined {
|
|
1910
|
-
const
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
const
|
|
1915
|
-
if (!
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
let chrome = 0;
|
|
1920
|
-
for (const child of inner.children) {
|
|
1921
|
-
if (child !== container) chrome += child.getBoundingClientRect().width;
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
// What the window has beside the sidebar, and within that the *standard*
|
|
1925
|
-
// content area: the width the app gave a "full" panel, or all there is
|
|
1926
|
-
// when the window has less. Widths are a pure function of the window —
|
|
1927
|
-
// never of what else is open — so a panel NEVER resizes because a
|
|
1928
|
-
// neighbour came or went; only a window resize (the snap pass in
|
|
1929
|
-
// `layout`) changes them:
|
|
1930
|
-
// - "full" fills the standard content area exactly;
|
|
1931
|
-
// - "half" is half of it whenever that half is still a usable column, and
|
|
1932
|
-
// the whole of it on narrower screens;
|
|
1933
|
-
// - "screen" ignores the standard width and takes everything the window
|
|
1934
|
-
// has — which also means nothing ever fits beside it.
|
|
1935
|
-
const screen = Math.max(0, total - chrome);
|
|
1936
|
-
const full = Math.min(this.opts.fullWidth, screen);
|
|
1937
|
-
const halved = full / 2;
|
|
1938
|
-
return { total, chrome, half: halved >= PAIR_MIN_PX ? halved : full, full, screen };
|
|
1917
|
+
const el = this.containerEl;
|
|
1918
|
+
// The rect is in window coordinates; the widths this yields are written
|
|
1919
|
+
// back as CSS lengths, which live in the region's own space — different
|
|
1920
|
+
// spaces when the shell has zoomed the page (see `watchScale` in main.ts).
|
|
1921
|
+
const area = el ? el.getBoundingClientRect().width / cssZoom(el) : 0;
|
|
1922
|
+
if (!area) return undefined;
|
|
1923
|
+
const small = Math.min(area / Math.max(1, Math.floor(area / SMALL_MIN_PX)), SMALL_MAX_PX);
|
|
1924
|
+
const units = (n: number) => Math.min(n * small, area);
|
|
1925
|
+
return { area, size: { small, medium: units(2), large: units(3), none: area } };
|
|
1939
1926
|
}
|
|
1940
1927
|
|
|
1941
1928
|
/**
|
|
@@ -1949,13 +1936,12 @@ export class PanelStackController implements PanelStack {
|
|
|
1949
1936
|
}
|
|
1950
1937
|
|
|
1951
1938
|
/** How wide a panel asking for this is, right now; 0 while the shell can't be measured. */
|
|
1952
|
-
private roomFor(
|
|
1953
|
-
return this.geometry()?.[
|
|
1939
|
+
private roomFor(size: PanelSize): number {
|
|
1940
|
+
return this.geometry()?.size[size] ?? 0;
|
|
1954
1941
|
}
|
|
1955
1942
|
|
|
1956
1943
|
/**
|
|
1957
|
-
* Size and position every panel
|
|
1958
|
-
* (sidebar + separator + columns) for the shell to centre itself on.
|
|
1944
|
+
* Size and position every panel.
|
|
1959
1945
|
*
|
|
1960
1946
|
* This is everything CSS can't work out for itself: which panels exist, which
|
|
1961
1947
|
* of them are visible, how wide each one is and where it sits. All the motion
|
|
@@ -1980,22 +1966,21 @@ export class PanelStackController implements PanelStack {
|
|
|
1980
1966
|
const geom = this.geometry();
|
|
1981
1967
|
if (!geom) return;
|
|
1982
1968
|
|
|
1983
|
-
const
|
|
1969
|
+
const single = this.opts.columns === "single";
|
|
1984
1970
|
|
|
1985
1971
|
// A window resize — or the app resizing the shell itself, by changing
|
|
1986
|
-
// `navWidth` or `
|
|
1972
|
+
// `navWidth` or `maxWidth` — must be adopted instantly: geometry tracking
|
|
1987
1973
|
// the window through a 450ms transition reads as lag, and a shell
|
|
1988
1974
|
// animating itself into place on its first pass reads as a glitch. Only
|
|
1989
1975
|
// what a *panel* did is worth animating, and none of those three are.
|
|
1990
1976
|
// `.s-shell-snap` suppresses every standing transition for this one pass.
|
|
1991
|
-
const
|
|
1992
|
-
const snap = was == null || was.total !== geom.total || was.chrome !== geom.chrome || was.full !== geom.full;
|
|
1977
|
+
const snap = this.lastGeom?.area !== geom.area;
|
|
1993
1978
|
if (snap) {
|
|
1994
1979
|
this.lastGeom = geom;
|
|
1995
1980
|
shell.classList.add("s-shell-snap");
|
|
1996
1981
|
}
|
|
1997
1982
|
|
|
1998
|
-
const width = (entry: PanelEntry) => geom[entry.maxWidth];
|
|
1983
|
+
const width = (entry: PanelEntry) => geom.size[entry.maxWidth];
|
|
1999
1984
|
|
|
2000
1985
|
// The visible run: as many columns as the window fits, at the sizes the
|
|
2001
1986
|
// window gives them, ending at the current panel — which always shows.
|
|
@@ -2003,22 +1988,20 @@ export class PanelStackController implements PanelStack {
|
|
|
2003
1988
|
const cur = Math.min(this.$state.focus, n - 1);
|
|
2004
1989
|
let first = cur;
|
|
2005
1990
|
let runSum = width(live[cur]);
|
|
2006
|
-
if (
|
|
1991
|
+
if (!single) {
|
|
2007
1992
|
for (let i = cur - 1; i >= 0; i--) {
|
|
2008
1993
|
const sum = runSum + width(live[i]);
|
|
2009
|
-
if (sum > geom.
|
|
1994
|
+
if (sum > geom.area) break;
|
|
2010
1995
|
runSum = sum;
|
|
2011
1996
|
first = i;
|
|
2012
1997
|
}
|
|
2013
1998
|
}
|
|
2014
1999
|
|
|
2015
|
-
// The content area
|
|
2016
|
-
//
|
|
2017
|
-
// the
|
|
2018
|
-
//
|
|
2019
|
-
|
|
2020
|
-
// do; with a "screen" up that's the window's edges.
|
|
2021
|
-
const area = Math.min(geom.screen, Math.max(geom.full, runSum));
|
|
2000
|
+
// The content area is a fixed width, so a run that doesn't fill it sits
|
|
2001
|
+
// centred in it rather than hanging off its left edge. Everything around
|
|
2002
|
+
// the columns holds still meanwhile: the sidebar, the top bar and the
|
|
2003
|
+
// footer never move, however many columns come and go.
|
|
2004
|
+
const left = (geom.area - runSum) / 2;
|
|
2022
2005
|
|
|
2023
2006
|
for (let i = first; i <= cur; i++) live[i].width = width(live[i]);
|
|
2024
2007
|
// Panels that have never been visible get their would-be width too, so a
|
|
@@ -2027,29 +2010,22 @@ export class PanelStackController implements PanelStack {
|
|
|
2027
2010
|
if (!entry.width) entry.width = width(entry);
|
|
2028
2011
|
}
|
|
2029
2012
|
|
|
2030
|
-
// The body row caps itself to the ensemble width, keeping the columns
|
|
2031
|
-
// centred however far the area stretches, and transitions its max-width
|
|
2032
|
-
// (see main.ts) so the recentring plays along with the panel that caused
|
|
2033
|
-
// it. The bars above and below don't follow — they hold at the standard
|
|
2034
|
-
// page width (also main.ts).
|
|
2035
|
-
shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
|
|
2036
|
-
|
|
2037
2013
|
// Phase 1 — every panel's *start* state for this frame. Panels already on
|
|
2038
2014
|
// screen simply move (their standing transition animates it); freshly
|
|
2039
2015
|
// mounted ones still have transitions switched off, so what we set here is
|
|
2040
2016
|
// adopted instantly and becomes the "before" of their enter animation.
|
|
2041
2017
|
const fresh: PanelEntry[] = [];
|
|
2042
|
-
let x =
|
|
2018
|
+
let x = left;
|
|
2043
2019
|
for (let i = 0; i < n; i++) {
|
|
2044
2020
|
const entry = live[i];
|
|
2045
2021
|
const el = entry.el!;
|
|
2046
2022
|
const shown = i >= first && i <= cur;
|
|
2047
|
-
// Visible columns tile the
|
|
2048
|
-
//
|
|
2049
|
-
//
|
|
2050
|
-
//
|
|
2051
|
-
//
|
|
2052
|
-
place(el, shown ? x : i > cur ?
|
|
2023
|
+
// Visible columns tile the run, left to right. Panels crowded out from
|
|
2024
|
+
// under it rest at its left edge; panels beyond the current panel park
|
|
2025
|
+
// just past its right edge — both keep their last width. Deeper panels
|
|
2026
|
+
// layer over shallower ones, each on the odd layer for its depth (see
|
|
2027
|
+
// LAYER_STEP).
|
|
2028
|
+
place(el, shown ? x : i > cur ? left + runSum : left, entry.width, LAYER_STEP * i + 1);
|
|
2053
2029
|
// What `$panel.visible` and `$panel.width` report: this pass is the one
|
|
2054
2030
|
// thing that knows them, window resizes included. Written only on a
|
|
2055
2031
|
// change, so per-panel UI hanging off them isn't rebuilt by every pass.
|
|
@@ -2128,22 +2104,6 @@ function firstText(el: HTMLElement): string | undefined {
|
|
|
2128
2104
|
}
|
|
2129
2105
|
}
|
|
2130
2106
|
|
|
2131
|
-
/**
|
|
2132
|
-
* Put a panel's address on the clipboard, as the absolute URL someone can paste
|
|
2133
|
-
* anywhere — which is what the browser's own "Copy link" would have given them.
|
|
2134
|
-
* Confirmed with a toast, since a silent copy leaves you wondering; `writeText`
|
|
2135
|
-
* needs a secure context, so a failure says so rather than lying.
|
|
2136
|
-
*/
|
|
2137
|
-
async function copyLink(path: string): Promise<void> {
|
|
2138
|
-
const url = new URL(path, location.href).href;
|
|
2139
|
-
try {
|
|
2140
|
-
await navigator.clipboard.writeText(url);
|
|
2141
|
-
toast({ message: "Link copied." });
|
|
2142
|
-
} catch {
|
|
2143
|
-
toast({ message: "Couldn't copy the link.", type: "danger" });
|
|
2144
|
-
}
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
2107
|
function drawDefaultNotFound($panel: Panel<{}>): void {
|
|
2148
2108
|
A("p fg:$s-muted", () => A("#", `No panel at ${$panel.path}`));
|
|
2149
2109
|
}
|