pi-weave 0.1.12 → 0.1.13
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 +8 -37
- package/package.json +1 -2
- package/src/core/concurrency.ts +3 -6
- package/src/core/frontmatter.ts +0 -53
- package/src/core/graph/build.ts +6 -7
- package/src/core/graph/current.ts +2 -4
- package/src/core/graph/model.ts +1 -1
- package/src/core/graph/wikilinks.ts +3 -3
- package/src/core/index.ts +26 -27
- package/src/core/paths.ts +0 -7
- package/src/core/vault.ts +16 -681
- package/src/core/view/detail.ts +1 -1
- package/src/core/view/health.ts +1 -1
- package/src/core/view/tree.ts +1 -1
- package/src/pi/index.ts +6 -85
- package/src/pi/summarize.ts +2 -2
- package/src/pi/viewer/tui/bodyStore.ts +4 -7
- package/src/pi/viewer/tui/branding.ts +7 -148
- package/src/pi/viewer/tui/run.ts +3 -17
- package/src/pi/viewer/tui/surface/base.ts +24 -3
- package/src/pi/viewer/tui/surface/explore.ts +41 -6
- package/src/pi/viewer/tui/workspace.ts +23 -351
- package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
- package/src/pi/viewer/web/run.ts +7 -117
- package/src/web/client/api.dom.ts +2 -2
- package/src/web/client/api.ts +14 -223
- package/src/web/client/bootstrap.ts +5 -14
- package/src/web/client/context/context.model.ts +9 -11
- package/src/web/client/dist/app.js +93 -219
- package/src/web/client/graph/dynamics.ts +5 -65
- package/src/web/client/graph/renderer.dom.ts +7 -8
- package/src/web/client/graph/renderer.ts +9 -35
- package/src/web/client/main.tsx +1 -1
- package/src/web/client/note/Note.tsx +21 -63
- package/src/web/client/search/SearchPalette.tsx +45 -36
- package/src/web/client/search/search.model.ts +33 -454
- package/src/web/client/shell/Columns.tsx +13 -83
- package/src/web/client/shell/Header.tsx +2 -10
- package/src/web/client/shell/Shell.tsx +50 -125
- package/src/web/client/shell/StatusBar.tsx +1 -4
- package/src/web/client/shell/icons.model.ts +4 -7
- package/src/web/client/shell/keys.model.ts +5 -42
- package/src/web/client/shell/keys.ts +2 -2
- package/src/web/client/shell/shell.model.ts +10 -133
- package/src/web/client/shell/theme.model.ts +2 -2
- package/src/web/client/shell/theme.ts +33 -157
- package/src/web/client/state.ts +9 -89
- package/src/web/client/tree/Tree.tsx +25 -575
- package/src/web/client/tree/tree.model.ts +8 -162
- package/src/web/client/workspace.ts +72 -242
- package/src/web/server/page.ts +8 -10
- package/src/web/server/routes.ts +30 -563
- package/src/web/server/server.ts +6 -145
- package/src/web/shared/layout.ts +72 -624
- package/src/web/shared/wire.ts +10 -196
- package/src/core/sessions.ts +0 -929
- package/src/pi/sessionScan.ts +0 -104
- package/src/pi/viewer/tui/explorer.ts +0 -586
- package/src/web/client/live.model.ts +0 -275
- package/src/web/client/live.ts +0 -151
- package/src/web/client/note/Editor.tsx +0 -109
- package/src/web/client/note/editor.controller.ts +0 -151
- package/src/web/client/note/editor.model.ts +0 -686
- package/src/web/client/search/search.ts +0 -107
- package/src/web/client/shell/Divider.tsx +0 -44
- package/src/web/client/shell/cssvars.ts +0 -70
- package/src/web/client/shell/drag.model.ts +0 -170
- package/src/web/client/shell/layout.model.ts +0 -500
- package/src/web/client/shell/viewport.ts +0 -29
- package/src/web/server/sse.ts +0 -321
- package/src/web/server/watcher.ts +0 -507
|
@@ -1,500 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The three-column layout, as pure data (weave-workspace §1.2).
|
|
3
|
-
*
|
|
4
|
-
* §1.2 is emphatic that there is exactly one layout: three resizable columns,
|
|
5
|
-
* a context rail under the graph, widths in `localStorage`, and two
|
|
6
|
-
* breakpoints. No panel engine, no presets. This module is that entire
|
|
7
|
-
* "layout system" — and it is a `.model.ts` rather than logic inside a
|
|
8
|
-
* component because there is no DOM test environment in this repository and
|
|
9
|
-
* we may not add one (§10). Everything below is a pure function over plain
|
|
10
|
-
* objects, so the drag arithmetic, the breakpoint table and the persistence
|
|
11
|
-
* validation are all covered by ordinary unit tests; `Shell.tsx` is left with
|
|
12
|
-
* nothing to do but render what these functions return.
|
|
13
|
-
*
|
|
14
|
-
* ## Widths are fractions, not pixels
|
|
15
|
-
*
|
|
16
|
-
* A stored pixel width is wrong the moment the window is resized, and worse,
|
|
17
|
-
* it is wrong *silently* — restore a 1600 px session on a 1200 px laptop and
|
|
18
|
-
* a column is simply off-screen. So the persisted unit is a fraction of the
|
|
19
|
-
* available width, and pixels only ever exist inside {@link resolveColumns},
|
|
20
|
-
* at the moment of rendering, where the viewport width is known.
|
|
21
|
-
*
|
|
22
|
-
* The invariant that makes this safe is {@link normalizeFractions}: the three
|
|
23
|
-
* fractions always sum to exactly 1 and none is below its minimum share. It
|
|
24
|
-
* is applied on *every* path into a {@link LayoutState} — construction,
|
|
25
|
-
* drag, and deserialization alike — so no caller can produce a state that
|
|
26
|
-
* violates it. That is deliberately stronger than validating at the edges: a
|
|
27
|
-
* layout that has drifted to summing 0.97 renders a 3 % dead stripe that
|
|
28
|
-
* nobody will trace back to a rounding bug three releases ago.
|
|
29
|
-
*
|
|
30
|
-
* ## Tier rules (§2)
|
|
31
|
-
*
|
|
32
|
-
* `src/web/client/**`: no `node:*`, no `src/core`. This file goes further and
|
|
33
|
-
* touches no DOM type either — {@link LayoutStorage} is a two-method
|
|
34
|
-
* interface, not `Storage`, and the viewport arrives as a number. That is
|
|
35
|
-
* what lets the root `tsconfig.json` project (which has no `DOM` lib) compile
|
|
36
|
-
* the tests that import it.
|
|
37
|
-
*/
|
|
38
|
-
|
|
39
|
-
// --- columns -----------------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
/** The three columns of §1.2, left to right. */
|
|
42
|
-
export type ColumnId = "tree" | "note" | "graph";
|
|
43
|
-
|
|
44
|
-
/** Every {@link ColumnId}, in layout order. */
|
|
45
|
-
export const COLUMNS: readonly ColumnId[] = ["tree", "note", "graph"];
|
|
46
|
-
|
|
47
|
-
/** A width per column. The unit depends on the container: see below. */
|
|
48
|
-
export type Columns<T> = { readonly [K in ColumnId]: T };
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Minimum width per column, in CSS pixels.
|
|
52
|
-
*
|
|
53
|
-
* Not arbitrary: the tree must fit `▾ repository` plus a nested path without
|
|
54
|
-
* ellipsis, the note column is the reading surface and gets the largest
|
|
55
|
-
* floor, and the graph needs enough room for the legend row under the canvas
|
|
56
|
-
* to stay on one line. Below these a column is not "small", it is useless,
|
|
57
|
-
* which is why the drag clamps instead of allowing a 12 px sliver.
|
|
58
|
-
*/
|
|
59
|
-
export const MIN_WIDTHS: Columns<number> = { tree: 180, note: 320, graph: 260 };
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* The default split.
|
|
63
|
-
*
|
|
64
|
-
* Note-heavy on purpose. §11's P2 exit criterion is that the workspace is
|
|
65
|
-
* "genuinely useful with no graph at all", and the default arrangement should
|
|
66
|
-
* say the same thing.
|
|
67
|
-
*/
|
|
68
|
-
export const DEFAULT_FRACTIONS: Columns<number> = { tree: 0.22, note: 0.46, graph: 0.32 };
|
|
69
|
-
|
|
70
|
-
// --- breakpoints --------------------------------------------------------------
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Which columns the viewport can afford (§1.2).
|
|
74
|
-
*
|
|
75
|
-
* `"wide"` shows all three; `"medium"` collapses the graph to a toggle;
|
|
76
|
-
* `"narrow"` collapses the tree as well, leaving the note column — the
|
|
77
|
-
* product — alone on screen.
|
|
78
|
-
*/
|
|
79
|
-
export type Breakpoint = "wide" | "medium" | "narrow";
|
|
80
|
-
|
|
81
|
-
/** §1.2: "Below 1100 px the graph column collapses to a toggle." */
|
|
82
|
-
export const BREAKPOINT_MEDIUM = 1100;
|
|
83
|
-
|
|
84
|
-
/** §1.2: "below 800 px the tree does too." */
|
|
85
|
-
export const BREAKPOINT_NARROW = 800;
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Classify a viewport width.
|
|
89
|
-
*
|
|
90
|
-
* Boundaries are inclusive at the top: exactly 1100 px is `"wide"`, because
|
|
91
|
-
* the doc says "below 1100", and an off-by-one here is a column that
|
|
92
|
-
* disappears one pixel early on a very common window size.
|
|
93
|
-
*
|
|
94
|
-
* A non-finite or negative width — which `window.innerWidth` will not
|
|
95
|
-
* produce, but a test double or a detached iframe can — classifies as
|
|
96
|
-
* `"narrow"` rather than throwing. Degrading to the single-column layout is
|
|
97
|
-
* the safe direction: it renders something readable, where a thrown error at
|
|
98
|
-
* render time renders nothing at all.
|
|
99
|
-
*/
|
|
100
|
-
export function breakpointFor(viewport: number): Breakpoint {
|
|
101
|
-
if (!Number.isFinite(viewport) || viewport < BREAKPOINT_NARROW) return "narrow";
|
|
102
|
-
if (viewport < BREAKPOINT_MEDIUM) return "medium";
|
|
103
|
-
return "wide";
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** The columns a breakpoint renders inline, rather than behind a toggle. */
|
|
107
|
-
export function columnsAt(breakpoint: Breakpoint): readonly ColumnId[] {
|
|
108
|
-
if (breakpoint === "wide") return COLUMNS;
|
|
109
|
-
if (breakpoint === "medium") return ["tree", "note"];
|
|
110
|
-
return ["note"];
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Whether a column is collapsed *by the viewport* at this breakpoint.
|
|
115
|
-
*
|
|
116
|
-
* Distinct from a user-toggled panel: this one is not a preference and is not
|
|
117
|
-
* persisted, so a laptop user who widens their window gets their three
|
|
118
|
-
* columns back without having to re-open anything.
|
|
119
|
-
*/
|
|
120
|
-
export function isCollapsed(breakpoint: Breakpoint, column: ColumnId): boolean {
|
|
121
|
-
return !columnsAt(breakpoint).includes(column);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// --- state --------------------------------------------------------------------
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* The persisted layout.
|
|
128
|
-
*
|
|
129
|
-
* `fractions` always satisfies the {@link normalizeFractions} invariant.
|
|
130
|
-
*
|
|
131
|
-
* There was once a `revealed` set here — a toggle that could re-open a
|
|
132
|
-
* viewport-collapsed column — but no surface ever rendered it, so it shipped
|
|
133
|
-
* as dead state that survived persistence and loads. Deleted rather than
|
|
134
|
-
* wired: a documented-but-absent affordance is worse than none, and the
|
|
135
|
-
* breakpoint collapse is the §1.2 behaviour as specified.
|
|
136
|
-
*/
|
|
137
|
-
export interface LayoutState {
|
|
138
|
-
readonly fractions: Columns<number>;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Force `fractions` to sum to 1 with every column above its minimum share.
|
|
143
|
-
*
|
|
144
|
-
* Three passes, in this order, because each depends on the previous:
|
|
145
|
-
*
|
|
146
|
-
* 1. **Sanitise.** Any non-finite or non-positive entry is replaced by its
|
|
147
|
-
* default. `NaN` is the interesting case — it propagates through every
|
|
148
|
-
* subsequent sum and would turn one bad stored value into three broken
|
|
149
|
-
* columns, so it must die here rather than being clamped later.
|
|
150
|
-
* 2. **Scale.** Divide by the total. This is what makes the function
|
|
151
|
-
* idempotent and what lets {@link resizeAt} do naive arithmetic and hand
|
|
152
|
-
* the result back for repair.
|
|
153
|
-
* 3. **Clamp and redistribute.** Lift anything under `minShare` up to it,
|
|
154
|
-
* then take the deficit back from the columns that are still above their
|
|
155
|
-
* floor, in proportion to their slack. If there is no slack anywhere —
|
|
156
|
-
* which happens when the minimums cannot all be honoured at this
|
|
157
|
-
* viewport — the minimums win and the sum is allowed to exceed 1. The
|
|
158
|
-
* alternative is a column below its declared floor, and a container that
|
|
159
|
-
* scrolls is a better failure than a column that cannot be read.
|
|
160
|
-
*
|
|
161
|
-
* @param fractions raw, possibly invalid shares
|
|
162
|
-
* @param minShare per-column floor as a fraction, from {@link minShares}
|
|
163
|
-
*/
|
|
164
|
-
export function normalizeFractions(fractions: Columns<number>, minShare: Columns<number>): Columns<number> {
|
|
165
|
-
return normalizeOver(COLUMNS, fractions, minShare);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* {@link normalizeFractions} over an arbitrary subset of columns.
|
|
170
|
-
*
|
|
171
|
-
* The subset parameter is not a generalisation for its own sake — it is what
|
|
172
|
-
* {@link resolveColumns} needs, and getting it wrong once already produced a
|
|
173
|
-
* real bug. Passing a collapsed column a share of `0` and hoping the
|
|
174
|
-
* three-column normaliser would drop it does not work: pass 1 treats `0` as
|
|
175
|
-
* corrupt and substitutes the default, so the hidden column comes back as
|
|
176
|
-
* invisible padding. Columns outside `ids` are excluded from every pass and
|
|
177
|
-
* emitted as `0`, which is the only formulation that cannot resurrect them.
|
|
178
|
-
*/
|
|
179
|
-
function normalizeOver(
|
|
180
|
-
ids: readonly ColumnId[],
|
|
181
|
-
fractions: Columns<number>,
|
|
182
|
-
minShare: Columns<number>,
|
|
183
|
-
): Columns<number> {
|
|
184
|
-
const clean: Record<ColumnId, number> = { tree: 0, note: 0, graph: 0 };
|
|
185
|
-
for (const id of ids) {
|
|
186
|
-
const raw = fractions[id];
|
|
187
|
-
clean[id] = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_FRACTIONS[id];
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
let total = 0;
|
|
191
|
-
for (const id of ids) total += clean[id];
|
|
192
|
-
for (const id of ids) clean[id] = clean[id] / total;
|
|
193
|
-
|
|
194
|
-
// Pass 3. `deficit` is how much we must borrow to satisfy the floors;
|
|
195
|
-
// `slack` is how much the unclamped columns can spare.
|
|
196
|
-
let deficit = 0;
|
|
197
|
-
let slack = 0;
|
|
198
|
-
const atFloor: Record<ColumnId, boolean> = { tree: false, note: false, graph: false };
|
|
199
|
-
for (const id of ids) {
|
|
200
|
-
const floor = minShare[id];
|
|
201
|
-
if (clean[id] < floor) {
|
|
202
|
-
deficit += floor - clean[id];
|
|
203
|
-
clean[id] = floor;
|
|
204
|
-
atFloor[id] = true;
|
|
205
|
-
} else {
|
|
206
|
-
slack += clean[id] - floor;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
if (deficit > 0 && slack > 0) {
|
|
210
|
-
// Proportional to slack, so a column with room to spare gives up more
|
|
211
|
-
// than one that is nearly at its own floor. Capped by `slack` so that
|
|
212
|
-
// repaying an impossible deficit cannot push a donor below its minimum.
|
|
213
|
-
const rate = Math.min(deficit, slack) / slack;
|
|
214
|
-
for (const id of ids) {
|
|
215
|
-
if (atFloor[id]) continue;
|
|
216
|
-
clean[id] -= (clean[id] - minShare[id]) * rate;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return { tree: clean.tree, note: clean.note, graph: clean.graph };
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* {@link MIN_WIDTHS} expressed as fractions of an available width.
|
|
225
|
-
*
|
|
226
|
-
* When the viewport is too small to honour every minimum the shares are
|
|
227
|
-
* scaled down to leave 10 % of the width unspoken for, rather than being
|
|
228
|
-
* returned as-is summing above 1. Without that, `normalizeFractions` would
|
|
229
|
-
* see three floors it can never satisfy, find zero slack, and return a state
|
|
230
|
-
* that overflows by an unbounded amount. Scaling keeps the columns
|
|
231
|
-
* proportional to their declared importance, which is the closest thing to
|
|
232
|
-
* "right" available at 400 px.
|
|
233
|
-
*/
|
|
234
|
-
export function minShares(available: number): Columns<number> {
|
|
235
|
-
const width = Number.isFinite(available) && available > 0 ? available : 1;
|
|
236
|
-
const raw = {
|
|
237
|
-
tree: MIN_WIDTHS.tree / width,
|
|
238
|
-
note: MIN_WIDTHS.note / width,
|
|
239
|
-
graph: MIN_WIDTHS.graph / width,
|
|
240
|
-
};
|
|
241
|
-
const total = raw.tree + raw.note + raw.graph;
|
|
242
|
-
if (total <= 0.9) return raw;
|
|
243
|
-
const scale = 0.9 / total;
|
|
244
|
-
return { tree: raw.tree * scale, note: raw.note * scale, graph: raw.graph * scale };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** A valid {@link LayoutState} from arbitrary shares. The only constructor. */
|
|
248
|
-
export function makeLayout(fractions: Columns<number>, available: number): LayoutState {
|
|
249
|
-
return {
|
|
250
|
-
fractions: normalizeFractions(fractions, minShares(available)),
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** The §1.2 default, sized for a viewport. */
|
|
255
|
-
export function defaultLayout(available: number): LayoutState {
|
|
256
|
-
return makeLayout(DEFAULT_FRACTIONS, available);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// --- resolution ---------------------------------------------------------------
|
|
260
|
-
|
|
261
|
-
/** A column's rendered width, in CSS pixels. */
|
|
262
|
-
export interface ResolvedColumn {
|
|
263
|
-
readonly id: ColumnId;
|
|
264
|
-
readonly width: number;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Fractions → pixels, for the columns this breakpoint actually renders.
|
|
269
|
-
*
|
|
270
|
-
* Collapsed columns are dropped and their share is redistributed among the
|
|
271
|
-
* survivors rather than left as a gap — at 900 px the tree and note should
|
|
272
|
-
* fill the window, not sit in the left two-thirds of it. Redistribution
|
|
273
|
-
* re-runs {@link normalizeFractions} over the visible subset so the minimums
|
|
274
|
-
* are enforced against the *remaining* width, which is the only width that
|
|
275
|
-
* matters once the graph is behind a toggle.
|
|
276
|
-
*
|
|
277
|
-
* @param available container width in CSS pixels, gutters already subtracted
|
|
278
|
-
*/
|
|
279
|
-
export function resolveColumns(state: LayoutState, available: number, breakpoint: Breakpoint): readonly ResolvedColumn[] {
|
|
280
|
-
const visible = columnsAt(breakpoint);
|
|
281
|
-
const width = Number.isFinite(available) && available > 0 ? available : 0;
|
|
282
|
-
const shares = normalizeOver(visible, state.fractions, minShares(width));
|
|
283
|
-
return visible.map((id) => ({ id, width: shares[id] * width }));
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// --- dragging -----------------------------------------------------------------
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* A divider, named by the column to its left.
|
|
290
|
-
*
|
|
291
|
-
* Two of them: `tree|note` and `note|graph`. A drag moves width between
|
|
292
|
-
* exactly those two neighbours and leaves the third alone, which is what
|
|
293
|
-
* makes the gesture feel local — the §1.2 sketch has no four-way splitter and
|
|
294
|
-
* a divider that reflowed all three columns would be a different, worse
|
|
295
|
-
* interaction.
|
|
296
|
-
*/
|
|
297
|
-
export type DividerId = "tree" | "note";
|
|
298
|
-
|
|
299
|
-
/** Both dividers, left to right. */
|
|
300
|
-
export const DIVIDERS: readonly DividerId[] = ["tree", "note"];
|
|
301
|
-
|
|
302
|
-
/** The pair of columns a divider sits between. */
|
|
303
|
-
export function dividerPair(divider: DividerId): readonly [ColumnId, ColumnId] {
|
|
304
|
-
return divider === "tree" ? ["tree", "note"] : ["note", "graph"];
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Apply a drag: move `deltaPx` of width across `divider`.
|
|
309
|
-
*
|
|
310
|
-
* Positive `delta` widens the left column. The delta is **clamped before it
|
|
311
|
-
* is applied**, against how much room each neighbour has above its floor —
|
|
312
|
-
* not applied first and repaired afterwards. That ordering matters: a raw
|
|
313
|
-
* overshoot drives the shrinking column to a negative share, which
|
|
314
|
-
* {@link normalizeFractions} reads as corrupt and replaces with the default,
|
|
315
|
-
* so the divider would snap to a position nowhere near the pointer. Clamping
|
|
316
|
-
* first gives the behaviour a user pulling a divider into a wall expects:
|
|
317
|
-
* the divider stops, the pointer keeps going, and releasing does not
|
|
318
|
-
* teleport anything.
|
|
319
|
-
*
|
|
320
|
-
* Returns the input state unchanged when nothing moved, so a `mousemove`
|
|
321
|
-
* storm at a clamped edge does not churn signal subscribers on every frame.
|
|
322
|
-
*/
|
|
323
|
-
export function resizeAt(state: LayoutState, divider: DividerId, deltaPx: number, available: number): LayoutState {
|
|
324
|
-
const width = Number.isFinite(available) && available > 0 ? available : 0;
|
|
325
|
-
if (width === 0 || !Number.isFinite(deltaPx) || deltaPx === 0) return state;
|
|
326
|
-
|
|
327
|
-
const [left, right] = dividerPair(divider);
|
|
328
|
-
const floors = minShares(width);
|
|
329
|
-
// How far each neighbour can shrink. `max(0, …)` because a column can
|
|
330
|
-
// already sit below its floor when the floors do not all fit; in that case
|
|
331
|
-
// it simply cannot donate, rather than donating a negative amount.
|
|
332
|
-
const leftRoom = Math.max(0, state.fractions[left] - floors[left]);
|
|
333
|
-
const rightRoom = Math.max(0, state.fractions[right] - floors[right]);
|
|
334
|
-
// Zero means the divider is against a wall — both when the pointer has not
|
|
335
|
-
// moved and when the clamp consumed the whole delta. Returning the input
|
|
336
|
-
// *identically* in both cases is what keeps a `mousemove` storm at a
|
|
337
|
-
// stopped divider from waking every signal subscriber sixty times a second.
|
|
338
|
-
const delta = Math.max(-leftRoom, Math.min(rightRoom, deltaPx / width));
|
|
339
|
-
if (delta === 0) return state;
|
|
340
|
-
|
|
341
|
-
const moved = { ...state.fractions, [left]: state.fractions[left] + delta, [right]: state.fractions[right] - delta };
|
|
342
|
-
return { fractions: normalizeFractions(moved, floors) };
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// --- persistence ---------------------------------------------------------------
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* The slice of `Storage` this module uses.
|
|
349
|
-
*
|
|
350
|
-
* An interface rather than the global for two reasons. The obvious one is
|
|
351
|
-
* testability without a DOM. The other is that `localStorage` *throws* on
|
|
352
|
-
* access in a handful of real configurations — Safari private browsing
|
|
353
|
-
* historically, and any embedding where storage is partitioned off — so the
|
|
354
|
-
* call has to be wrapped somewhere, and a narrow injected port is a better
|
|
355
|
-
* place for that wrapper than a component.
|
|
356
|
-
*/
|
|
357
|
-
export interface LayoutStorage {
|
|
358
|
-
getItem(key: string): string | null;
|
|
359
|
-
setItem(key: string, value: string): void;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/** The `localStorage` key. Namespaced; §1.2 puts widths in storage. */
|
|
363
|
-
export const LAYOUT_STORAGE_KEY = "pi-weave.layout.v1";
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Serialize for storage.
|
|
367
|
-
*
|
|
368
|
-
* Fractions are rounded to four decimals — about a quarter-pixel on a 4K
|
|
369
|
-
* display, and far below what a drag can express — so that a stored value is
|
|
370
|
-
* short and, more usefully, so that a hand-inspected entry is readable.
|
|
371
|
-
*/
|
|
372
|
-
export function serializeLayout(state: LayoutState): string {
|
|
373
|
-
return JSON.stringify({
|
|
374
|
-
v: 1,
|
|
375
|
-
tree: round4(state.fractions.tree),
|
|
376
|
-
note: round4(state.fractions.note),
|
|
377
|
-
graph: round4(state.fractions.graph),
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function round4(value: number): number {
|
|
382
|
-
return Math.round(value * 10000) / 10000;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Parse a stored layout, or `null`.
|
|
387
|
-
*
|
|
388
|
-
* Everything here is untrusted. The string is user-editable by construction —
|
|
389
|
-
* it lives in a devtools pane that anyone can type into — and it also
|
|
390
|
-
* outlives the schema, so a v1 reader will one day meet a v2 entry written by
|
|
391
|
-
* a newer build the user ran yesterday. Both cases have the same correct
|
|
392
|
-
* answer: return `null` and let the caller fall back to the default. There is
|
|
393
|
-
* no repair path, because a partially-repaired layout is a bug report that
|
|
394
|
-
* says "my columns are weird sometimes".
|
|
395
|
-
*
|
|
396
|
-
* Note what is *not* rejected: out-of-range or non-summing fractions. Those
|
|
397
|
-
* go to {@link normalizeFractions}, which is total. Only structural nonsense —
|
|
398
|
-
* not JSON, not an object, wrong version, a missing or non-numeric column —
|
|
399
|
-
* is fatal.
|
|
400
|
-
*/
|
|
401
|
-
export function deserializeLayout(raw: string | null, available: number): LayoutState | null {
|
|
402
|
-
if (raw === null) return null;
|
|
403
|
-
let parsed: unknown;
|
|
404
|
-
try {
|
|
405
|
-
parsed = JSON.parse(raw);
|
|
406
|
-
} catch {
|
|
407
|
-
return null;
|
|
408
|
-
}
|
|
409
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
410
|
-
|
|
411
|
-
const record = parsed as Record<string, unknown>;
|
|
412
|
-
if (record["v"] !== 1) return null;
|
|
413
|
-
|
|
414
|
-
// The `revealed` key of the toggle era is deliberately not parsed: the
|
|
415
|
-
// toggle never rendered, so a stored set is dead weight and unknown keys
|
|
416
|
-
// are ignored the same way devtools experiments are.
|
|
417
|
-
const fractions: Record<ColumnId, number> = { tree: 0, note: 0, graph: 0 };
|
|
418
|
-
for (const id of COLUMNS) {
|
|
419
|
-
const value = record[id];
|
|
420
|
-
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
|
|
421
|
-
fractions[id] = value;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
return makeLayout(fractions, available);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Read the stored layout, falling back to the §1.2 default.
|
|
429
|
-
*
|
|
430
|
-
* Storage access is wrapped: see {@link LayoutStorage}. A throwing
|
|
431
|
-
* `getItem` is indistinguishable from an absent entry as far as the layout is
|
|
432
|
-
* concerned, and the workspace opening with default widths is not an error
|
|
433
|
-
* worth surfacing.
|
|
434
|
-
*/
|
|
435
|
-
export function loadLayout(storage: LayoutStorage, available: number): LayoutState {
|
|
436
|
-
let raw: string | null;
|
|
437
|
-
try {
|
|
438
|
-
raw = storage.getItem(LAYOUT_STORAGE_KEY);
|
|
439
|
-
} catch {
|
|
440
|
-
return defaultLayout(available);
|
|
441
|
-
}
|
|
442
|
-
return deserializeLayout(raw, available) ?? defaultLayout(available);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/**
|
|
446
|
-
* Persist the layout, best-effort.
|
|
447
|
-
*
|
|
448
|
-
* Returns whether it stuck, rather than throwing. `setItem` fails on quota
|
|
449
|
-
* exhaustion and in partitioned-storage contexts, and neither is a reason to
|
|
450
|
-
* break a drag that has already been applied to the live layout — the user's
|
|
451
|
-
* columns move, they just do not survive a reload.
|
|
452
|
-
*/
|
|
453
|
-
export function saveLayout(storage: LayoutStorage, state: LayoutState): boolean {
|
|
454
|
-
try {
|
|
455
|
-
storage.setItem(LAYOUT_STORAGE_KEY, serializeLayout(state));
|
|
456
|
-
return true;
|
|
457
|
-
} catch {
|
|
458
|
-
return false;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// --- rendering support ----------------------------------------------------------
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* The CSS custom property carrying a column's width.
|
|
466
|
-
*
|
|
467
|
-
* **This is a CSP constraint, not a style preference.** §5.2 serves the page
|
|
468
|
-
* with `style-src 'nonce-…'` and no `'unsafe-inline'`, so an inline
|
|
469
|
-
* `style="width:340px"` attribute is *blocked by the browser* — Preact sets
|
|
470
|
-
* that attribute via `dom.style.cssText` for string styles and via
|
|
471
|
-
* `dom.style[key] = …` for object styles, and neither is subject to
|
|
472
|
-
* `style-src`, because CSSOM mutation is not "inline style" in CSP terms.
|
|
473
|
-
* Only a literal `style` attribute in markup is.
|
|
474
|
-
*
|
|
475
|
-
* We use the custom-property form anyway, via `el.style.setProperty`, for a
|
|
476
|
-
* reason beyond CSP: the nonce'd stylesheet in `page.ts` can then own the
|
|
477
|
-
* actual `grid-template-columns` declaration, and the client contributes one
|
|
478
|
-
* number per column instead of a layout rule. That keeps the presentation in
|
|
479
|
-
* CSS where it can be read, and keeps the bundle from carrying a second,
|
|
480
|
-
* competing layout implementation.
|
|
481
|
-
*/
|
|
482
|
-
export function columnVar(column: ColumnId): string {
|
|
483
|
-
return `--weave-col-${column}`;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/** A resolved width as a CSS length. Integral: subpixel columns blur text. */
|
|
487
|
-
export function columnValue(width: number): string {
|
|
488
|
-
return `${Math.round(width)}px`;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/**
|
|
492
|
-
* The full set of custom properties for a resolved layout.
|
|
493
|
-
*
|
|
494
|
-
* Returned as pairs rather than applied, so the caller — a `useLayoutEffect`
|
|
495
|
-
* in the shell — is the only thing that touches an element, and this stays
|
|
496
|
-
* testable without one.
|
|
497
|
-
*/
|
|
498
|
-
export function columnVars(resolved: readonly ResolvedColumn[]): readonly (readonly [string, string])[] {
|
|
499
|
-
return resolved.map((column) => [columnVar(column.id), columnValue(column.width)] as const);
|
|
500
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Watching the viewport width (weave-workspace §1.2).
|
|
3
|
-
*
|
|
4
|
-
* The width is not a cosmetic detail here: it picks the breakpoint, which
|
|
5
|
-
* decides how many columns render, which decides how many dividers exist. So
|
|
6
|
-
* the subscription is worth a test, and a subscription written inline in a
|
|
7
|
-
* `useEffect` is one no test can reach (§10). Four lines, an injected port,
|
|
8
|
-
* and the shell keeps a one-line effect.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/** The slice of `window` this module reads. `window` satisfies it. */
|
|
12
|
-
export interface ViewportHost {
|
|
13
|
-
readonly innerWidth: number;
|
|
14
|
-
addEventListener(type: "resize", listener: () => void): void;
|
|
15
|
-
removeEventListener(type: "resize", listener: () => void): void;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Call `onChange` with the width whenever the window resizes.
|
|
20
|
-
*
|
|
21
|
-
* Returns an unsubscribe. The listener is *not* invoked eagerly: the shell
|
|
22
|
-
* already seeds its state from `initialWidth` at mount, and firing here would
|
|
23
|
-
* make the first render set state during an effect for no change in value.
|
|
24
|
-
*/
|
|
25
|
-
export function watchViewport(host: ViewportHost, onChange: (width: number) => void): () => void {
|
|
26
|
-
const listener = (): void => onChange(host.innerWidth);
|
|
27
|
-
host.addEventListener("resize", listener);
|
|
28
|
-
return () => host.removeEventListener("resize", listener);
|
|
29
|
-
}
|