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
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import A, { OPAQUE } from "aberdeen";
|
|
2
2
|
import * as route from "aberdeen/route";
|
|
3
|
-
import { drawSlot } from "../core.js";
|
|
4
|
-
import { circle as dotIcon,
|
|
5
|
-
import {
|
|
3
|
+
import { drawSlot, cssZoom, MIN_PX } from "../core.js";
|
|
4
|
+
import { circle as dotIcon, pin as pinIcon, pinOff as pinOffIcon, slash as sepIcon, x as closeIcon, } from "../icons.js";
|
|
5
|
+
import { PANEL_SHEEN } from "../theme.js";
|
|
6
6
|
import { addContextMenu } from "./menu.js";
|
|
7
7
|
import { scrollStrip, revealInStrip } from "./tabs.js";
|
|
8
|
-
import { toast } from "./toast.js";
|
|
9
8
|
/**
|
|
10
9
|
* The matchers a `[name=matcher]` segment can use. A matcher returns the param's
|
|
11
10
|
* value, or `undefined` to fail the match, in which case the path falls through
|
|
@@ -111,10 +110,9 @@ function matchRoute(r, segments) {
|
|
|
111
110
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
112
111
|
/**
|
|
113
112
|
* The one duration every bit of shell motion shares: the enter/exit fades, the
|
|
114
|
-
* `left` moves of columns shifting sideways, the
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* and JS can't drift apart.
|
|
113
|
+
* `left` moves of columns shifting sideways, and the narrow-screen nav panel's
|
|
114
|
+
* slide. Published as the `--s-panel-ms` custom property below, so CSS and JS
|
|
115
|
+
* can't drift apart.
|
|
118
116
|
*
|
|
119
117
|
* Short enough to read as *the screen responded*, rather than as an animation
|
|
120
118
|
* being played at you: a panel arriving is navigation, and navigation should
|
|
@@ -123,8 +121,15 @@ function matchRoute(r, segments) {
|
|
|
123
121
|
const PAGE_MS = 250;
|
|
124
122
|
/** How long a freshly pushed `loading` panel holds its enter animation. */
|
|
125
123
|
const LOADING_HOLD_MS = 300;
|
|
126
|
-
/**
|
|
127
|
-
|
|
124
|
+
/**
|
|
125
|
+
* The bounds of a column. At least 360px — the phone width every panel must
|
|
126
|
+
* handle anyway. And at most 540: that is the width the multi-column regime
|
|
127
|
+
* never reaches (`area / floor(area / 360)` stays under it), so capping the
|
|
128
|
+
* lone column of a 540–720px area to it too — centred, rather than stretched —
|
|
129
|
+
* makes an ask's ceiling uniform: never wider than its column count × 540.
|
|
130
|
+
*/
|
|
131
|
+
const SMALL_MIN_PX = MIN_PX;
|
|
132
|
+
export const SMALL_MAX_PX = 540;
|
|
128
133
|
/**
|
|
129
134
|
* Panels are layered by their depth in the stack, two `z-index` steps per panel:
|
|
130
135
|
* a panel sits on the odd layer for its depth, and a *closing* one drops to the
|
|
@@ -142,16 +147,18 @@ A.insertGlobalCss({
|
|
|
142
147
|
// layers they stack themselves in (see LAYER_STEP) to themselves: the region
|
|
143
148
|
// as a whole still sits under the shell's own chrome — the sticky top bar, and
|
|
144
149
|
// the nav panel that slides across the body — however deep the stack gets.
|
|
145
|
-
// The region paints the
|
|
146
|
-
//
|
|
147
|
-
//
|
|
150
|
+
// The region paints the columns' sheen over its own box, and every panel
|
|
151
|
+
// paints the very same one (see `.s-panel` below) — which, being a straight
|
|
152
|
+
// vertical wash over boxes of one height, comes out identical whatever a
|
|
153
|
+
// column's width, so the columns and the ground beside them are one
|
|
154
|
+
// continuous surface.
|
|
148
155
|
// `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
|
|
149
156
|
// and anything that ever scrolls it — find-in-page reaching for text in a
|
|
150
157
|
// parked column, an in-page anchor, an extension — shifts every column
|
|
151
158
|
// sideways, permanently, because nothing here would ever scroll it back.
|
|
152
159
|
// `clip` clips without being scrollable at all, closing the whole class.
|
|
153
160
|
".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
|
|
154
|
-
|
|
161
|
+
PANEL_SHEEN,
|
|
155
162
|
".s-panel": {
|
|
156
163
|
// A panel rests at a plain `left` offset and carries no transform: a
|
|
157
164
|
// transformed element is composited, which costs it subpixel text
|
|
@@ -174,17 +181,16 @@ A.insertGlobalCss({
|
|
|
174
181
|
//
|
|
175
182
|
// Every panel paints an opaque ground, because panels animate over one
|
|
176
183
|
// another — entering, leaving, being crowded out — and two transparent ones
|
|
177
|
-
// mean text sliding over text. It takes
|
|
178
|
-
//
|
|
179
|
-
//
|
|
184
|
+
// mean text sliding over text. It takes {@link PANEL_SHEEN}, resolved here
|
|
185
|
+
// against the inherited `--s-bg` (a panel is not a surface, so it has to
|
|
186
|
+
// paint it itself).
|
|
180
187
|
//
|
|
181
|
-
// Painted per panel, over the panel's own box
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
// ground beside a lone column matches it just as closely.
|
|
188
|
+
// Painted per panel, over the panel's own box — and yet seamless with its
|
|
189
|
+
// neighbours and with the ground beside them, because that wash runs
|
|
190
|
+
// straight down: it takes its extent from the height these boxes all share,
|
|
191
|
+
// never from their differing widths. See PANEL_SHEEN for why that matters.
|
|
186
192
|
"&": "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
|
|
187
|
-
|
|
193
|
+
PANEL_SHEEN + " " +
|
|
188
194
|
"visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
|
|
189
195
|
// The hairline between two columns, fading out at both ends — the same
|
|
190
196
|
// treatment as the sidebar's `.s-nav-sep`. Columns tile the area with no
|
|
@@ -621,7 +627,7 @@ export class PanelStackController {
|
|
|
621
627
|
path,
|
|
622
628
|
draw,
|
|
623
629
|
$ui: A.proxy({ holding: false }),
|
|
624
|
-
maxWidth: "
|
|
630
|
+
maxWidth: "medium",
|
|
625
631
|
width: 0,
|
|
626
632
|
};
|
|
627
633
|
// `close` closes *this* panel, current or not, and `open` navigates
|
|
@@ -767,31 +773,32 @@ export class PanelStackController {
|
|
|
767
773
|
return settling;
|
|
768
774
|
}
|
|
769
775
|
/**
|
|
770
|
-
* Make the stack's `index`th panel current
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
776
|
+
* Make the stack's `index`th panel current without closing anything, leaving
|
|
777
|
+
* the panels right of it parked out of sight.
|
|
778
|
+
*
|
|
779
|
+
* Only ever a step around a panel that refuses to close — nothing else is
|
|
780
|
+
* left sitting after the current one — so this is Escape's way past an
|
|
781
|
+
* unsaved panel, and the way back to one. It is a history entry, so the
|
|
782
|
+
* browser's back button returns the focus to where it was.
|
|
775
783
|
*/
|
|
776
|
-
focusAt(index
|
|
784
|
+
focusAt(index) {
|
|
777
785
|
const arr = this.intended();
|
|
778
786
|
if (index < 0 || index >= arr.stack.length || index === arr.focus)
|
|
779
787
|
return Promise.resolve(false);
|
|
780
788
|
const target = { stack: arr.stack, focus: index };
|
|
781
789
|
const path = arr.stack[index];
|
|
782
790
|
return this.issue(target, () => {
|
|
783
|
-
// The panel gets its own last search and hash back
|
|
784
|
-
// that brought us here carries its own.
|
|
791
|
+
// The panel gets its own last search and hash back (see PanelEntry.search).
|
|
785
792
|
const entry = this.$state.live.find((e) => e.path === path);
|
|
786
|
-
return route.go({ path, search:
|
|
793
|
+
return route.go({ path, search: entry?.search, hash: entry?.hash, state: this.stateFor(target) });
|
|
787
794
|
});
|
|
788
795
|
}
|
|
789
796
|
/**
|
|
790
797
|
* One step back along the stack — what Escape does (`main()` calls this;
|
|
791
|
-
* it is not {@link PanelStack} API).
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
798
|
+
* it is not {@link PanelStack} API). Normally that closes the current panel,
|
|
799
|
+
* which is the stack's end. When it holds {@link Panel.unsaved} work — or
|
|
800
|
+
* panels sit parked beyond it — it stays open instead, and the focus
|
|
801
|
+
* simply moves to the panel on its left.
|
|
795
802
|
* Resolves `false` at the stack's start, where there is no left to go.
|
|
796
803
|
*/
|
|
797
804
|
back() {
|
|
@@ -884,11 +891,14 @@ export class PanelStackController {
|
|
|
884
891
|
* that know it.
|
|
885
892
|
*
|
|
886
893
|
* `how` is the link's `data-panel` attribute (or the caller's word for
|
|
887
|
-
* it)
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
* `"open"`
|
|
891
|
-
*
|
|
894
|
+
* it), picking how much of `from`'s context the target keeps: a push (the
|
|
895
|
+
* default, and what unrecognised values fall back to) keeps `from` and
|
|
896
|
+
* builds on it, `"replace"` keeps only what is beneath `from`, and
|
|
897
|
+
* `"open"` keeps nothing — the target arrives with its own stack, the way
|
|
898
|
+
* a nav item's link does. Absent, it is the shell's `linkNavigation`
|
|
899
|
+
* default, like a link without the attribute. A target that is already
|
|
900
|
+
* open is returned to by a push, and *moved* — alive, state intact — by
|
|
901
|
+
* the other two: the stack never holds a path twice.
|
|
892
902
|
*
|
|
893
903
|
* Resolves the way every {@link PanelStack} method does: `true` once the
|
|
894
904
|
* navigation lands, `false` when it doesn't (already there counts as
|
|
@@ -907,47 +917,59 @@ export class PanelStackController {
|
|
|
907
917
|
return Promise.resolve(false);
|
|
908
918
|
}
|
|
909
919
|
const path = normalizePath(url.pathname);
|
|
910
|
-
const search = Object.fromEntries(new URLSearchParams(url.search));
|
|
911
|
-
const hash = url.hash;
|
|
912
920
|
const arr = this.intended();
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
// focus: the panels right of the target stay open, parked past the right
|
|
916
|
-
// edge, and nothing closes. That is the whole behaviour of a breadcrumb,
|
|
917
|
-
// which is exactly such a link.
|
|
921
|
+
// Whatever we navigate to ends up on top of the stack; all that differs
|
|
922
|
+
// is what it lands on.
|
|
918
923
|
const open = beneath ? -1 : arr.stack.indexOf(path);
|
|
919
|
-
|
|
920
|
-
|
|
924
|
+
let target;
|
|
925
|
+
if (open >= 0 && mode !== "replace" && mode !== "open") {
|
|
926
|
+
// A push to a path that is already open is a return: the panel takes
|
|
927
|
+
// back its own place, and whatever was stacked on top of it closes.
|
|
928
|
+
// (A stack never holds the same path twice, so there is no second
|
|
929
|
+
// copy to open — and a breadcrumb is exactly such a link.) Pinned
|
|
930
|
+
// panels above it are the exception, as ever: they stay in their
|
|
931
|
+
// order, parked past the panel we return to, one crumb click away.
|
|
932
|
+
const above = this.pinnedIn(arr.stack.slice(open + 1), []);
|
|
933
|
+
target = { stack: [...arr.stack.slice(0, open + 1), ...above], focus: open };
|
|
921
934
|
}
|
|
922
|
-
|
|
923
|
-
// The target
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
935
|
+
else {
|
|
936
|
+
// The target opens on top of the panel the link sits in, or in its
|
|
937
|
+
// place for a `replace`, closing the panels after it. Without an
|
|
938
|
+
// originating panel there is no stack to build on, so it is the
|
|
939
|
+
// caller's own `beneath` or one derived from the path — which is what
|
|
940
|
+
// makes a nav click and a deep link to the same URL land identically
|
|
941
|
+
// (bar the pins, which a fresh tab doesn't have).
|
|
942
|
+
const originIndex = origin == null ? -1 : arr.stack.indexOf(origin);
|
|
943
|
+
const raw = beneath
|
|
944
|
+
? beneath.map(normalizePath)
|
|
945
|
+
: originIndex < 0
|
|
946
|
+
? this.deriveStack(path).slice(0, -1)
|
|
947
|
+
: arr.stack.slice(0, replace ? originIndex : originIndex + 1);
|
|
948
|
+
// A stack never holds the same path twice (rendering reconciles by
|
|
949
|
+
// path), so the target is dropped from the base — a `replace` or
|
|
950
|
+
// `open` may well aim at a path that is open mid-stack, whose panel
|
|
951
|
+
// then simply *moves* to the top, alive — and a caller-supplied
|
|
952
|
+
// `beneath` is deduplicated for the same reason.
|
|
953
|
+
const base = raw.filter((p, i, all) => p !== path && all.indexOf(p) === i);
|
|
954
|
+
// Pinned panels ride along, keeping their order, beneath the new one
|
|
955
|
+
// (unsaved ones the commit itself keeps, parked — see `propose`). A
|
|
956
|
+
// replaced origin closes, pin or no pin: replacing is the panel's own
|
|
957
|
+
// doing, not somewhere else navigating over it.
|
|
958
|
+
const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
|
|
959
|
+
target = { stack: [...under, path], focus: under.length };
|
|
960
|
+
}
|
|
961
|
+
// Search and hash belong to the current panel only, so a panel we return
|
|
962
|
+
// to gets its own back (see PanelEntry.search) — unless the link carries
|
|
963
|
+
// its own, which win.
|
|
964
|
+
const returning = open >= 0 ? this.$state.live.find((e) => e.path === path) : undefined;
|
|
965
|
+
const search = url.search ? Object.fromEntries(new URLSearchParams(url.search)) : returning?.search ?? {};
|
|
966
|
+
const hash = url.hash || returning?.hash || "";
|
|
967
|
+
// Going nowhere at all: this panel, on this arrangement, with the query
|
|
968
|
+
// the link asks for. Not even a history entry.
|
|
969
|
+
if (target.focus === arr.focus && sameStack(target.stack, arr.stack)
|
|
970
|
+
&& url.search === location.search && (url.hash || "") === (location.hash || "")) {
|
|
971
|
+
return Promise.resolve(true);
|
|
930
972
|
}
|
|
931
|
-
// A new panel: it opens at the stack's end and becomes current. The
|
|
932
|
-
// panels after the origin close — except pinned ones, which ride along,
|
|
933
|
-
// keeping their order, beneath the new panel (and unsaved ones, which
|
|
934
|
-
// the commit itself keeps, parked — see `propose`). Without an
|
|
935
|
-
// originating panel there is no stack to build on, so derive one — a
|
|
936
|
-
// nav click and a deep link to the same URL land identically (bar the
|
|
937
|
-
// pins, which a fresh tab doesn't have).
|
|
938
|
-
const originIndex = origin == null ? -1 : arr.stack.indexOf(origin);
|
|
939
|
-
// A stack never holds the same path twice (rendering reconciles by
|
|
940
|
-
// path), so a caller-supplied `beneath` is deduplicated, not just
|
|
941
|
-
// filtered against the target.
|
|
942
|
-
const base = beneath
|
|
943
|
-
? beneath.map(normalizePath).filter((p, i, all) => p !== path && all.indexOf(p) === i)
|
|
944
|
-
: originIndex < 0
|
|
945
|
-
? this.deriveStack(path).slice(0, -1)
|
|
946
|
-
: arr.stack.slice(0, replace ? originIndex : originIndex + 1);
|
|
947
|
-
// A replaced origin closes, pin or no pin: replacing is the panel's own
|
|
948
|
-
// doing, not somewhere else navigating over it.
|
|
949
|
-
const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
|
|
950
|
-
const target = { stack: [...under, path], focus: under.length };
|
|
951
973
|
return this.issue(target, () => route.go({ path, search, hash, state: this.stateFor(target) }));
|
|
952
974
|
});
|
|
953
975
|
}
|
|
@@ -1021,7 +1043,9 @@ export class PanelStackController {
|
|
|
1021
1043
|
// app that reads them off a proxy (or through a getter) can change them at
|
|
1022
1044
|
// runtime and the shell adapts in place — nothing is redrawn, no panel
|
|
1023
1045
|
// loses its state. Not {@link PanelStack} API: the app talks to `main()`'s
|
|
1024
|
-
// options; these are how `main()` talks to the stack.
|
|
1046
|
+
// options; these are how `main()` talks to the stack. The shell's *widths*
|
|
1047
|
+
// need no counterpart here: `navWidth` and `maxWidth` both resize the
|
|
1048
|
+
// column region, which the layout engine is already observing.
|
|
1025
1049
|
/** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
|
|
1026
1050
|
setColumns(columns) {
|
|
1027
1051
|
if (this.opts.columns === columns)
|
|
@@ -1033,26 +1057,15 @@ export class PanelStackController {
|
|
|
1033
1057
|
setLinkNavigation(mode) {
|
|
1034
1058
|
this.opts.linkNavigation = mode;
|
|
1035
1059
|
}
|
|
1036
|
-
/**
|
|
1037
|
-
* Adopt a changed `fullWidth`: one layout pass, nothing redrawn. A changed
|
|
1038
|
-
* `navWidth` needs no counterpart — resizing the sidebar resizes the column
|
|
1039
|
-
* region, which the layout engine is already observing.
|
|
1040
|
-
*/
|
|
1041
|
-
setFullWidth(px) {
|
|
1042
|
-
if (this.opts.fullWidth === px)
|
|
1043
|
-
return;
|
|
1044
|
-
this.opts.fullWidth = px;
|
|
1045
|
-
this.scheduleLayout();
|
|
1046
|
-
}
|
|
1047
1060
|
/**
|
|
1048
1061
|
* The breadcrumb stack, drawn by `main()` into the top bar: every open
|
|
1049
1062
|
* panel, oldest first, the ones on screen right now in bold, pinned ones
|
|
1050
1063
|
* wearing their pin. Every crumb but the current panel's is a plain link to
|
|
1051
|
-
* that panel, and a link to an open panel
|
|
1052
|
-
* so clicking
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1064
|
+
* that panel, and a link to an open panel returns to it (see `navigate`) —
|
|
1065
|
+
* so clicking a crumb goes back to that panel and closes what was stacked on
|
|
1066
|
+
* top of it, pinned and unsaved panels excepted. Right-click (or long-press)
|
|
1067
|
+
* offers pinning, and closing just that one panel — the close that splices
|
|
1068
|
+
* it out of the middle when it isn't last.
|
|
1056
1069
|
*/
|
|
1057
1070
|
drawCrumbs() {
|
|
1058
1071
|
// The very same row `S.tabs` puts its tab strip in: it scrolls when the
|
|
@@ -1103,24 +1116,9 @@ export class PanelStackController {
|
|
|
1103
1116
|
pinIcon({ size: "0.85em", attrs: ".s-crumb-pin" }); });
|
|
1104
1117
|
// `||`, not `??`: the root path's last segment is the empty string.
|
|
1105
1118
|
A(() => { A("#", entry?.$panel.title ?? entry?.$ui.fallback ?? (path.split("/").pop() || path)); });
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
|
|
1109
|
-
// below the rule.
|
|
1110
|
-
addContextMenu({ items: [
|
|
1111
|
-
{
|
|
1112
|
-
// A real new tab, so it arrives cold and builds its own stack
|
|
1113
|
-
// from the path — exactly what the same link middle-clicked does.
|
|
1114
|
-
label: "Open in new tab",
|
|
1115
|
-
icon: newTabIcon,
|
|
1116
|
-
click: () => { window.open(path, "_blank", "noopener"); },
|
|
1117
|
-
},
|
|
1118
|
-
{
|
|
1119
|
-
label: "Copy link",
|
|
1120
|
-
icon: linkIcon,
|
|
1121
|
-
click: () => void copyLink(path),
|
|
1122
|
-
},
|
|
1123
|
-
{ separator: true },
|
|
1119
|
+
// `link` puts the browser's own link entries — Open in new tab, Copy
|
|
1120
|
+
// link — above the rule; the shell's own verbs sit below it.
|
|
1121
|
+
addContextMenu({ link: path, items: [
|
|
1124
1122
|
{
|
|
1125
1123
|
label: () => { A(() => { A("#", entry?.$panel.pinned ? "Unpin" : "Pin"); }); },
|
|
1126
1124
|
icon: () => { A(() => { (entry?.$panel.pinned ? pinOffIcon : pinIcon)(); }); },
|
|
@@ -1173,33 +1171,27 @@ export class PanelStackController {
|
|
|
1173
1171
|
}
|
|
1174
1172
|
/**
|
|
1175
1173
|
* While any open panel holds unsaved work, closing the tab — or navigating
|
|
1176
|
-
* the whole browser away — runs into the browser's own are-you-sure
|
|
1177
|
-
* the
|
|
1178
|
-
*
|
|
1174
|
+
* the whole browser away — runs into the browser's own are-you-sure, with
|
|
1175
|
+
* the unsaved panel brought on screen as the question is raised, so what is
|
|
1176
|
+
* holding the tab is in front of the user rather than parked out of sight.
|
|
1179
1177
|
*/
|
|
1180
1178
|
guardTabClose() {
|
|
1181
1179
|
if (typeof window === "undefined")
|
|
1182
1180
|
return;
|
|
1183
|
-
let leaving = false;
|
|
1184
|
-
const onHide = () => { leaving = true; };
|
|
1185
1181
|
const onBeforeUnload = (e) => {
|
|
1186
|
-
// Being asked again means we weren't gone after all (a bfcache restore).
|
|
1187
|
-
leaving = false;
|
|
1188
1182
|
const dirty = this.$state.live.find((entry) => entry.$panel.unsaved);
|
|
1189
1183
|
if (!dirty)
|
|
1190
1184
|
return;
|
|
1191
1185
|
e.preventDefault();
|
|
1192
1186
|
e.returnValue = true; // Chrome/Edge < 119
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
void this.focusAt(this.intended().stack.indexOf(path));
|
|
1202
|
-
}, 0);
|
|
1187
|
+
// Bring the unsaved panel on screen right here, so what is holding the
|
|
1188
|
+
// tab is in front of the user — behind the browser's dialog where the
|
|
1189
|
+
// browser paints that early, and the moment they choose to stay
|
|
1190
|
+
// otherwise. A confirmed leave unloads the document before any of it
|
|
1191
|
+
// is seen; the history entry the move makes is then where a back
|
|
1192
|
+
// navigation returns to, which is right: the panel that held the tab.
|
|
1193
|
+
if (!dirty.$panel.visible)
|
|
1194
|
+
void this.focusAt(this.intended().stack.indexOf(dirty.path));
|
|
1203
1195
|
};
|
|
1204
1196
|
// Registered only while a panel actually holds unsaved work: a page with a
|
|
1205
1197
|
// `beforeunload` listener is shut out of the browser's back/forward cache,
|
|
@@ -1209,11 +1201,7 @@ export class PanelStackController {
|
|
|
1209
1201
|
if (!this.$state.live.some((entry) => entry.$panel.unsaved))
|
|
1210
1202
|
return;
|
|
1211
1203
|
window.addEventListener("beforeunload", onBeforeUnload);
|
|
1212
|
-
window.
|
|
1213
|
-
A.clean(() => {
|
|
1214
|
-
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
1215
|
-
window.removeEventListener("pagehide", onHide);
|
|
1216
|
-
});
|
|
1204
|
+
A.clean(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|
1217
1205
|
});
|
|
1218
1206
|
}
|
|
1219
1207
|
// ── Rendering ──────────────────────────────────────────────────────────
|
|
@@ -1237,13 +1225,11 @@ export class PanelStackController {
|
|
|
1237
1225
|
A.onEach(this.$open, (entry) => this.drawPanel(entry), (entry) => entry.order);
|
|
1238
1226
|
});
|
|
1239
1227
|
if (typeof ResizeObserver !== "undefined") {
|
|
1228
|
+
// The region *is* the content area every width is measured from (see
|
|
1229
|
+
// `measure`), so watching it catches the lot: a window resize, the
|
|
1230
|
+
// sidebar coming or going, the shell's own `maxWidth` changing.
|
|
1240
1231
|
const ro = new ResizeObserver(() => this.layout());
|
|
1241
|
-
// The region *and* the body it sits in: the region alone misses a shell
|
|
1242
|
-
// resize that the columns happen to absorb, which still re-resolves widths.
|
|
1243
1232
|
ro.observe(container);
|
|
1244
|
-
const body = container.parentElement?.parentElement;
|
|
1245
|
-
if (body)
|
|
1246
|
-
ro.observe(body);
|
|
1247
1233
|
A.clean(() => ro.disconnect());
|
|
1248
1234
|
}
|
|
1249
1235
|
A.clean(() => { if (this.containerEl === container)
|
|
@@ -1256,13 +1242,13 @@ export class PanelStackController {
|
|
|
1256
1242
|
// element that arrives without a width has no box for its content to measure
|
|
1257
1243
|
// itself against until the next frame's layout pass, which is a frame too
|
|
1258
1244
|
// late for anything that sizes itself from its container. So the panel is
|
|
1259
|
-
// created at the width the window gives it — the "
|
|
1260
|
-
// says otherwise. Reactively, too: a panel that changes its mind later
|
|
1261
|
-
// its data arrives, say) reflows in place rather than being redrawn,
|
|
1262
|
-
// columns beside it slide over to make room.
|
|
1245
|
+
// created at the width the window gives it — the "medium" width until the
|
|
1246
|
+
// panel says otherwise. Reactively, too: a panel that changes its mind later
|
|
1247
|
+
// (when its data arrives, say) reflows in place rather than being redrawn,
|
|
1248
|
+
// and the columns beside it slide over to make room.
|
|
1263
1249
|
A(() => {
|
|
1264
1250
|
const asked = entry.$panel.maxWidth;
|
|
1265
|
-
entry.maxWidth = asked === "
|
|
1251
|
+
entry.maxWidth = asked === "small" || asked === "large" || asked === "none" ? asked : "medium";
|
|
1266
1252
|
const width = this.roomFor(entry.maxWidth);
|
|
1267
1253
|
if (!width)
|
|
1268
1254
|
return;
|
|
@@ -1346,46 +1332,39 @@ export class PanelStackController {
|
|
|
1346
1332
|
});
|
|
1347
1333
|
}
|
|
1348
1334
|
/**
|
|
1349
|
-
* Measure the
|
|
1350
|
-
*
|
|
1351
|
-
*
|
|
1352
|
-
*
|
|
1353
|
-
*
|
|
1335
|
+
* Measure the content area, and with it the width a panel of each size gets.
|
|
1336
|
+
*
|
|
1337
|
+
* The column region *is* the content area: it takes whatever the shell has
|
|
1338
|
+
* left beside the sidebar, capped by the shell's own `maxWidth` — all of it
|
|
1339
|
+
* CSS's doing, so there is nothing to add up here and nothing that could
|
|
1340
|
+
* drift from the width the bars above and below line up with. Fractional
|
|
1341
|
+
* widths throughout: a rounded column edge would drift a pixel away from that
|
|
1342
|
+
* chrome.
|
|
1343
|
+
*
|
|
1344
|
+
* The area divides into the narrowest whole number of columns that keeps each
|
|
1345
|
+
* at least {@link SMALL_MIN_PX} wide — the `"small"` unit every other size is
|
|
1346
|
+
* a multiple of, capped at the area itself. So 1080px is three columns of 360
|
|
1347
|
+
* and 1520px four of 380. An area too narrow for two is a single column,
|
|
1348
|
+
* itself capped at {@link SMALL_MAX_PX}: a small centres there instead of
|
|
1349
|
+
* stretching toward 720, so its ceiling holds, while the larger sizes still
|
|
1350
|
+
* take the whole area. A width is thus a pure function of the window: a panel
|
|
1351
|
+
* NEVER resizes because a neighbour came or went, and only a window resize
|
|
1352
|
+
* (the snap pass in `layout`) changes one.
|
|
1354
1353
|
*
|
|
1355
1354
|
* `undefined` while the shell has no width to speak of (it isn't in a document
|
|
1356
1355
|
* yet, or it's `display:none`); the next pass tries again.
|
|
1357
1356
|
*/
|
|
1358
1357
|
measure() {
|
|
1359
|
-
const
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
if (!total)
|
|
1358
|
+
const el = this.containerEl;
|
|
1359
|
+
// The rect is in window coordinates; the widths this yields are written
|
|
1360
|
+
// back as CSS lengths, which live in the region's own space — different
|
|
1361
|
+
// spaces when the shell has zoomed the page (see `watchScale` in main.ts).
|
|
1362
|
+
const area = el ? el.getBoundingClientRect().width / cssZoom(el) : 0;
|
|
1363
|
+
if (!area)
|
|
1366
1364
|
return undefined;
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
for (const child of inner.children) {
|
|
1371
|
-
if (child !== container)
|
|
1372
|
-
chrome += child.getBoundingClientRect().width;
|
|
1373
|
-
}
|
|
1374
|
-
// What the window has beside the sidebar, and within that the *standard*
|
|
1375
|
-
// content area: the width the app gave a "full" panel, or all there is
|
|
1376
|
-
// when the window has less. Widths are a pure function of the window —
|
|
1377
|
-
// never of what else is open — so a panel NEVER resizes because a
|
|
1378
|
-
// neighbour came or went; only a window resize (the snap pass in
|
|
1379
|
-
// `layout`) changes them:
|
|
1380
|
-
// - "full" fills the standard content area exactly;
|
|
1381
|
-
// - "half" is half of it whenever that half is still a usable column, and
|
|
1382
|
-
// the whole of it on narrower screens;
|
|
1383
|
-
// - "screen" ignores the standard width and takes everything the window
|
|
1384
|
-
// has — which also means nothing ever fits beside it.
|
|
1385
|
-
const screen = Math.max(0, total - chrome);
|
|
1386
|
-
const full = Math.min(this.opts.fullWidth, screen);
|
|
1387
|
-
const halved = full / 2;
|
|
1388
|
-
return { total, chrome, half: halved >= PAIR_MIN_PX ? halved : full, full, screen };
|
|
1365
|
+
const small = Math.min(area / Math.max(1, Math.floor(area / SMALL_MIN_PX)), SMALL_MAX_PX);
|
|
1366
|
+
const units = (n) => Math.min(n * small, area);
|
|
1367
|
+
return { area, size: { small, medium: units(2), large: units(3), none: area } };
|
|
1389
1368
|
}
|
|
1390
1369
|
/**
|
|
1391
1370
|
* The measurements this pass runs on. Taken once per layout pass and per
|
|
@@ -1397,12 +1376,11 @@ export class PanelStackController {
|
|
|
1397
1376
|
return (this.geom ??= this.measure());
|
|
1398
1377
|
}
|
|
1399
1378
|
/** How wide a panel asking for this is, right now; 0 while the shell can't be measured. */
|
|
1400
|
-
roomFor(
|
|
1401
|
-
return this.geometry()?.[
|
|
1379
|
+
roomFor(size) {
|
|
1380
|
+
return this.geometry()?.size[size] ?? 0;
|
|
1402
1381
|
}
|
|
1403
1382
|
/**
|
|
1404
|
-
* Size and position every panel
|
|
1405
|
-
* (sidebar + separator + columns) for the shell to centre itself on.
|
|
1383
|
+
* Size and position every panel.
|
|
1406
1384
|
*
|
|
1407
1385
|
* This is everything CSS can't work out for itself: which panels exist, which
|
|
1408
1386
|
* of them are visible, how wide each one is and where it sits. All the motion
|
|
@@ -1428,42 +1406,39 @@ export class PanelStackController {
|
|
|
1428
1406
|
const geom = this.geometry();
|
|
1429
1407
|
if (!geom)
|
|
1430
1408
|
return;
|
|
1431
|
-
const
|
|
1409
|
+
const single = this.opts.columns === "single";
|
|
1432
1410
|
// A window resize — or the app resizing the shell itself, by changing
|
|
1433
|
-
// `navWidth` or `
|
|
1411
|
+
// `navWidth` or `maxWidth` — must be adopted instantly: geometry tracking
|
|
1434
1412
|
// the window through a 450ms transition reads as lag, and a shell
|
|
1435
1413
|
// animating itself into place on its first pass reads as a glitch. Only
|
|
1436
1414
|
// what a *panel* did is worth animating, and none of those three are.
|
|
1437
1415
|
// `.s-shell-snap` suppresses every standing transition for this one pass.
|
|
1438
|
-
const
|
|
1439
|
-
const snap = was == null || was.total !== geom.total || was.chrome !== geom.chrome || was.full !== geom.full;
|
|
1416
|
+
const snap = this.lastGeom?.area !== geom.area;
|
|
1440
1417
|
if (snap) {
|
|
1441
1418
|
this.lastGeom = geom;
|
|
1442
1419
|
shell.classList.add("s-shell-snap");
|
|
1443
1420
|
}
|
|
1444
|
-
const width = (entry) => geom[entry.maxWidth];
|
|
1421
|
+
const width = (entry) => geom.size[entry.maxWidth];
|
|
1445
1422
|
// The visible run: as many columns as the window fits, at the sizes the
|
|
1446
1423
|
// window gives them, ending at the current panel — which always shows.
|
|
1447
1424
|
// Panels beyond it are parked past the right edge (see phase 1).
|
|
1448
1425
|
const cur = Math.min(this.$state.focus, n - 1);
|
|
1449
1426
|
let first = cur;
|
|
1450
1427
|
let runSum = width(live[cur]);
|
|
1451
|
-
if (
|
|
1428
|
+
if (!single) {
|
|
1452
1429
|
for (let i = cur - 1; i >= 0; i--) {
|
|
1453
1430
|
const sum = runSum + width(live[i]);
|
|
1454
|
-
if (sum > geom.
|
|
1431
|
+
if (sum > geom.area)
|
|
1455
1432
|
break;
|
|
1456
1433
|
runSum = sum;
|
|
1457
1434
|
first = i;
|
|
1458
1435
|
}
|
|
1459
1436
|
}
|
|
1460
|
-
// The content area
|
|
1461
|
-
//
|
|
1462
|
-
// the
|
|
1463
|
-
//
|
|
1464
|
-
|
|
1465
|
-
// do; with a "screen" up that's the window's edges.
|
|
1466
|
-
const area = Math.min(geom.screen, Math.max(geom.full, runSum));
|
|
1437
|
+
// The content area is a fixed width, so a run that doesn't fill it sits
|
|
1438
|
+
// centred in it rather than hanging off its left edge. Everything around
|
|
1439
|
+
// the columns holds still meanwhile: the sidebar, the top bar and the
|
|
1440
|
+
// footer never move, however many columns come and go.
|
|
1441
|
+
const left = (geom.area - runSum) / 2;
|
|
1467
1442
|
for (let i = first; i <= cur; i++)
|
|
1468
1443
|
live[i].width = width(live[i]);
|
|
1469
1444
|
// Panels that have never been visible get their would-be width too, so a
|
|
@@ -1472,28 +1447,22 @@ export class PanelStackController {
|
|
|
1472
1447
|
if (!entry.width)
|
|
1473
1448
|
entry.width = width(entry);
|
|
1474
1449
|
}
|
|
1475
|
-
// The body row caps itself to the ensemble width, keeping the columns
|
|
1476
|
-
// centred however far the area stretches, and transitions its max-width
|
|
1477
|
-
// (see main.ts) so the recentring plays along with the panel that caused
|
|
1478
|
-
// it. The bars above and below don't follow — they hold at the standard
|
|
1479
|
-
// page width (also main.ts).
|
|
1480
|
-
shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
|
|
1481
1450
|
// Phase 1 — every panel's *start* state for this frame. Panels already on
|
|
1482
1451
|
// screen simply move (their standing transition animates it); freshly
|
|
1483
1452
|
// mounted ones still have transitions switched off, so what we set here is
|
|
1484
1453
|
// adopted instantly and becomes the "before" of their enter animation.
|
|
1485
1454
|
const fresh = [];
|
|
1486
|
-
let x =
|
|
1455
|
+
let x = left;
|
|
1487
1456
|
for (let i = 0; i < n; i++) {
|
|
1488
1457
|
const entry = live[i];
|
|
1489
1458
|
const el = entry.el;
|
|
1490
1459
|
const shown = i >= first && i <= cur;
|
|
1491
|
-
// Visible columns tile the
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
place(el, shown ? x : i > cur ?
|
|
1460
|
+
// Visible columns tile the run, left to right. Panels crowded out from
|
|
1461
|
+
// under it rest at its left edge; panels beyond the current panel park
|
|
1462
|
+
// just past its right edge — both keep their last width. Deeper panels
|
|
1463
|
+
// layer over shallower ones, each on the odd layer for its depth (see
|
|
1464
|
+
// LAYER_STEP).
|
|
1465
|
+
place(el, shown ? x : i > cur ? left + runSum : left, entry.width, LAYER_STEP * i + 1);
|
|
1497
1466
|
// What `$panel.visible` and `$panel.width` report: this pass is the one
|
|
1498
1467
|
// thing that knows them, window resizes included. Written only on a
|
|
1499
1468
|
// change, so per-panel UI hanging off them isn't rebuilt by every pass.
|
|
@@ -1578,22 +1547,6 @@ function firstText(el) {
|
|
|
1578
1547
|
return t.length > 48 ? `${t.slice(0, 47).trimEnd()}…` : t;
|
|
1579
1548
|
}
|
|
1580
1549
|
}
|
|
1581
|
-
/**
|
|
1582
|
-
* Put a panel's address on the clipboard, as the absolute URL someone can paste
|
|
1583
|
-
* anywhere — which is what the browser's own "Copy link" would have given them.
|
|
1584
|
-
* Confirmed with a toast, since a silent copy leaves you wondering; `writeText`
|
|
1585
|
-
* needs a secure context, so a failure says so rather than lying.
|
|
1586
|
-
*/
|
|
1587
|
-
async function copyLink(path) {
|
|
1588
|
-
const url = new URL(path, location.href).href;
|
|
1589
|
-
try {
|
|
1590
|
-
await navigator.clipboard.writeText(url);
|
|
1591
|
-
toast({ message: "Link copied." });
|
|
1592
|
-
}
|
|
1593
|
-
catch {
|
|
1594
|
-
toast({ message: "Couldn't copy the link.", type: "danger" });
|
|
1595
|
-
}
|
|
1596
|
-
}
|
|
1597
1550
|
function drawDefaultNotFound($panel) {
|
|
1598
1551
|
A("p fg:$s-muted", () => A("#", `No panel at ${$panel.path}`));
|
|
1599
1552
|
}
|