pi-weave 0.1.11 → 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 +148 -132
- package/package.json +1 -2
- package/src/core/cache/workspace.ts +26 -9
- package/src/core/concurrency.ts +3 -6
- package/src/core/frontmatter.ts +0 -53
- package/src/core/graph/build.ts +12 -8
- package/src/core/graph/current.ts +4 -6
- 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/slug.ts +13 -0
- package/src/core/types.ts +1 -0
- package/src/core/vault.ts +45 -467
- package/src/core/view/detail.ts +1 -1
- package/src/core/view/health.ts +1 -1
- package/src/core/view/tree.ts +15 -3
- 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 -176
- 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 +77 -167
- 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 +37 -60
- package/src/web/client/note/note.model.ts +23 -0
- 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 -81
- package/src/web/client/shell/Header.tsx +2 -10
- package/src/web/client/shell/Shell.tsx +50 -123
- 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 -121
- package/src/web/client/state.ts +9 -89
- package/src/web/client/tree/Tree.tsx +24 -97
- package/src/web/client/tree/tree.model.ts +8 -56
- package/src/web/client/workspace.ts +72 -242
- package/src/web/server/page.ts +8 -10
- package/src/web/server/routes.ts +30 -415
- package/src/web/server/server.ts +6 -145
- package/src/web/shared/layout.ts +72 -624
- package/src/web/shared/wire.ts +10 -178
- 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,15 +33,13 @@
|
|
|
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,
|
|
54
39
|
EMPTY_PREVIEW,
|
|
55
40
|
PREVIEW_ID,
|
|
56
41
|
WIKILINK_ATTR,
|
|
42
|
+
hasTextSelection,
|
|
57
43
|
noteEmptyMessage,
|
|
58
44
|
noteHeader,
|
|
59
45
|
previewAnchorOf,
|
|
@@ -81,14 +67,9 @@ export interface NoteProps {
|
|
|
81
67
|
graph: GraphPayload | null;
|
|
82
68
|
selectedId: string | null;
|
|
83
69
|
onSelect: (id: string) => void;
|
|
70
|
+
onOpen: (slug: string) => void;
|
|
84
71
|
/** Epoch ms for relative times. Injected so the render is deterministic. */
|
|
85
72
|
now: number;
|
|
86
|
-
/** The editor's view model, or `null` when the shell wired no editor. */
|
|
87
|
-
toolbar: EditorToolbar | null;
|
|
88
|
-
prompt: EditorPrompt | null;
|
|
89
|
-
/** The `<textarea>`'s content. Rendered only while `toolbar.editing`. */
|
|
90
|
-
draft: string;
|
|
91
|
-
send: (event: EditorEvent) => void;
|
|
92
73
|
}
|
|
93
74
|
|
|
94
75
|
/**
|
|
@@ -96,16 +77,23 @@ export interface NoteProps {
|
|
|
96
77
|
*
|
|
97
78
|
* The hierarchy is the point: the title is the page's largest voice and the
|
|
98
79
|
* meta line is its footnote — provenance glyph and word, edited, created.
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* rides the `title` attribute so the icon never has to explain itself on
|
|
102
|
-
* 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.
|
|
103
82
|
*/
|
|
104
|
-
function Header({
|
|
83
|
+
function Header({
|
|
84
|
+
view,
|
|
85
|
+
open,
|
|
86
|
+
}: {
|
|
87
|
+
view: NoteHeaderView;
|
|
88
|
+
open: () => void;
|
|
89
|
+
}) {
|
|
105
90
|
return (
|
|
106
91
|
<header class="weave-note-head">
|
|
107
92
|
<h3 class="weave-note-title">{view.title}</h3>
|
|
108
93
|
<p class="weave-note-meta">
|
|
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>
|
|
109
97
|
<span class={`weave-prov weave-prov-${view.provenance}`} title={view.provenanceTitle}>
|
|
110
98
|
{view.provenanceGlyph} {view.provenance}
|
|
111
99
|
</span>
|
|
@@ -115,11 +103,6 @@ function Header({ view, open }: { view: NoteHeaderView; open: (() => void) | nul
|
|
|
115
103
|
<span class="weave-note-time" title={view.createdIso}>
|
|
116
104
|
{CREATED_WORD} {view.created}
|
|
117
105
|
</span>
|
|
118
|
-
{open === null ? null : (
|
|
119
|
-
<button type="button" class="weave-note-open" title={OPEN_HINT} aria-label={OPEN_LABEL} onClick={open}>
|
|
120
|
-
<span class="weave-note-open-mark" aria-hidden="true" dangerouslySetInnerHTML={{ __html: OPEN_ICON }} />
|
|
121
|
-
</button>
|
|
122
|
-
)}
|
|
123
106
|
</p>
|
|
124
107
|
<p class="weave-note-tags">
|
|
125
108
|
{view.tags.map((tag) => (
|
|
@@ -138,9 +121,8 @@ export function Note(props: NoteProps) {
|
|
|
138
121
|
|
|
139
122
|
// Both hook calls sit before the empty-return so the hook order cannot
|
|
140
123
|
// depend on whether a note is loaded. The memoization is cheap insurance:
|
|
141
|
-
// one marked parse + DOMPurify pass + O(nodes) index per
|
|
142
|
-
// instead of per shell render
|
|
143
|
-
// 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 —
|
|
144
126
|
// `markdownRenderer(index)` inside `renderNote` — stays per-call by design;
|
|
145
127
|
// only the *result* is memoized.
|
|
146
128
|
const index = useMemo(() => (note === null ? null : wikiIndex(props.graph, note.slug)), [note, props.graph]);
|
|
@@ -205,11 +187,6 @@ export function Note(props: NoteProps) {
|
|
|
205
187
|
|
|
206
188
|
if (note === null || index === null) return <p class="weave-note-empty">{empty}</p>;
|
|
207
189
|
|
|
208
|
-
// Bound once so TypeScript narrows it for both uses below: `toolbar.editing`
|
|
209
|
-
// is what decides whether the textarea renders, and reading it twice off
|
|
210
|
-
// `props` would need a non-null assertion at the second read.
|
|
211
|
-
const toolbar = props.toolbar;
|
|
212
|
-
const shell = { draft: props.draft, prompt: props.prompt, send: props.send };
|
|
213
190
|
// Keyed on the slug, not the body digest: the article is the scroll
|
|
214
191
|
// container, so an in-place swap would open every note at the previous
|
|
215
192
|
// note's scroll offset. A key change remounts it, and a fresh container
|
|
@@ -219,12 +196,11 @@ export function Note(props: NoteProps) {
|
|
|
219
196
|
const header = noteHeader(note, props.now);
|
|
220
197
|
return (
|
|
221
198
|
<article key={note.slug} class={`weave-note weave-note-${header.provenance}`}>
|
|
222
|
-
<Header
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
<div
|
|
199
|
+
<Header
|
|
200
|
+
view={header}
|
|
201
|
+
open={() => props.onOpen(note.slug)}
|
|
202
|
+
/>
|
|
203
|
+
<div
|
|
228
204
|
ref={bodyRef}
|
|
229
205
|
class="weave-note-body"
|
|
230
206
|
// Programmatic focus target for `⌘2`: without a `tabindex`,
|
|
@@ -255,17 +231,19 @@ export function Note(props: NoteProps) {
|
|
|
255
231
|
}}
|
|
256
232
|
onBlur={() => dispatch({ type: "hide" })}
|
|
257
233
|
onClick={(event) => {
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
234
|
+
// Selecting text to copy must not trigger wikilink navigation.
|
|
235
|
+
const selection =
|
|
236
|
+
typeof window !== "undefined" && typeof window.getSelection === "function"
|
|
237
|
+
? window.getSelection()
|
|
238
|
+
: null;
|
|
239
|
+
if (hasTextSelection(selection)) return;
|
|
240
|
+
|
|
241
|
+
// A wikilink carries no href, so route it onto the context bus.
|
|
263
242
|
const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
|
|
264
243
|
if (target !== null) {
|
|
265
244
|
props.onSelect(target);
|
|
266
245
|
return;
|
|
267
246
|
}
|
|
268
|
-
if (props.toolbar !== null) props.send({ type: "toggle" });
|
|
269
247
|
}}
|
|
270
248
|
onKeyDown={(event) => {
|
|
271
249
|
// Escape is first so the card closes on the gesture a keyboard
|
|
@@ -287,7 +265,6 @@ export function Note(props: NoteProps) {
|
|
|
287
265
|
// Sanitised by `renderNote`'s three layers — see note.model.ts.
|
|
288
266
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
289
267
|
/>
|
|
290
|
-
)}
|
|
291
268
|
{card === null ? null : (
|
|
292
269
|
<div
|
|
293
270
|
ref={cardRef}
|
|
@@ -302,4 +279,4 @@ export function Note(props: NoteProps) {
|
|
|
302
279
|
)}
|
|
303
280
|
</article>
|
|
304
281
|
);
|
|
305
|
-
}
|
|
282
|
+
}
|
|
@@ -670,6 +670,29 @@ export function wikilinkTargetOf(from: ClosestElement | null): string | null {
|
|
|
670
670
|
return null;
|
|
671
671
|
}
|
|
672
672
|
|
|
673
|
+
/**
|
|
674
|
+
* A minimal selection interface for detecting active text selection.
|
|
675
|
+
* Abstracted so callers pass `window.getSelection()` and tests stay pure.
|
|
676
|
+
*/
|
|
677
|
+
export interface SelectionLike {
|
|
678
|
+
readonly isCollapsed?: boolean | undefined;
|
|
679
|
+
toString?(): string;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Whether the user has an active, non-empty text selection.
|
|
684
|
+
*
|
|
685
|
+
* Selecting text to copy (mouse drag, keyboard range) must neither flip the
|
|
686
|
+
* note into edit mode nor navigate a wikilink the selection happens to cross.
|
|
687
|
+
*/
|
|
688
|
+
export function hasTextSelection(selection: SelectionLike | null | undefined): boolean {
|
|
689
|
+
if (!selection) return false;
|
|
690
|
+
if (selection.isCollapsed === true) return false;
|
|
691
|
+
const toString = selection.toString;
|
|
692
|
+
const text = typeof toString === "function" && toString !== Object.prototype.toString ? toString.call(selection) : "";
|
|
693
|
+
return text.length > 0;
|
|
694
|
+
}
|
|
695
|
+
|
|
673
696
|
// --- the front-matter header --------------------------------------------------------------
|
|
674
697
|
|
|
675
698
|
/**
|
|
@@ -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>
|