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,45 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Live force dynamics for the graph's current shape — the Obsidian-style
|
|
3
|
-
* "the graph is alive" feel while the user drags.
|
|
4
|
-
*
|
|
5
|
-
* ## One physics, two drivers
|
|
6
|
-
*
|
|
7
|
-
* The static layout (`shared/layout.ts`) and this module now share ONE force
|
|
8
|
-
* configuration: {@link createForceSimulation}. They used to be two
|
|
9
|
-
* independent physics — the static path ring-sized its link distances per
|
|
10
|
-
* fan-out and anchored every node to its seed; this one pulled at a flat 90
|
|
11
|
-
* units toward its own equilibrium. Every graph therefore *visibly re-laid
|
|
12
|
-
* itself* after mount (a 215-node repo graph migrated from a ~1200-unit ring
|
|
13
|
-
* to the bespoke sim's ~300-unit cloud over about thirty seconds), and the
|
|
14
|
-
* constant-alpha loop ticked at 60 fps forever, because a bespoke integrator
|
|
15
|
-
* has no alpha to decay. Sharing the factory fixes both by construction: the
|
|
16
|
-
* warm start is already at the shared equilibrium, so there is nothing to
|
|
17
|
-
* migrate to, and d3's alpha decay is what says "settled".
|
|
18
|
-
*
|
|
19
|
-
* ## The lifecycle
|
|
20
|
-
*
|
|
21
|
-
* A new engine starts at {@link SETTLE_ALPHA} (an expand/collapse lands new
|
|
22
|
-
* nodes already laid out; the brief settle is the "alive" response, not a
|
|
23
|
-
* re-layout) and decays to d3's alpha floor, at which point {@link awake}
|
|
24
|
-
* turns false and the component's clock puts itself to sleep — no frames are
|
|
25
|
-
* spent on a graph that is holding still. `pin` re-heats to
|
|
26
|
-
* {@link DRAG_ALPHA_TARGET} (d3's own drag pattern), so neighbours make room
|
|
27
|
-
* while the user drags; `release` clears the pin AND retargets the node's
|
|
28
|
-
* anchor to its drop point, so a dragged node stays where it was dropped
|
|
29
|
-
* instead of springing back to its seed.
|
|
30
|
-
*
|
|
31
|
-
* ## The §7.2 vertical-line freeze
|
|
32
|
-
*
|
|
33
|
-
* Inherited from d3 itself: `manyBody`, `collide` and `link` all inject a
|
|
34
|
-
* seeded `jiggle` into a zero axis component, so coincident or collinear
|
|
35
|
-
* nodes always acquire a direction. The seeded LCG (`seed: 1`) keeps the
|
|
36
|
-
* whole thing deterministic — the same graph, the same gestures, byte-identical
|
|
37
|
-
* positions — which is what the test gate asserts.
|
|
38
|
-
*/
|
|
1
|
+
/** Live d3-force dynamics used while a node is dragged. */
|
|
39
2
|
|
|
40
3
|
import type { Point } from "../../shared/layout";
|
|
41
|
-
import {
|
|
42
|
-
import type { WireEdgeKind } from "../../shared/graph";
|
|
4
|
+
import { collideRadius, createForceSimulation } from "../../shared/layout";
|
|
43
5
|
import type { RenderGraph } from "./graph.model";
|
|
44
6
|
|
|
45
7
|
interface SimNode {
|
|
@@ -55,12 +17,6 @@ interface SimNode {
|
|
|
55
17
|
r?: number;
|
|
56
18
|
}
|
|
57
19
|
|
|
58
|
-
interface SimLink {
|
|
59
|
-
source: string | SimNode;
|
|
60
|
-
target: string | SimNode;
|
|
61
|
-
kind: WireEdgeKind;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
20
|
export interface GraphSimulation {
|
|
65
21
|
tick(): void;
|
|
66
22
|
positions(): Map<string, Point>;
|
|
@@ -118,7 +74,7 @@ export function createGraphSimulation(graph: RenderGraph, initial?: ReadonlyMap<
|
|
|
118
74
|
// would throw on them, so they are dropped here and the layout's own
|
|
119
75
|
// `pathologicalGraph` fixture remains the gate for degenerate *outputs*.
|
|
120
76
|
const seen = new Set<string>();
|
|
121
|
-
const links:
|
|
77
|
+
const links: { source: string; target: string; kind: RenderGraph["edges"][number]["kind"] }[] = [];
|
|
122
78
|
for (const edge of graph.edges) {
|
|
123
79
|
if (edge.source === edge.target) continue;
|
|
124
80
|
if (!byId.has(edge.source) || !byId.has(edge.target)) continue;
|
|
@@ -128,18 +84,7 @@ export function createGraphSimulation(graph: RenderGraph, initial?: ReadonlyMap<
|
|
|
128
84
|
links.push({ source: edge.source, target: edge.target, kind: edge.kind });
|
|
129
85
|
}
|
|
130
86
|
|
|
131
|
-
|
|
132
|
-
// `forceY()`). The anchors start as the **branch ring** (`branchAnchors`),
|
|
133
|
-
// so a released graph holds the separated equilibrium the static layout
|
|
134
|
-
// settled into instead of gliding back toward one centre — the live and
|
|
135
|
-
// static paths share one physics, and that includes its gravity targets.
|
|
136
|
-
// `release` then overrides a dropped node's entry with its drop point, so a
|
|
137
|
-
// placement sticks (the anchor is five times stronger than the leaf
|
|
138
|
-
// spring, so the node rests where the user left it).
|
|
139
|
-
const settled = new Map<string, Point>();
|
|
140
|
-
for (const node of graph.nodes) settled.set(node.id, { x: node.x, y: node.y });
|
|
141
|
-
const anchors = new Map<string, Point>(branchAnchors(graph, settled));
|
|
142
|
-
const sim = createForceSimulation({ nodes, links, anchors, seed: 1 })
|
|
87
|
+
const sim = createForceSimulation({ nodes, links, seed: 1 })
|
|
143
88
|
.alpha(SETTLE_ALPHA)
|
|
144
89
|
.alphaMin(ALPHA_MIN);
|
|
145
90
|
|
|
@@ -174,14 +119,9 @@ export function createGraphSimulation(graph: RenderGraph, initial?: ReadonlyMap<
|
|
|
174
119
|
release(id) {
|
|
175
120
|
const node = byId.get(id);
|
|
176
121
|
if (!node) return;
|
|
177
|
-
// Unfix, like d3's `dragended` — and retarget the node's centre-gravity
|
|
178
|
-
// anchor to the drop point, so the graph reads the drop as a placement:
|
|
179
|
-
// the node rests where the user left it instead of gliding back into
|
|
180
|
-
// the tree.
|
|
181
|
-
anchors.set(id, { x: node.x ?? 0, y: node.y ?? 0 });
|
|
182
122
|
node.fx = null;
|
|
183
123
|
node.fy = null;
|
|
184
124
|
sim.alphaTarget(0);
|
|
185
125
|
},
|
|
186
126
|
};
|
|
187
|
-
}
|
|
127
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* whole renderer: `sigmaRenderer` holds the lifecycle, the reducers and the
|
|
14
14
|
* delegation, and is unit-tested against `SigmaLike` with a recording fake.
|
|
15
15
|
* Identical shape and identical reasoning to `api.dom.ts` for `fetch` and
|
|
16
|
-
*
|
|
16
|
+
* the injected renderer port in the testable half of this module.
|
|
17
17
|
*
|
|
18
18
|
* This module is only reachable from a `.tsx` entry point, so it is compiled
|
|
19
19
|
* exclusively by `tsconfig.web.json` (which has `DOM`) and never pulled into
|
|
@@ -29,24 +29,23 @@
|
|
|
29
29
|
|
|
30
30
|
import Sigma from "sigma";
|
|
31
31
|
import type { ColorScheme } from "./graph.model";
|
|
32
|
-
import type { GraphRenderer, RendererFactory,
|
|
32
|
+
import type { GraphRenderer, RendererFactory, SigmaLike } from "./renderer";
|
|
33
33
|
import { sigmaRenderer } from "./renderer";
|
|
34
34
|
|
|
35
35
|
/** Sigma's own container parameter type, recovered without naming the DOM. */
|
|
36
36
|
type SigmaContainer = ConstructorParameters<typeof Sigma>[1];
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* `new Sigma(graph, container, settings)`, as a {@link SigmaFactory}.
|
|
40
|
-
*
|
|
41
39
|
* The real `Sigma` satisfies `SigmaLike` structurally — `on`, `setSetting`,
|
|
42
40
|
* `setGraph`, `refresh`, `getCamera` and `kill` are all present with
|
|
43
41
|
* compatible shapes — so the only work here is the container cast, which is
|
|
44
42
|
* checked against sigma's own declared parameter type rather than against an
|
|
45
43
|
* `HTMLElement` asserted from memory.
|
|
46
44
|
*/
|
|
47
|
-
const createSigma: SigmaFactory = (graph, container, settings) =>
|
|
48
|
-
new Sigma(graph, container as unknown as SigmaContainer, settings) as unknown as SigmaLike;
|
|
49
|
-
|
|
50
45
|
/** The browser's renderer factory. Passed to the graph column by the shell. */
|
|
51
46
|
export const createSigmaRenderer: RendererFactory = (scheme: ColorScheme): GraphRenderer =>
|
|
52
|
-
sigmaRenderer(
|
|
47
|
+
sigmaRenderer(
|
|
48
|
+
(graph, container, settings) =>
|
|
49
|
+
new Sigma(graph, container as unknown as SigmaContainer, settings) as unknown as SigmaLike,
|
|
50
|
+
scheme,
|
|
51
|
+
);
|
|
@@ -23,20 +23,20 @@
|
|
|
23
23
|
* line of ours runs.
|
|
24
24
|
*
|
|
25
25
|
* That is a worse outcome than it looks. This repository's rule (§10, and
|
|
26
|
-
*
|
|
26
|
+
* the browser coverage policy) is that *untestable lines* are kept to a handful,
|
|
27
27
|
* not that untestable *files* are excluded — the one coverage exclusion that
|
|
28
28
|
* exists is a type-only module, and a blanket `src/web/client/**` exclude is
|
|
29
29
|
* explicitly "not acceptable". A whole renderer sitting outside the gate would
|
|
30
30
|
* be exactly the erosion that rule prevents.
|
|
31
31
|
*
|
|
32
|
-
* So the dependency is inverted. {@link SigmaLike}
|
|
32
|
+
* So the dependency is inverted. {@link SigmaLike}
|
|
33
33
|
* are the two-and-a-half-method port sigma satisfies structurally;
|
|
34
34
|
* {@link sigmaRenderer} is the entire renderer written against the port, and
|
|
35
35
|
* it is covered by ordinary unit tests with a recording fake. The only thing
|
|
36
36
|
* that genuinely cannot be tested is the four-line adapter in
|
|
37
37
|
* `renderer.dom.ts` that says `new Sigma(graph, container, settings)` — the
|
|
38
38
|
* same shape, and the same reasoning, as `api.dom.ts` for `fetch` and
|
|
39
|
-
*
|
|
39
|
+
* the DOM renderer seam for the browser canvas.
|
|
40
40
|
*
|
|
41
41
|
* §7.5's promise is unaffected. "One file changes" is still true, and it is
|
|
42
42
|
* now true of a file with no branches in it.
|
|
@@ -111,7 +111,7 @@ export interface GraphRenderer {
|
|
|
111
111
|
* importing it drags the module into the **root** `tsconfig.json` project
|
|
112
112
|
* (`exclude` filters the initial glob, not what an included file imports), and
|
|
113
113
|
* that project has no `DOM` lib. The structural stand-in is the same trick
|
|
114
|
-
* `cssvars.ts
|
|
114
|
+
* `cssvars.ts` and `api.ts` use; the one cast lives in
|
|
115
115
|
* `renderer.dom.ts`, which is compiled only by `tsconfig.web.json`.
|
|
116
116
|
*/
|
|
117
117
|
export interface RenderContainer {
|
|
@@ -119,33 +119,6 @@ export interface RenderContainer {
|
|
|
119
119
|
readonly clientHeight: number;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/**
|
|
123
|
-
* A renderer that draws nothing.
|
|
124
|
-
*
|
|
125
|
-
* Not a test double — a production path. The graph column renders before its
|
|
126
|
-
* container exists (the first pass), may never mount at all (the `medium`
|
|
127
|
-
* breakpoint collapses the column), and must keep working when it does not. A
|
|
128
|
-
* null object removes the `renderer === null` check from every call site,
|
|
129
|
-
* which is the check that is always missing from exactly one of them.
|
|
130
|
-
*/
|
|
131
|
-
export function nullRenderer(): GraphRenderer {
|
|
132
|
-
return {
|
|
133
|
-
mount() {},
|
|
134
|
-
setGraph() {},
|
|
135
|
-
setPositions() {},
|
|
136
|
-
setHighlight() {},
|
|
137
|
-
onSelect() {},
|
|
138
|
-
fit() {},
|
|
139
|
-
onDragStart() {},
|
|
140
|
-
onDragMove() {},
|
|
141
|
-
onDragEnd() {},
|
|
142
|
-
positions() {
|
|
143
|
-
return new Map();
|
|
144
|
-
},
|
|
145
|
-
destroy() {},
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
122
|
/**
|
|
150
123
|
* How a {@link GraphRenderer} is obtained.
|
|
151
124
|
*
|
|
@@ -171,7 +144,7 @@ export interface CameraLike {
|
|
|
171
144
|
* The slice of `Sigma` this renderer drives.
|
|
172
145
|
*
|
|
173
146
|
* Structural, so the real class satisfies it without a cast and a fake is an
|
|
174
|
-
* object literal — the same reasoning
|
|
147
|
+
* object literal — the same reasoning the injected renderer port records.
|
|
175
148
|
* Six methods, and every one of them is called below, so the port cannot grow
|
|
176
149
|
* a member nothing uses.
|
|
177
150
|
*
|
|
@@ -212,8 +185,6 @@ export interface SigmaLike {
|
|
|
212
185
|
* everything *around* it can be. `renderer.dom.ts` is the four-line adapter
|
|
213
186
|
* that supplies the real constructor.
|
|
214
187
|
*/
|
|
215
|
-
export type SigmaFactory = (graph: ProjectedGraph, container: RenderContainer, settings: GraphSettings) => SigmaLike;
|
|
216
|
-
|
|
217
188
|
// --- the implementation --------------------------------------------------------------
|
|
218
189
|
|
|
219
190
|
/**
|
|
@@ -230,7 +201,10 @@ export type SigmaFactory = (graph: ProjectedGraph, container: RenderContainer, s
|
|
|
230
201
|
* every node colour and re-projecting — a second code path for a case nobody
|
|
231
202
|
* hits.
|
|
232
203
|
*/
|
|
233
|
-
export function sigmaRenderer(
|
|
204
|
+
export function sigmaRenderer(
|
|
205
|
+
create: (graph: ProjectedGraph, container: RenderContainer, settings: GraphSettings) => SigmaLike,
|
|
206
|
+
scheme: ColorScheme,
|
|
207
|
+
): GraphRenderer {
|
|
234
208
|
let sigma: SigmaLike | null = null;
|
|
235
209
|
let graph: ProjectedGraph = project({ nodes: [], edges: [] });
|
|
236
210
|
let highlight: ReadonlySet<string> | null = null;
|
package/src/web/client/main.tsx
CHANGED
|
@@ -28,7 +28,7 @@ if (host !== null) {
|
|
|
28
28
|
// writes nothing — the media query already answered.
|
|
29
29
|
applyPrePaintTheme();
|
|
30
30
|
const boot = readBootstrap(document.getElementById(BOOTSTRAP_ELEMENT_ID)?.textContent ?? null);
|
|
31
|
-
render(<Shell cwd={boot.cwd}
|
|
31
|
+
render(<Shell cwd={boot.cwd} platform={navigator.platform} />, host);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** Apply the stored theme choice to `<html>` synchronously, pre-render. */
|
|
@@ -1,28 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The note column (weave-workspace §1.2, P2.4,
|
|
2
|
+
* The read-only note column (weave-workspace §1.2, P2.4, P6.3).
|
|
3
3
|
*
|
|
4
|
-
* Props in, JSX out. The markdown pipeline,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
8
|
-
* content that has already been through all three of that module's layers,
|
|
9
|
-
* and swaps the rendered body for a `<textarea>` when the editor is open.
|
|
4
|
+
* Props in, JSX out. The markdown pipeline, sanitiser config, wikilink
|
|
5
|
+
* resolution and every string are in `note.model.ts`. This file wires the
|
|
6
|
+
* rendered body, selection-safe wikilink navigation, and the Open in
|
|
7
|
+
* `$EDITOR` action.
|
|
10
8
|
*
|
|
11
|
-
* `dangerouslySetInnerHTML` is used deliberately
|
|
9
|
+
* `dangerouslySetInnerHTML` is used deliberately for sanitized note HTML. The
|
|
12
10
|
* alternative is parsing marked's output into a Preact tree, which means a
|
|
13
11
|
* second HTML parser in the bundle and a second place for a sanitisation
|
|
14
12
|
* mistake to hide. One clearly-marked line whose input is
|
|
15
|
-
* `renderNote(DOMPurify, …)` is easier to audit than a
|
|
16
|
-
* avoid the word "dangerously". The second is the ⌘E control's icon — a
|
|
17
|
-
* constant from `editor.model.ts`, not note content, and small enough that
|
|
18
|
-
* a second site is cheaper than an icon component.
|
|
19
|
-
*
|
|
20
|
-
* The read view is rendered from the **note**, not from the draft: what the
|
|
21
|
-
* `<textarea>` holds is unsaved and un-sanitised, and piping it through
|
|
22
|
-
* `renderNote` on every keystroke would be a live preview paid for with a
|
|
23
|
-
* markdown parse and a DOMPurify pass per character. §11 P5.4 mentions a live
|
|
24
|
-
* preview; the honest version of it is `⌘E`, which is instant and shows the
|
|
25
|
-
* text that actually exists.
|
|
13
|
+
* `renderNote(DOMPurify, …)` is easier to audit than a second HTML parser.
|
|
26
14
|
*
|
|
27
15
|
* ## Wikilink preview (P6.3)
|
|
28
16
|
*
|
|
@@ -45,9 +33,6 @@
|
|
|
45
33
|
import DOMPurify from "dompurify";
|
|
46
34
|
import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
47
35
|
import type { GraphPayload, NotePayload } from "../../shared/wire";
|
|
48
|
-
import { Editor, EditorBar } from "./Editor";
|
|
49
|
-
import type { EditorEvent, EditorPrompt, EditorToolbar } from "./editor.model";
|
|
50
|
-
import { OPEN_HINT, OPEN_ICON, OPEN_LABEL } from "./editor.model";
|
|
51
36
|
import {
|
|
52
37
|
CREATED_WORD,
|
|
53
38
|
EDITED_WORD,
|
|
@@ -82,14 +67,9 @@ export interface NoteProps {
|
|
|
82
67
|
graph: GraphPayload | null;
|
|
83
68
|
selectedId: string | null;
|
|
84
69
|
onSelect: (id: string) => void;
|
|
70
|
+
onOpen: (slug: string) => void;
|
|
85
71
|
/** Epoch ms for relative times. Injected so the render is deterministic. */
|
|
86
72
|
now: number;
|
|
87
|
-
/** The editor's view model, or `null` when the shell wired no editor. */
|
|
88
|
-
toolbar: EditorToolbar | null;
|
|
89
|
-
prompt: EditorPrompt | null;
|
|
90
|
-
/** The `<textarea>`'s content. Rendered only while `toolbar.editing`. */
|
|
91
|
-
draft: string;
|
|
92
|
-
send: (event: EditorEvent) => void;
|
|
93
73
|
}
|
|
94
74
|
|
|
95
75
|
/**
|
|
@@ -97,27 +77,23 @@ export interface NoteProps {
|
|
|
97
77
|
*
|
|
98
78
|
* The hierarchy is the point: the title is the page's largest voice and the
|
|
99
79
|
* meta line is its footnote — provenance glyph and word, edited, created.
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* rides the `title` attribute so the icon never has to explain itself on
|
|
103
|
-
* screen.
|
|
80
|
+
* The Open in `$EDITOR` action sits at the end of that line as an icon, where
|
|
81
|
+
* a workflow control belongs in a column whose job is reading.
|
|
104
82
|
*/
|
|
105
83
|
function Header({
|
|
106
84
|
view,
|
|
107
85
|
open,
|
|
108
86
|
}: {
|
|
109
87
|
view: NoteHeaderView;
|
|
110
|
-
open: (
|
|
88
|
+
open: () => void;
|
|
111
89
|
}) {
|
|
112
90
|
return (
|
|
113
91
|
<header class="weave-note-head">
|
|
114
92
|
<h3 class="weave-note-title">{view.title}</h3>
|
|
115
93
|
<p class="weave-note-meta">
|
|
116
|
-
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
</button>
|
|
120
|
-
)}
|
|
94
|
+
<button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
|
|
95
|
+
<span class="weave-note-open-mark" aria-hidden="true">↗</span>
|
|
96
|
+
</button>
|
|
121
97
|
<span class={`weave-prov weave-prov-${view.provenance}`} title={view.provenanceTitle}>
|
|
122
98
|
{view.provenanceGlyph} {view.provenance}
|
|
123
99
|
</span>
|
|
@@ -145,9 +121,8 @@ export function Note(props: NoteProps) {
|
|
|
145
121
|
|
|
146
122
|
// Both hook calls sit before the empty-return so the hook order cannot
|
|
147
123
|
// depend on whether a note is loaded. The memoization is cheap insurance:
|
|
148
|
-
// one marked parse + DOMPurify pass + O(nodes) index per
|
|
149
|
-
// instead of per shell render
|
|
150
|
-
// re-renders the shell). The instance the render builds —
|
|
124
|
+
// one marked parse + DOMPurify pass + O(nodes) index per body or vault
|
|
125
|
+
// instead of per shell render. The instance the render builds —
|
|
151
126
|
// `markdownRenderer(index)` inside `renderNote` — stays per-call by design;
|
|
152
127
|
// only the *result* is memoized.
|
|
153
128
|
const index = useMemo(() => (note === null ? null : wikiIndex(props.graph, note.slug)), [note, props.graph]);
|
|
@@ -212,11 +187,6 @@ export function Note(props: NoteProps) {
|
|
|
212
187
|
|
|
213
188
|
if (note === null || index === null) return <p class="weave-note-empty">{empty}</p>;
|
|
214
189
|
|
|
215
|
-
// Bound once so TypeScript narrows it for both uses below: `toolbar.editing`
|
|
216
|
-
// is what decides whether the textarea renders, and reading it twice off
|
|
217
|
-
// `props` would need a non-null assertion at the second read.
|
|
218
|
-
const toolbar = props.toolbar;
|
|
219
|
-
const shell = { draft: props.draft, prompt: props.prompt, send: props.send };
|
|
220
190
|
// Keyed on the slug, not the body digest: the article is the scroll
|
|
221
191
|
// container, so an in-place swap would open every note at the previous
|
|
222
192
|
// note's scroll offset. A key change remounts it, and a fresh container
|
|
@@ -228,13 +198,9 @@ export function Note(props: NoteProps) {
|
|
|
228
198
|
<article key={note.slug} class={`weave-note weave-note-${header.provenance}`}>
|
|
229
199
|
<Header
|
|
230
200
|
view={header}
|
|
231
|
-
open={
|
|
201
|
+
open={() => props.onOpen(note.slug)}
|
|
232
202
|
/>
|
|
233
|
-
|
|
234
|
-
{toolbar !== null && toolbar.editing ? (
|
|
235
|
-
<Editor toolbar={toolbar} {...shell} />
|
|
236
|
-
) : (
|
|
237
|
-
<div
|
|
203
|
+
<div
|
|
238
204
|
ref={bodyRef}
|
|
239
205
|
class="weave-note-body"
|
|
240
206
|
// Programmatic focus target for `⌘2`: without a `tabindex`,
|
|
@@ -265,26 +231,19 @@ export function Note(props: NoteProps) {
|
|
|
265
231
|
}}
|
|
266
232
|
onBlur={() => dispatch({ type: "hide" })}
|
|
267
233
|
onClick={(event) => {
|
|
268
|
-
// Selecting text to copy
|
|
269
|
-
// mode or trigger wikilink navigation. Only a clean click with no
|
|
270
|
-
// active selection routes the gesture.
|
|
234
|
+
// Selecting text to copy must not trigger wikilink navigation.
|
|
271
235
|
const selection =
|
|
272
236
|
typeof window !== "undefined" && typeof window.getSelection === "function"
|
|
273
237
|
? window.getSelection()
|
|
274
238
|
: null;
|
|
275
239
|
if (hasTextSelection(selection)) return;
|
|
276
240
|
|
|
277
|
-
// A wikilink carries no href, so
|
|
278
|
-
// has to route the click onto the §1.3 context bus. Anywhere else
|
|
279
|
-
// on the page *is* the edit affordance: a click on the prose opens
|
|
280
|
-
// the editor. The two never fight, because the link check reads
|
|
281
|
-
// the exact element the click landed on.
|
|
241
|
+
// A wikilink carries no href, so route it onto the context bus.
|
|
282
242
|
const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
|
|
283
243
|
if (target !== null) {
|
|
284
244
|
props.onSelect(target);
|
|
285
245
|
return;
|
|
286
246
|
}
|
|
287
|
-
if (props.toolbar !== null) props.send({ type: "toggle" });
|
|
288
247
|
}}
|
|
289
248
|
onKeyDown={(event) => {
|
|
290
249
|
// Escape is first so the card closes on the gesture a keyboard
|
|
@@ -306,7 +265,6 @@ export function Note(props: NoteProps) {
|
|
|
306
265
|
// Sanitised by `renderNote`'s three layers — see note.model.ts.
|
|
307
266
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
308
267
|
/>
|
|
309
|
-
)}
|
|
310
268
|
{card === null ? null : (
|
|
311
269
|
<div
|
|
312
270
|
ref={cardRef}
|
|
@@ -321,4 +279,4 @@ export function Note(props: NoteProps) {
|
|
|
321
279
|
)}
|
|
322
280
|
</article>
|
|
323
281
|
);
|
|
324
|
-
}
|
|
282
|
+
}
|
|
@@ -1,27 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
* The ⌘K palette (weave-workspace §1.2, P4).
|
|
3
|
-
*
|
|
4
|
-
* Props in, JSX out. Ranking, merging, debouncing, stale-response rejection,
|
|
5
|
-
* cursor movement and every string are `search.model.ts` and `search.ts`; the
|
|
6
|
-
* focus trap is `focus.model.ts` behind `useFocusTrap`. What is left is a
|
|
7
|
-
* `useState`, one memo and three handlers. §10's rule.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
1
|
+
import { useEffect, useRef, useState } from "preact/hooks";
|
|
11
2
|
import type { GraphPayload } from "../../shared/wire";
|
|
12
3
|
import { useFocusTrap } from "../shell/FocusTrap";
|
|
4
|
+
import { fetchSearch } from "../api";
|
|
5
|
+
import type { FetchLike } from "../api";
|
|
13
6
|
import type { SearchRowView } from "./search.model";
|
|
14
|
-
import { PALETTE_HINT, PALETTE_PLACEHOLDER, PALETTE_TITLE, initialSearchState, paletteModel, resultIdAt, searchKey } from "./search.model";
|
|
15
|
-
import type {
|
|
16
|
-
import { createSearch } from "./search";
|
|
7
|
+
import { DEBOUNCE_MS, PALETTE_HINT, PALETTE_PLACEHOLDER, PALETTE_TITLE, initialSearchState, paletteModel, resultIdAt, searchKey } from "./search.model";
|
|
8
|
+
import type { SearchState } from "./search.model";
|
|
17
9
|
|
|
18
10
|
export interface SearchPaletteProps {
|
|
19
11
|
graph: GraphPayload | null;
|
|
20
|
-
/** The §1.3 context bus. */
|
|
21
12
|
onSelect: (id: string) => void;
|
|
22
13
|
onClose: () => void;
|
|
23
|
-
|
|
24
|
-
ports: Omit<SearchOptions, "onChange">;
|
|
14
|
+
ports: { fetch: FetchLike };
|
|
25
15
|
}
|
|
26
16
|
|
|
27
17
|
function Row({ row, onPick, onHover }: { row: SearchRowView; onPick: () => void; onHover: () => void }) {
|
|
@@ -42,24 +32,47 @@ function Row({ row, onPick, onHover }: { row: SearchRowView; onPick: () => void;
|
|
|
42
32
|
}
|
|
43
33
|
|
|
44
34
|
export function SearchPalette(props: SearchPaletteProps) {
|
|
45
|
-
const [state, setState] = useState(initialSearchState);
|
|
46
|
-
const
|
|
35
|
+
const [state, setState] = useState<SearchState>(initialSearchState);
|
|
36
|
+
const requestId = useRef(0);
|
|
37
|
+
const abort = useRef<AbortController | null>(null);
|
|
47
38
|
const trap = useFocusTrap();
|
|
48
39
|
const input = useRef<HTMLInputElement | null>(null);
|
|
49
|
-
|
|
50
|
-
// whole surface is its query, and opening it to a container that only
|
|
51
|
-
// swallows (and does not forward) keystrokes makes the user click before
|
|
52
|
-
// they can type. Runs after the trap's own effect — hooks run in order,
|
|
53
|
-
// and the trap is created first — so this is the final word. The input
|
|
54
|
-
// carries the dialog's `aria-label`, so the announcement the
|
|
55
|
-
// container-focus exists for still happens.
|
|
40
|
+
|
|
56
41
|
useEffect(() => void input.current?.focus(), []);
|
|
57
|
-
const model = paletteModel(state, props.graph);
|
|
58
42
|
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const query = state.query.trim();
|
|
45
|
+
let active = true;
|
|
46
|
+
if (!query) {
|
|
47
|
+
abort.current?.abort();
|
|
48
|
+
setState((current) => ({ ...current, hits: [], answered: "", loading: false, failed: false }));
|
|
49
|
+
return () => { active = false; };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const timer = window.setTimeout(async () => {
|
|
53
|
+
const id = ++requestId.current;
|
|
54
|
+
abort.current?.abort();
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
abort.current = controller;
|
|
57
|
+
setState((current) => ({ ...current, loading: true, failed: false }));
|
|
58
|
+
const result = await fetchSearch(props.ports.fetch, query, controller.signal);
|
|
59
|
+
if (!active || controller.signal.aborted || id !== requestId.current) return;
|
|
60
|
+
setState((current) => result.ok
|
|
61
|
+
? { ...current, hits: result.data.hits, answered: query, loading: false, failed: false, cursor: 0 }
|
|
62
|
+
: { ...current, loading: false, failed: true });
|
|
63
|
+
}, DEBOUNCE_MS);
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
active = false;
|
|
67
|
+
window.clearTimeout(timer);
|
|
68
|
+
abort.current?.abort();
|
|
69
|
+
};
|
|
70
|
+
}, [state.query, props.ports.fetch]);
|
|
71
|
+
|
|
72
|
+
const model = paletteModel(state, props.graph);
|
|
59
73
|
const pick = (index: number | null): void => {
|
|
60
74
|
const id = resultIdAt(model.rows, index);
|
|
61
75
|
if (id !== null) props.onSelect(id);
|
|
62
|
-
search.dismiss();
|
|
63
76
|
props.onClose();
|
|
64
77
|
};
|
|
65
78
|
|
|
@@ -75,10 +88,10 @@ export function SearchPalette(props: SearchPaletteProps) {
|
|
|
75
88
|
onClick={(event) => event.stopPropagation()}
|
|
76
89
|
onKeyDown={(event) => {
|
|
77
90
|
if (trap.onKeyDown(event as unknown as KeyboardEvent)) return;
|
|
78
|
-
const next = searchKey(state, event.key, model.count);
|
|
91
|
+
const next = searchKey(state.cursor, event.key, model.count);
|
|
79
92
|
if (!next.handled) return;
|
|
80
93
|
event.preventDefault();
|
|
81
|
-
|
|
94
|
+
setState((current) => ({ ...current, cursor: next.cursor }));
|
|
82
95
|
if (next.dismiss) pick(next.activate);
|
|
83
96
|
}}
|
|
84
97
|
>
|
|
@@ -91,18 +104,14 @@ export function SearchPalette(props: SearchPaletteProps) {
|
|
|
91
104
|
aria-label={PALETTE_TITLE}
|
|
92
105
|
aria-controls="weave-search-results"
|
|
93
106
|
aria-activedescendant={model.activeDomId ?? undefined}
|
|
94
|
-
onInput={(event) =>
|
|
107
|
+
onInput={(event) => setState((current) => ({ ...current, query: event.currentTarget.value, cursor: 0 }))}
|
|
95
108
|
/>
|
|
96
109
|
{model.status === null ? (
|
|
97
110
|
<ul id="weave-search-results" class="weave-hits" role="listbox" aria-label={PALETTE_TITLE}>
|
|
98
|
-
{model.rows.map((row, index) => (
|
|
99
|
-
<Row key={row.id} row={row} onPick={() => pick(index)} onHover={() => search.setCursor(index)} />
|
|
100
|
-
))}
|
|
111
|
+
{model.rows.map((row, index) => <Row key={row.id} row={row} onPick={() => pick(index)} onHover={() => setState((current) => ({ ...current, cursor: index }))} />)}
|
|
101
112
|
</ul>
|
|
102
113
|
) : (
|
|
103
|
-
<p class="weave-palette-status" role="status">
|
|
104
|
-
{model.status}
|
|
105
|
-
</p>
|
|
114
|
+
<p class="weave-palette-status" role="status">{model.status}</p>
|
|
106
115
|
)}
|
|
107
116
|
<p class="weave-palette-foot">
|
|
108
117
|
<span>{model.countLabel}</span>
|