pi-weave 0.1.8 → 0.1.10
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 +9 -3
- package/package.json +2 -2
- package/skills/weave-notepad/SKILL.md +13 -4
- package/src/core/concurrency.ts +36 -0
- package/src/core/frontmatter.ts +53 -0
- package/src/core/graph/build.ts +60 -1
- package/src/core/graph/model.ts +15 -0
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +2 -0
- package/src/core/paths.ts +7 -0
- package/src/core/sessions.ts +929 -0
- package/src/core/summaries.ts +1 -19
- package/src/core/vault.ts +264 -16
- package/src/core/workspace.ts +3 -3
- package/src/pi/index.ts +144 -37
- package/src/pi/sessionScan.ts +104 -0
- package/src/pi/summarize.ts +24 -4
- package/src/pi/tools/noteTool.ts +13 -4
- package/src/pi/viewer/tui/branding.ts +8 -7
- package/src/pi/viewer/tui/explorer.ts +1 -1
- package/src/web/client/dist/app.js +272 -113
- package/src/web/client/graph/Graph.tsx +112 -22
- package/src/web/client/graph/column.model.ts +52 -94
- package/src/web/client/graph/dynamics.ts +181 -0
- package/src/web/client/graph/graph.model.ts +113 -34
- package/src/web/client/graph/positions.ts +58 -13
- package/src/web/client/graph/renderer.ts +89 -5
- package/src/web/client/graph/scheme.ts +51 -6
- package/src/web/client/main.tsx +12 -0
- package/src/web/client/note/Editor.tsx +16 -9
- package/src/web/client/note/Note.tsx +40 -7
- package/src/web/client/note/note.model.ts +1 -1
- package/src/web/client/search/SearchPalette.tsx +11 -2
- package/src/web/client/selection.storage.ts +69 -0
- package/src/web/client/shell/Columns.tsx +13 -0
- package/src/web/client/shell/Header.tsx +25 -1
- package/src/web/client/shell/Shell.tsx +76 -2
- package/src/web/client/shell/deeplink.model.ts +61 -0
- package/src/web/client/shell/focus.model.ts +14 -6
- package/src/web/client/shell/keys.model.ts +27 -3
- package/src/web/client/shell/layout.model.ts +12 -38
- package/src/web/client/shell/theme.model.ts +147 -0
- package/src/web/client/shell/theme.ts +289 -79
- package/src/web/client/state.ts +22 -0
- package/src/web/client/tree/Tree.tsx +11 -2
- package/src/web/client/tree/tree.model.ts +12 -1
- package/src/web/client/workspace.ts +78 -6
- package/src/web/server/page.ts +6 -4
- package/src/web/server/routes.ts +19 -7
- package/src/web/shared/graph.ts +7 -0
- package/src/web/shared/layout.ts +393 -307
- package/src/web/shared/logo.ts +11 -0
|
@@ -67,53 +67,78 @@ export type ColorScheme = "dark" | "light";
|
|
|
67
67
|
*/
|
|
68
68
|
export const GRAPH_PALETTE: Readonly<Record<ColorScheme, Readonly<Record<ColorSlot, string>>>> = {
|
|
69
69
|
dark: {
|
|
70
|
-
accent: "#
|
|
71
|
-
success: "#
|
|
72
|
-
warning: "#
|
|
73
|
-
dim: "#
|
|
74
|
-
text: "#
|
|
75
|
-
muted: "#
|
|
76
|
-
line: "#
|
|
70
|
+
accent: "#b79fdd",
|
|
71
|
+
success: "#7fc49a",
|
|
72
|
+
warning: "#e8b04c",
|
|
73
|
+
dim: "#bb9ecf",
|
|
74
|
+
text: "#fde2f3",
|
|
75
|
+
muted: "#aca3d4",
|
|
76
|
+
line: "#4a527e",
|
|
77
77
|
},
|
|
78
78
|
light: {
|
|
79
|
-
accent: "#
|
|
80
|
-
success: "#
|
|
81
|
-
warning: "#
|
|
82
|
-
dim: "#
|
|
83
|
-
text: "#
|
|
84
|
-
muted: "#
|
|
85
|
-
line: "#
|
|
79
|
+
accent: "#85586f",
|
|
80
|
+
success: "#3f704e",
|
|
81
|
+
warning: "#a05a1c",
|
|
82
|
+
dim: "#7c6257",
|
|
83
|
+
text: "#43303a",
|
|
84
|
+
muted: "#7f6455",
|
|
85
|
+
line: "#d0b8a8",
|
|
86
86
|
},
|
|
87
87
|
};
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* Node kind → colour slot.
|
|
91
91
|
*
|
|
92
|
-
* The
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
92
|
+
* The shell's design language (shell/theme.ts) is **three greys doing the
|
|
93
|
+
* structural work and one accent used sparingly** — success/warning are
|
|
94
|
+
* status colours there (provenance badges, the connection dot), not
|
|
95
|
+
* decoration. Mapping code kinds onto them painted the canvas green and
|
|
96
|
+
* amber and read as a different app inside the workspace, so every kind
|
|
97
|
+
* except the three that carry identity recedes into `dim`:
|
|
98
|
+
*
|
|
99
|
+
* - `vault` / `repository` keep `accent` — the two knowledge anchors, the
|
|
100
|
+
* same violet the note column's wikilinks are painted in.
|
|
101
|
+
* - `note` keeps `text` — notes are the product (§1.1), and their identity
|
|
102
|
+
* is carried by the provenance badge, not by a hue.
|
|
103
|
+
* - Generated code — modules, packages, entry points, git, externals, files
|
|
104
|
+
* — takes `dim`, the same grey a `generated` row takes in the tree. The
|
|
105
|
+
* TUI's `kindStyle` (src/pi/viewer/tui/theme.ts) still maps these kinds to
|
|
106
|
+
* success/warning; the two media disagree on purpose, because a pi theme
|
|
107
|
+
* has no dark/light stylesheet to violate and a terminal can restyle
|
|
108
|
+
* everything. Here the sheet decides, and the sheet says calm.
|
|
98
109
|
*/
|
|
99
110
|
export const KIND_SLOT: Readonly<Record<WireNodeKind, ColorSlot>> = {
|
|
100
111
|
vault: "accent",
|
|
101
112
|
note: "text",
|
|
102
113
|
repository: "accent",
|
|
103
|
-
module: "
|
|
104
|
-
package: "
|
|
105
|
-
entryPoint: "
|
|
106
|
-
gitState: "
|
|
107
|
-
external: "
|
|
114
|
+
module: "dim",
|
|
115
|
+
package: "dim",
|
|
116
|
+
entryPoint: "dim",
|
|
117
|
+
gitState: "dim",
|
|
118
|
+
external: "dim",
|
|
108
119
|
file: "dim",
|
|
109
120
|
};
|
|
110
121
|
|
|
111
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Edge kind → colour slot. Structure recedes; association is the accent.
|
|
124
|
+
*
|
|
125
|
+
* "Recedes" used to mean `line` (`--weave-line-strong`), which read as
|
|
126
|
+
* *absent* — 1.5:1 against either background, below the 3:1 non-text minimum,
|
|
127
|
+
* and the reason the skeleton of the graph was invisible until something was
|
|
128
|
+
* selected. The `dim` slot is 5.5:1 against the dark background and 4.9:1
|
|
129
|
+
* against the light one, so containment reads as the hairline scaffolding it
|
|
130
|
+
* is without vanishing.
|
|
131
|
+
*
|
|
132
|
+
* `mentions` joins `links-to` on the accent. Both are content associations
|
|
133
|
+
* between human knowledge and the rest of the graph — splitting them across
|
|
134
|
+
* two hues is what put a rainbow on the canvas — and §1.3's rail is where
|
|
135
|
+
* the two kinds are told apart, at reading size, with labels.
|
|
136
|
+
*/
|
|
112
137
|
export const EDGE_SLOT: Readonly<Record<WireEdgeKind, ColorSlot>> = {
|
|
113
|
-
contains: "
|
|
114
|
-
"anchored-at": "
|
|
138
|
+
contains: "dim",
|
|
139
|
+
"anchored-at": "dim",
|
|
115
140
|
"links-to": "accent",
|
|
116
|
-
mentions: "
|
|
141
|
+
mentions: "accent",
|
|
117
142
|
};
|
|
118
143
|
|
|
119
144
|
/** The colour a node kind is drawn in. */
|
|
@@ -131,9 +156,13 @@ export function edgeColor(kind: WireEdgeKind, scheme: ColorScheme): string {
|
|
|
131
156
|
/**
|
|
132
157
|
* The smallest a node is ever drawn, in layout units.
|
|
133
158
|
*
|
|
134
|
-
* A leaf still has to be clickable, and sigma's hit test is the drawn radius
|
|
159
|
+
* A leaf still has to be clickable, and sigma's hit test is the drawn radius —
|
|
160
|
+
* at fit zoom a leaf renders at `MIN_NODE_SIZE · cameraCorrection`, so a floor
|
|
161
|
+
* of 6 is what keeps every node inside a pointer's reach at overview zoom.
|
|
162
|
+
* (The d3 gallery's force-directed graphs draw every node the same radius for
|
|
163
|
+
* exactly this reason; size still varies by degree up to the ceiling.)
|
|
135
164
|
*/
|
|
136
|
-
export const MIN_NODE_SIZE =
|
|
165
|
+
export const MIN_NODE_SIZE = 6;
|
|
137
166
|
|
|
138
167
|
/**
|
|
139
168
|
* The degree at which a node reaches {@link NODE_RADIUS}.
|
|
@@ -167,10 +196,10 @@ export function nodeSize(degree: number): number {
|
|
|
167
196
|
return MIN_NODE_SIZE + (NODE_RADIUS - MIN_NODE_SIZE) * share;
|
|
168
197
|
}
|
|
169
198
|
|
|
170
|
-
/** Edge thickness by kind.
|
|
199
|
+
/** Edge thickness by kind. A wikilink keeps its weight; structure is hairline. */
|
|
171
200
|
export const EDGE_SIZE: Readonly<Record<WireEdgeKind, number>> = {
|
|
172
|
-
contains:
|
|
173
|
-
"anchored-at":
|
|
201
|
+
contains: 1,
|
|
202
|
+
"anchored-at": 1,
|
|
174
203
|
"links-to": 1.4,
|
|
175
204
|
mentions: 1,
|
|
176
205
|
};
|
|
@@ -247,6 +276,52 @@ export interface RenderGraph {
|
|
|
247
276
|
readonly edges: readonly RenderEdge[];
|
|
248
277
|
}
|
|
249
278
|
|
|
279
|
+
/** The normalization box the renderer freezes the view to. See {@link frameBox}. */
|
|
280
|
+
export interface ViewBox {
|
|
281
|
+
readonly x: readonly [number, number];
|
|
282
|
+
readonly y: readonly [number, number];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The box the graph is framed on — sigma's `customBBox`.
|
|
287
|
+
*
|
|
288
|
+
* ## Why a box is frozen at all
|
|
289
|
+
*
|
|
290
|
+
* Sigma's default `autoRescale` recomputes the graph→viewport normalization
|
|
291
|
+
* from the **current extent on every refresh**, and a drag repaints every
|
|
292
|
+
* frame. The consequence, measured on this repository's graph: the moment a
|
|
293
|
+
* dragged node crosses the extent boundary — at the edge of the view — the
|
|
294
|
+
* whole graph rescales under the cursor, so the view "suddenly makes a
|
|
295
|
+
* distance" and the node ends up dragged very far from the centre while the
|
|
296
|
+
* user chases it. A frozen box makes the coordinates stable for the whole
|
|
297
|
+
* session: the node stays under the cursor, the canvas bounds the drag, and
|
|
298
|
+
* `[fit]` re-frames onto whatever the current positions are.
|
|
299
|
+
*
|
|
300
|
+
* The box is the exact extent of the rendered positions — no padding of its
|
|
301
|
+
* own, because sigma's `stagePadding` (in pixels) already insets the fit, and
|
|
302
|
+
* the same box handed back on `fit()` re-frames dragged-apart graphs.
|
|
303
|
+
*
|
|
304
|
+
* `null` for an empty graph: there is nothing to frame, and the caller
|
|
305
|
+
* clears the override so sigma falls back to its own behaviour.
|
|
306
|
+
*/
|
|
307
|
+
export function frameBox(points: readonly Point[]): ViewBox | null {
|
|
308
|
+
let minX = Infinity;
|
|
309
|
+
let minY = Infinity;
|
|
310
|
+
let maxX = -Infinity;
|
|
311
|
+
let maxY = -Infinity;
|
|
312
|
+
let seen = 0;
|
|
313
|
+
for (const p of points) {
|
|
314
|
+
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue;
|
|
315
|
+
seen++;
|
|
316
|
+
if (p.x < minX) minX = p.x;
|
|
317
|
+
if (p.x > maxX) maxX = p.x;
|
|
318
|
+
if (p.y < minY) minY = p.y;
|
|
319
|
+
if (p.y > maxY) maxY = p.y;
|
|
320
|
+
}
|
|
321
|
+
if (seen === 0) return null;
|
|
322
|
+
return { x: [minX, maxX], y: [minY, maxY] };
|
|
323
|
+
}
|
|
324
|
+
|
|
250
325
|
/** The empty graph. What the column renders before the first payload lands. */
|
|
251
326
|
export const EMPTY_RENDER_GRAPH: RenderGraph = { nodes: [], edges: [] };
|
|
252
327
|
|
|
@@ -455,6 +530,7 @@ export function edgeReducer(
|
|
|
455
530
|
*/
|
|
456
531
|
export interface GraphSettings {
|
|
457
532
|
readonly hideEdgesOnMove: boolean;
|
|
533
|
+
readonly enableCameraPanning: boolean;
|
|
458
534
|
readonly renderEdgeLabels: boolean;
|
|
459
535
|
readonly labelDensity: number;
|
|
460
536
|
readonly labelGridCellSize: number;
|
|
@@ -505,6 +581,9 @@ export function graphSettings(scheme: ColorScheme): GraphSettings {
|
|
|
505
581
|
// §7.4. A pan over a few thousand edges is the one interaction that drops
|
|
506
582
|
// frames, and the edges are the part nobody is reading mid-gesture.
|
|
507
583
|
hideEdgesOnMove: true,
|
|
584
|
+
// The renderer toggles this off while a node is being dragged, so the
|
|
585
|
+
// gesture pins the node to the cursor instead of panning the camera.
|
|
586
|
+
enableCameraPanning: true,
|
|
508
587
|
renderEdgeLabels: false,
|
|
509
588
|
labelDensity: LABEL_DENSITY,
|
|
510
589
|
labelGridCellSize: LABEL_GRID_CELL_SIZE,
|
|
@@ -34,10 +34,34 @@
|
|
|
34
34
|
* the root `tsconfig.json` project compiles the tests.
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
import { computeLayout
|
|
37
|
+
import { computeLayout } from "../../shared/layout";
|
|
38
38
|
import type { LayoutOptions, Point } from "../../shared/layout";
|
|
39
39
|
import type { WireGraphEdge, WireGraphNode } from "../../shared/wire";
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* FNV-1a (32-bit) followed by MurmurHash3's `fmix32` avalanche.
|
|
43
|
+
*
|
|
44
|
+
* The finalizer is not optional: FNV-1a mixes its *low* bits well but its high
|
|
45
|
+
* bits poorly for short, near-identical inputs — and the digest folds the hash
|
|
46
|
+
* into a bounded key, so the high bits are the significant part. Raw FNV-1a
|
|
47
|
+
* over `leaf001…leaf199` put 199 ids into six of twelve sectors; `fmix32`
|
|
48
|
+
* costs four lines and makes every bit depend on every input bit.
|
|
49
|
+
*/
|
|
50
|
+
export function hashId(id: string): number {
|
|
51
|
+
let h = 0x811c9dc5;
|
|
52
|
+
for (let i = 0; i < id.length; i++) {
|
|
53
|
+
h ^= id.charCodeAt(i);
|
|
54
|
+
h = Math.imul(h, 0x01000193);
|
|
55
|
+
}
|
|
56
|
+
h ^= h >>> 16;
|
|
57
|
+
h = Math.imul(h, 0x85ebca6b);
|
|
58
|
+
h ^= h >>> 13;
|
|
59
|
+
h = Math.imul(h, 0xc2b2ae35);
|
|
60
|
+
h ^= h >>> 16;
|
|
61
|
+
return h >>> 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
41
65
|
// --- computing ----------------------------------------------------------------------
|
|
42
66
|
|
|
43
67
|
/**
|
|
@@ -64,22 +88,39 @@ export const LAYOUT_SEED = 1;
|
|
|
64
88
|
* re-running from a settled state is a near-fixed-point, which is the property
|
|
65
89
|
* that makes an expand feel like an expand.
|
|
66
90
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
91
|
+
* **New containment children start at their parent** (d3's own collapse/
|
|
92
|
+
* expand pattern): a newly visible node without a stored position enters the
|
|
93
|
+
* simulation at its parent's warm position, so the relaxation it needs is
|
|
94
|
+
* local — the collide force makes it step out among its siblings — instead of
|
|
95
|
+
* d3's phyllotaxis spiral dropping it across the graph and dragging the whole
|
|
96
|
+
* arrangement along.
|
|
97
|
+
*
|
|
98
|
+
* The viewport is **not** a parameter, deliberately. The layout is centred on
|
|
99
|
+
* the origin by `forceX()`/`forceY()`, and sigma's camera fits whatever extent
|
|
100
|
+
* it is handed — so feeding the real column width in would re-run the
|
|
101
|
+
* simulation on every divider drag to produce a picture the camera
|
|
102
|
+
* immediately normalises away. Keeping the layout a function of the *graph*
|
|
103
|
+
* alone is what makes it cacheable at all.
|
|
73
104
|
*/
|
|
74
105
|
export function layoutFor(
|
|
75
106
|
nodes: readonly WireGraphNode[],
|
|
76
107
|
edges: readonly WireGraphEdge[],
|
|
77
108
|
warm?: ReadonlyMap<string, Point>,
|
|
78
109
|
): Map<string, Point> {
|
|
79
|
-
const
|
|
110
|
+
const seeded = new Map<string, Point>(warm ?? []);
|
|
111
|
+
if (seeded.size > 0) {
|
|
112
|
+
for (const e of edges) {
|
|
113
|
+
if (e.kind !== "contains" && e.kind !== "anchored-at") continue;
|
|
114
|
+
if (seeded.has(e.target)) continue;
|
|
115
|
+
const at = seeded.get(e.source);
|
|
116
|
+
if (at !== undefined) seeded.set(e.target, { x: at.x, y: at.y });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const options: LayoutOptions = { ticks: LAYOUT_TICKS, seed: LAYOUT_SEED, pinWarm: true };
|
|
120
|
+
if (seeded.size > 0) options.initial = seeded;
|
|
80
121
|
return computeLayout(
|
|
81
|
-
{ generatedAt: "", staleness: null, nodes: [...nodes], edges: [...edges] },
|
|
82
|
-
|
|
122
|
+
{ generatedAt: "", staleness: null, nodes: [...nodes], edges: [...edges], contentDigest: "" },
|
|
123
|
+
options,
|
|
83
124
|
);
|
|
84
125
|
}
|
|
85
126
|
|
|
@@ -161,7 +202,7 @@ export interface PositionStorage {
|
|
|
161
202
|
}
|
|
162
203
|
|
|
163
204
|
/** The `localStorage` key. Namespaced and versioned, like the layout's. */
|
|
164
|
-
export const POSITIONS_STORAGE_KEY = "pi-weave.graph.positions.
|
|
205
|
+
export const POSITIONS_STORAGE_KEY = "pi-weave.graph.positions.v2";
|
|
165
206
|
|
|
166
207
|
/**
|
|
167
208
|
* Coordinates are rounded to **one** decimal before storage.
|
|
@@ -188,7 +229,11 @@ function round1(value: number): number {
|
|
|
188
229
|
export function serializePositions(key: string, positions: ReadonlyMap<string, Point>): string {
|
|
189
230
|
const at: Record<string, [number, number]> = {};
|
|
190
231
|
for (const [id, point] of positions) at[id] = [round1(point.x), round1(point.y)];
|
|
191
|
-
|
|
232
|
+
// `v: 2` — the branch-anchor layout (separated sibling blobs). A v1 entry
|
|
233
|
+
// is a tangled arrangement from the single-centre recipe, and the shape key
|
|
234
|
+
// cannot tell the two apart because the recipe changed under the same node
|
|
235
|
+
// and edge set. The version can.
|
|
236
|
+
return JSON.stringify({ v: 2, key, at });
|
|
192
237
|
}
|
|
193
238
|
|
|
194
239
|
/**
|
|
@@ -220,7 +265,7 @@ export function deserializePositions(raw: string | null, key: string): Map<strin
|
|
|
220
265
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
221
266
|
|
|
222
267
|
const record = parsed as Record<string, unknown>;
|
|
223
|
-
if (record["v"] !==
|
|
268
|
+
if (record["v"] !== 2) return null;
|
|
224
269
|
if (record["key"] !== key) return null;
|
|
225
270
|
|
|
226
271
|
const at = record["at"];
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
*/
|
|
50
50
|
|
|
51
51
|
import type { Point } from "../../shared/layout";
|
|
52
|
-
import type { ColorScheme, EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode } from "./graph.model";
|
|
53
|
-
import { edgeReducer, graphSettings, nodeReducer } from "./graph.model";
|
|
52
|
+
import type { ColorScheme, EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, ViewBox } from "./graph.model";
|
|
53
|
+
import { edgeReducer, frameBox, graphSettings, nodeReducer } from "./graph.model";
|
|
54
54
|
import type { ProjectedGraph } from "./project";
|
|
55
55
|
import { positionsOf, project, syncPositions } from "./project";
|
|
56
56
|
|
|
@@ -94,6 +94,12 @@ export interface GraphRenderer {
|
|
|
94
94
|
fit(): void;
|
|
95
95
|
/** Current positions, for warm-starting a re-run. */
|
|
96
96
|
positions(): Map<string, Point>;
|
|
97
|
+
/** Called when the user starts dragging a node. */
|
|
98
|
+
onDragStart(handler: (id: string) => void): void;
|
|
99
|
+
/** Called with graph-space coordinates while a node is dragged. */
|
|
100
|
+
onDragMove(handler: (id: string, at: Point) => void): void;
|
|
101
|
+
/** Called when a node drag ends. */
|
|
102
|
+
onDragEnd(handler: (id: string) => void): void;
|
|
97
103
|
/** Tear down. Idempotent, and must leave no listener behind. */
|
|
98
104
|
destroy(): void;
|
|
99
105
|
}
|
|
@@ -130,6 +136,9 @@ export function nullRenderer(): GraphRenderer {
|
|
|
130
136
|
setHighlight() {},
|
|
131
137
|
onSelect() {},
|
|
132
138
|
fit() {},
|
|
139
|
+
onDragStart() {},
|
|
140
|
+
onDragMove() {},
|
|
141
|
+
onDragEnd() {},
|
|
133
142
|
positions() {
|
|
134
143
|
return new Map();
|
|
135
144
|
},
|
|
@@ -174,9 +183,23 @@ export interface CameraLike {
|
|
|
174
183
|
export interface SigmaLike {
|
|
175
184
|
on(event: "clickNode", handler: (payload: { node: string }) => void): unknown;
|
|
176
185
|
on(event: "clickStage", handler: () => void): unknown;
|
|
186
|
+
on(event: "downNode", handler: (payload: { node: string }) => void): unknown;
|
|
187
|
+
on(event: "moveBody", handler: (payload: { event: { x: number; y: number }; preventSigmaDefault(): void }) => void): unknown;
|
|
188
|
+
on(event: "upNode" | "upStage", handler: () => void): unknown;
|
|
189
|
+
viewportToGraph(position: { x: number; y: number }): Point;
|
|
177
190
|
setSetting(key: "nodeReducer", value: (id: string, data: RenderNode) => NodeDisplayOverride): unknown;
|
|
178
191
|
setSetting(key: "edgeReducer", value: (key: string, data: RenderEdge) => EdgeDisplayOverride): unknown;
|
|
192
|
+
setSetting(key: "enableCameraPanning", value: boolean): unknown;
|
|
179
193
|
setGraph(graph: ProjectedGraph): unknown;
|
|
194
|
+
/**
|
|
195
|
+
* Freeze the graph→viewport normalization onto a fixed box.
|
|
196
|
+
*
|
|
197
|
+
* Sigma's own `autoRescale` would recompute that mapping from the moving
|
|
198
|
+
* extent on every repaint — which a drag does every frame — so crossing the
|
|
199
|
+
* view's edge rescaled the whole graph under the cursor. `graph.model.ts`'s
|
|
200
|
+
* {@link frameBox} records the why in full.
|
|
201
|
+
*/
|
|
202
|
+
setCustomBBox(box: ViewBox | null): unknown;
|
|
180
203
|
refresh(): unknown;
|
|
181
204
|
getCamera(): CameraLike;
|
|
182
205
|
kill(): void;
|
|
@@ -212,6 +235,24 @@ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphR
|
|
|
212
235
|
let graph: ProjectedGraph = project({ nodes: [], edges: [] });
|
|
213
236
|
let highlight: ReadonlySet<string> | null = null;
|
|
214
237
|
let select: (id: string | null) => void = () => {};
|
|
238
|
+
let dragStart: (id: string) => void = () => {};
|
|
239
|
+
let dragMove: (id: string, at: Point) => void = () => {};
|
|
240
|
+
let dragEnd: (id: string) => void = () => {};
|
|
241
|
+
let dragging: string | null = null;
|
|
242
|
+
|
|
243
|
+
const endDrag = (id: string | null): void => {
|
|
244
|
+
if (dragging === null) return;
|
|
245
|
+
const current = dragging;
|
|
246
|
+
dragging = null;
|
|
247
|
+
// A node drag is a pin, not a pan: sigma's captor would otherwise move the
|
|
248
|
+
// camera on the same gesture that is moving the node, and the node would
|
|
249
|
+
// slide away from the cursor. Re-enable panning only when the drag ends.
|
|
250
|
+
sigma?.setSetting("enableCameraPanning", true);
|
|
251
|
+
if (id === null || id === current) dragEnd(current);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** The box to freeze the view on, from the graph's *current* positions. */
|
|
255
|
+
const boxOf = (): ViewBox | null => frameBox([...positionsOf(graph).values()]);
|
|
215
256
|
|
|
216
257
|
/**
|
|
217
258
|
* Push the current highlight into sigma's reducers (§7.4).
|
|
@@ -236,13 +277,41 @@ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphR
|
|
|
236
277
|
// the tree and the context rail all recompute from it.
|
|
237
278
|
instance.on("clickNode", ({ node }) => select(node));
|
|
238
279
|
instance.on("clickStage", () => select(null));
|
|
280
|
+
instance.on("downNode", ({ node }) => {
|
|
281
|
+
dragging = node;
|
|
282
|
+
// Hold the view still while the node follows the cursor (see endDrag).
|
|
283
|
+
sigma?.setSetting("enableCameraPanning", false);
|
|
284
|
+
dragStart(node);
|
|
285
|
+
});
|
|
286
|
+
instance.on("moveBody", (payload) => {
|
|
287
|
+
if (dragging !== null) {
|
|
288
|
+
// Sigma's captor pans the camera on every mouse move while the
|
|
289
|
+
// button is down; during a node drag that is the pan the gesture
|
|
290
|
+
// must not be. `preventSigmaDefault` is the captor's own gate — it
|
|
291
|
+
// is consulted right after this handler returns — and without it
|
|
292
|
+
// the camera follows the cursor 1:1 while the pin follows it too,
|
|
293
|
+
// so the node appears to slide away under the view.
|
|
294
|
+
payload.preventSigmaDefault();
|
|
295
|
+
dragMove(dragging, instance.viewportToGraph({ x: payload.event.x, y: payload.event.y }));
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
instance.on("upNode", () => endDrag(dragging));
|
|
299
|
+
instance.on("upStage", () => endDrag(null));
|
|
239
300
|
applyReducers(instance);
|
|
301
|
+
// Freeze the view box for whatever graph arrived before mount, so the
|
|
302
|
+
// first frame is already framed and stable — see `SigmaLike.setCustomBBox`.
|
|
303
|
+
instance.setCustomBBox(boxOf());
|
|
240
304
|
sigma = instance;
|
|
241
305
|
},
|
|
242
306
|
|
|
243
307
|
setGraph(next) {
|
|
244
308
|
graph = project(next);
|
|
245
309
|
sigma?.setGraph(graph);
|
|
310
|
+
// Re-frame on a shape change: new nodes legitimately change the extent,
|
|
311
|
+
// and the box is what keeps the *drag* frames from also changing it.
|
|
312
|
+
// Deliberately not touched by `setPositions` — a settling simulation
|
|
313
|
+
// must not move the view under the user.
|
|
314
|
+
sigma?.setCustomBBox(boxOf());
|
|
246
315
|
},
|
|
247
316
|
|
|
248
317
|
setPositions(positions) {
|
|
@@ -261,16 +330,31 @@ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphR
|
|
|
261
330
|
select = handler;
|
|
262
331
|
},
|
|
263
332
|
|
|
333
|
+
onDragStart(handler) {
|
|
334
|
+
dragStart = handler;
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
onDragMove(handler) {
|
|
338
|
+
dragMove = handler;
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
onDragEnd(handler) {
|
|
342
|
+
dragEnd = handler;
|
|
343
|
+
},
|
|
344
|
+
|
|
264
345
|
fit() {
|
|
265
|
-
//
|
|
266
|
-
//
|
|
346
|
+
// Re-frame onto the **current** positions — a graph the user has pulled
|
|
347
|
+
// apart wants its dragged nodes inside the view — then reset the camera
|
|
348
|
+
// onto that box. `animatedReset` settles when the animation ends. Nothing
|
|
349
|
+
// awaits a camera move, and a rejection from a camera killed mid-flight
|
|
350
|
+
// is noise.
|
|
351
|
+
sigma?.setCustomBBox(boxOf());
|
|
267
352
|
void sigma?.getCamera().animatedReset();
|
|
268
353
|
},
|
|
269
354
|
|
|
270
355
|
positions() {
|
|
271
356
|
return positionsOf(graph);
|
|
272
357
|
},
|
|
273
|
-
|
|
274
358
|
destroy() {
|
|
275
359
|
sigma?.kill();
|
|
276
360
|
sigma = null;
|
|
@@ -18,14 +18,14 @@ import type { ColorScheme } from "./graph.model";
|
|
|
18
18
|
/**
|
|
19
19
|
* The slice of `window` this module reads.
|
|
20
20
|
*
|
|
21
|
-
* `matchMedia` returns far more than
|
|
22
|
-
* would make the module uncompilable by the root
|
|
23
|
-
* `DOM` lib), which is what a test importing it
|
|
24
|
-
* is the same trick `viewport.ts` and
|
|
25
|
-
* satisfies it structurally.
|
|
21
|
+
* `matchMedia` returns far more than a {@link SchemeList}, and naming
|
|
22
|
+
* `MediaQueryList` would make the module uncompilable by the root
|
|
23
|
+
* `tsconfig.json` project (no `DOM` lib), which is what a test importing it
|
|
24
|
+
* runs under. The narrow shape is the same trick `viewport.ts` and
|
|
25
|
+
* `cssvars.ts` use, and the real `window` satisfies it structurally.
|
|
26
26
|
*/
|
|
27
27
|
export interface SchemeHost {
|
|
28
|
-
matchMedia(query: string):
|
|
28
|
+
matchMedia(query: string): SchemeList;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** The query. Light is the exception; see the module header. */
|
|
@@ -42,3 +42,48 @@ export function schemeOf(host: SchemeHost | null | undefined): ColorScheme {
|
|
|
42
42
|
if (host === null || host === undefined || typeof host.matchMedia !== "function") return "dark";
|
|
43
43
|
return host.matchMedia(LIGHT_QUERY).matches ? "light" : "dark";
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
// --- the live watcher ---------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The slice of `MediaQueryList` the watcher reads.
|
|
50
|
+
*
|
|
51
|
+
* Three members, so the platform's list satisfies it structurally and a fake
|
|
52
|
+
* is an object literal with a listener slot.
|
|
53
|
+
*/
|
|
54
|
+
export interface SchemeList {
|
|
55
|
+
readonly matches: boolean;
|
|
56
|
+
addEventListener(type: "change", listener: () => void): void;
|
|
57
|
+
removeEventListener(type: "change", listener: () => void): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Watch for an OS scheme flip, calling back with the resolved scheme.
|
|
62
|
+
*
|
|
63
|
+
* The stylesheet answers an OS flip on its own (the media query repaints
|
|
64
|
+
* without JavaScript), but the graph cannot: sigma's palette is fixed at
|
|
65
|
+
* renderer construction ("the scheme is fixed at construction" —
|
|
66
|
+
* `renderer.ts`), so the shell must learn about the flip to hand the column a
|
|
67
|
+
* new scheme and let it remount. This is that one listener.
|
|
68
|
+
*
|
|
69
|
+
* The returned `stop` removes the listener; the shell registers the watch in
|
|
70
|
+
* a mount effect, so a hot reload cannot stack listeners. A host without
|
|
71
|
+
* `matchMedia` gets a do-nothing cleanup — the same "legible, not throwing"
|
|
72
|
+
* answer `schemeOf` gives, because a graph in a stale palette beats a
|
|
73
|
+
* workspace that crashed on mount.
|
|
74
|
+
*
|
|
75
|
+
* A manual choice ("light" / "dark") ignores this callback entirely — the
|
|
76
|
+
* shell simply does not consult the OS while overridden, and the stylesheet's
|
|
77
|
+
* `:root[data-weave-theme=…]` attribute wins over the media query the same way.
|
|
78
|
+
*/
|
|
79
|
+
export function watchScheme(
|
|
80
|
+
host: SchemeHost,
|
|
81
|
+
onChange: (scheme: ColorScheme) => void,
|
|
82
|
+
): () => void {
|
|
83
|
+
if (typeof host.matchMedia !== "function") return () => {};
|
|
84
|
+
const list: SchemeList = host.matchMedia(LIGHT_QUERY);
|
|
85
|
+
if (typeof list.addEventListener !== "function") return () => {};
|
|
86
|
+
const listener = (): void => onChange(list.matches ? "light" : "dark");
|
|
87
|
+
list.addEventListener("change", listener);
|
|
88
|
+
return () => list.removeEventListener("change", listener);
|
|
89
|
+
}
|
package/src/web/client/main.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import { BOOTSTRAP_ELEMENT_ID } from "../shared/wire";
|
|
|
15
15
|
import { readBootstrap } from "./bootstrap";
|
|
16
16
|
import { Shell } from "./shell/Shell";
|
|
17
17
|
import { installTheme } from "./shell/theme";
|
|
18
|
+
import { loadTheme, themeAttr } from "./shell/theme.model";
|
|
18
19
|
|
|
19
20
|
const host = document.getElementById("app");
|
|
20
21
|
if (host !== null) {
|
|
@@ -22,6 +23,17 @@ if (host !== null) {
|
|
|
22
23
|
// installing it after would show one unstyled frame. It copies the
|
|
23
24
|
// per-response CSP nonce off the server's own `<style>` — see `theme.ts`.
|
|
24
25
|
installTheme(document);
|
|
26
|
+
// And before the first paint: a stored manual choice must govern from frame
|
|
27
|
+
// one, or a light-theme user sees one dark frame. `null` (system mode)
|
|
28
|
+
// writes nothing — the media query already answered.
|
|
29
|
+
applyPrePaintTheme();
|
|
25
30
|
const boot = readBootstrap(document.getElementById(BOOTSTRAP_ELEMENT_ID)?.textContent ?? null);
|
|
26
31
|
render(<Shell cwd={boot.cwd} initialWidth={window.innerWidth} platform={navigator.platform} />, host);
|
|
27
32
|
}
|
|
33
|
+
|
|
34
|
+
/** Apply the stored theme choice to `<html>` synchronously, pre-render. */
|
|
35
|
+
function applyPrePaintTheme(): void {
|
|
36
|
+
const attr = themeAttr(loadTheme(localStorage) ?? "system");
|
|
37
|
+
if (attr === null) return;
|
|
38
|
+
document.documentElement.dataset.weaveTheme = attr;
|
|
39
|
+
}
|
|
@@ -42,19 +42,26 @@ function Prompt({ prompt, send }: { prompt: EditorPrompt; send: (event: EditorEv
|
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** The toolbar:
|
|
45
|
+
/** The toolbar while editing: done, save, open-in-$EDITOR, and the status word.
|
|
46
|
+
*
|
|
47
|
+
* There is no button in read mode: the note itself is the edit affordance —
|
|
48
|
+
* clicking its text opens the editor (see `Note.tsx`), and `⌘E` still works.
|
|
49
|
+
* Rendering the button only while editing keeps the read view down to prose
|
|
50
|
+
* and the `Open in $EDITOR` control. */
|
|
46
51
|
export function EditorBar(props: EditorProps) {
|
|
47
52
|
const { toolbar, send } = props;
|
|
48
53
|
return (
|
|
49
54
|
<div class="weave-note-bar">
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
{toolbar.editing ? (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
class="weave-note-toggle"
|
|
59
|
+
aria-pressed={toolbar.editing}
|
|
60
|
+
onClick={() => send({ type: "toggle" })}
|
|
61
|
+
>
|
|
62
|
+
{toolbar.toggleLabel}
|
|
63
|
+
</button>
|
|
64
|
+
) : null}
|
|
58
65
|
{toolbar.editing ? (
|
|
59
66
|
<button type="button" class="weave-note-save" disabled={!toolbar.canSave} onClick={() => send({ type: "save" })}>
|
|
60
67
|
{toolbar.saveLabel}
|