pi-weave 0.3.8 → 0.3.9
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 +17 -16
- package/package.json +4 -3
- package/skills/weave-explore/SKILL.md +3 -5
- package/skills/weave-notepad/SKILL.md +22 -2
- package/skills/weave-notepad/references/second-thoughts.md +57 -0
- package/src/core/graph/build.ts +1 -1
- package/src/core/graph/current.ts +10 -8
- package/src/core/history.ts +417 -0
- package/src/core/index.ts +19 -0
- package/src/core/links/repair.ts +5 -68
- package/src/core/noteAction.ts +72 -2
- package/src/core/openInEditor.ts +3 -8
- package/src/core/repoAction.ts +20 -61
- package/src/core/repoIndex.ts +38 -0
- package/src/core/trail.ts +124 -0
- package/src/core/types.ts +1 -3
- package/src/core/vault.ts +116 -29
- package/src/core/view/focus.ts +2 -2
- package/src/core/view/index.ts +2 -3
- package/src/core/view/rawTail.ts +153 -0
- package/src/core/workspace.ts +20 -26
- package/src/core/workspaceCommands.ts +14 -14
- package/src/opencode/tools.ts +3 -3
- package/src/opencode/tui.ts +1 -7
- package/src/pi/index.ts +68 -80
- package/src/pi/tools/noteTool.ts +1 -1
- package/src/pi/tools/repoTool.ts +3 -6
- package/src/pi/viewer/web/run.ts +6 -8
- package/src/web/client/api.ts +103 -5
- package/src/web/client/dist/app.js +183 -52
- package/src/web/client/graph/Graph.tsx +23 -2
- package/src/web/client/graph/column.model.ts +1 -1
- package/src/web/client/graph/graph.model.ts +69 -7
- package/src/web/client/graph/renderer.ts +19 -3
- package/src/web/client/note/Note.tsx +171 -20
- package/src/web/client/note/Replay.tsx +128 -0
- package/src/web/client/note/drafts.ts +50 -9
- package/src/web/client/note/note.model.ts +17 -0
- package/src/web/client/note/replay.model.ts +173 -0
- package/src/web/client/shell/ContextRail.tsx +7 -0
- package/src/web/client/shell/Settings.tsx +21 -1
- package/src/web/client/shell/Shell.tsx +34 -10
- package/src/web/client/shell/keys.model.ts +20 -7
- package/src/web/client/shell/theme.ts +118 -1
- package/src/web/client/shell/workspace.css.ts +4 -0
- package/src/web/client/trail/TrailStrip.tsx +55 -0
- package/src/web/client/trail/trail.model.ts +195 -0
- package/src/web/client/trail/trail.ts +45 -0
- package/src/web/client/tree/Tree.tsx +7 -3
- package/src/web/client/tree/tree.model.ts +2 -5
- package/src/web/server/page.ts +1 -1
- package/src/web/server/routes.ts +83 -3
- package/src/web/shared/logo.ts +1 -1
- package/src/web/shared/view.ts +10 -4
- package/src/web/shared/wire.ts +74 -1
- package/src/core/view/health.ts +0 -160
- package/src/pi/viewer/tui/bodyStore.ts +0 -95
- package/src/pi/viewer/tui/branding.ts +0 -14
- package/src/pi/viewer/tui/model.ts +0 -390
- package/src/pi/viewer/tui/openNote.ts +0 -11
- package/src/pi/viewer/tui/run.ts +0 -71
- package/src/pi/viewer/tui/surface/base.ts +0 -203
- package/src/pi/viewer/tui/surface/detail.ts +0 -315
- package/src/pi/viewer/tui/surface/explore.ts +0 -241
- package/src/pi/viewer/tui/surface/focus.ts +0 -139
- package/src/pi/viewer/tui/surface/health.ts +0 -102
- package/src/pi/viewer/tui/theme.ts +0 -90
- package/src/pi/viewer/tui/workspace.ts +0 -53
- package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
} from "./column.model";
|
|
46
46
|
import type { PositionStorage } from "./positions";
|
|
47
47
|
import type { GraphRenderer, RendererFactory } from "./renderer";
|
|
48
|
+
import type { TrailGlow } from "./graph.model";
|
|
48
49
|
import { schemeOf } from "./scheme";
|
|
49
50
|
import type { SchemeHost } from "./scheme";
|
|
50
51
|
import type { GraphTheme } from "../../shared/themes";
|
|
@@ -92,14 +93,21 @@ export interface GraphProps {
|
|
|
92
93
|
* keeps the ownership where it is and costs one line at each end.
|
|
93
94
|
*/
|
|
94
95
|
fit: { current: (() => void) | null };
|
|
95
|
-
|
|
96
|
+
/** Recall-trail glows by node id (`trail/trail.model.ts`). Omitted means none. */
|
|
97
|
+
trail?: ReadonlyMap<string, TrailGlow>;
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
const NO_GLOWS: ReadonlyMap<string, TrailGlow> = new Map();
|
|
101
|
+
|
|
98
102
|
export function Graph(props: GraphProps) {
|
|
99
103
|
const canvas = useRef<HTMLDivElement | null>(null);
|
|
100
104
|
const renderer = useRef<GraphRenderer | null>(null);
|
|
101
105
|
const dynamics = useRef<GraphSimulation | null>(null);
|
|
102
106
|
const clock = useRef<number | null>(null);
|
|
107
|
+
// Whether the camera has been fitted to the first settled layout. The
|
|
108
|
+
// mount frames the *starting* positions, which a settling simulation then
|
|
109
|
+
// pushes past the edges — a two-note vault opened with clipped circles.
|
|
110
|
+
const fitted = useRef(false);
|
|
103
111
|
/**
|
|
104
112
|
* Arm the simulation clock — idempotent.
|
|
105
113
|
*
|
|
@@ -116,7 +124,13 @@ export function Graph(props: GraphProps) {
|
|
|
116
124
|
if (engine === null) return;
|
|
117
125
|
engine.tick();
|
|
118
126
|
// Asleep: let the clock die. The next pin re-arms it.
|
|
119
|
-
if (!engine.awake())
|
|
127
|
+
if (!engine.awake()) {
|
|
128
|
+
if (!fitted.current) {
|
|
129
|
+
fitted.current = true;
|
|
130
|
+
renderer.current?.fit();
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
120
134
|
renderer.current?.setPositions(engine.positions());
|
|
121
135
|
clock.current = requestAnimationFrame(step);
|
|
122
136
|
};
|
|
@@ -146,6 +160,8 @@ export function Graph(props: GraphProps) {
|
|
|
146
160
|
const preview = graphPreview(props.graph, props.previewId);
|
|
147
161
|
|
|
148
162
|
// Read by the mount-time `onSelect`, which outlives this render.
|
|
163
|
+
const trailRef = useRef<ReadonlyMap<string, TrailGlow>>(NO_GLOWS);
|
|
164
|
+
trailRef.current = props.trail ?? NO_GLOWS;
|
|
149
165
|
const live = useRef({ view, model, onSelect: props.onSelect, selectedId: props.selectedId });
|
|
150
166
|
live.current = { view, model, onSelect: props.onSelect, selectedId: props.selectedId };
|
|
151
167
|
|
|
@@ -195,6 +211,7 @@ export function Graph(props: GraphProps) {
|
|
|
195
211
|
// the column is already showing.
|
|
196
212
|
instance.setGraph(live.current.model.graph);
|
|
197
213
|
instance.setHighlight(live.current.model.highlight, live.current.selectedId);
|
|
214
|
+
instance.setTrail(trailRef.current);
|
|
198
215
|
props.fit.current = () => instance.fit();
|
|
199
216
|
return () => {
|
|
200
217
|
instance.destroy();
|
|
@@ -242,6 +259,10 @@ export function Graph(props: GraphProps) {
|
|
|
242
259
|
renderer.current?.setHighlight(model.highlight, props.selectedId);
|
|
243
260
|
}, [model.highlight, props.selectedId]);
|
|
244
261
|
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
renderer.current?.setTrail(trailRef.current);
|
|
264
|
+
}, [props.trail]);
|
|
265
|
+
|
|
245
266
|
return (
|
|
246
267
|
<div class="weave-graph">
|
|
247
268
|
{/* Same shape as `ContextRail`'s: the *decision* is `graphEmptyMessage`,
|
|
@@ -235,7 +235,7 @@ export function graphCountLabel(visible: number, total: number): string {
|
|
|
235
235
|
/** What the column says instead of a canvas. `null` means "there is a graph". */
|
|
236
236
|
export function graphEmptyMessage(payload: GraphPayload | null, visible: number): string | null {
|
|
237
237
|
if (payload === null) return "Loading…";
|
|
238
|
-
if (payload.model.nodes.length === 0) return "Nothing
|
|
238
|
+
if (payload.model.nodes.length === 0) return "Nothing here yet — add a note to get started.";
|
|
239
239
|
// A payload with nodes but nothing visible cannot happen through the UI
|
|
240
240
|
// (`clusterAggregate` always keeps the roots), but a truncated response
|
|
241
241
|
// could produce it, and a blank canvas with no explanation reads as a crash.
|
|
@@ -83,11 +83,8 @@ export function graphPalette(theme: GraphTheme): Readonly<Record<ColorSlot, stri
|
|
|
83
83
|
* - `note` keeps `text` — notes are the product (§1.1), and their identity
|
|
84
84
|
* is carried by the provenance badge, not by a hue.
|
|
85
85
|
* - Generated code — modules, packages, entry points, git, externals, files
|
|
86
|
-
* — takes `dim`, the same grey a `generated` row takes in the tree.
|
|
87
|
-
*
|
|
88
|
-
* success/warning; the two media disagree on purpose, because a pi theme
|
|
89
|
-
* has no dark/light stylesheet to violate and a terminal can restyle
|
|
90
|
-
* everything. Here the sheet decides, and the sheet says calm.
|
|
86
|
+
* — takes `dim`, the same grey a `generated` row takes in the tree. Here
|
|
87
|
+
* the sheet decides, and the sheet says calm.
|
|
91
88
|
*/
|
|
92
89
|
export const KIND_SLOT: Readonly<Record<WireNodeKind, ColorSlot>> = {
|
|
93
90
|
vault: "accent",
|
|
@@ -513,7 +510,8 @@ export interface ViewBox {
|
|
|
513
510
|
* session: the node stays under the cursor, the canvas bounds the drag, and
|
|
514
511
|
* `[fit]` re-frames onto whatever the current positions are.
|
|
515
512
|
*
|
|
516
|
-
* Use the position extent
|
|
513
|
+
* Use the position extent padded by the largest node size, so no circle
|
|
514
|
+
* at the edge is cut off, with a minimum span of four collision radii.
|
|
517
515
|
* A single node (or coincident nodes) has no extent; Sigma scales layout-unit
|
|
518
516
|
* node sizes by that span, so a near-zero box magnifies one node across the
|
|
519
517
|
* canvas. Pixel-based `stagePadding` cannot prevent that.
|
|
@@ -536,7 +534,9 @@ export function frameBox(points: readonly Point[]): ViewBox | null {
|
|
|
536
534
|
if (p.y > maxY) maxY = p.y;
|
|
537
535
|
}
|
|
538
536
|
if (seen === 0) return null;
|
|
539
|
-
|
|
537
|
+
// The extent is of node *centres*; the circles reach past it by up to a
|
|
538
|
+
// full node size, which clipped the outermost nodes on a small graph.
|
|
539
|
+
const padding = Math.max(MAX_NODE_SIZE, (COLLIDE_RADIUS * 4 - Math.max(maxX - minX, maxY - minY)) / 2);
|
|
540
540
|
return { x: [minX - padding, maxX + padding], y: [minY - padding, maxY + padding] };
|
|
541
541
|
}
|
|
542
542
|
|
|
@@ -882,6 +882,54 @@ export function edgeReducer(
|
|
|
882
882
|
};
|
|
883
883
|
}
|
|
884
884
|
|
|
885
|
+
// --- the recall trail (issue #78) -----------------------------------------------------
|
|
886
|
+
|
|
887
|
+
/** The slice of `trail.model.ts`'s `TrailMark` this layer reads, restated to keep this file import-free of it. */
|
|
888
|
+
export interface TrailGlow {
|
|
889
|
+
readonly kind: "searched" | "read" | "wrote";
|
|
890
|
+
readonly strength: number;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* How each kind of touch is painted: which slot it blends toward, how far, and
|
|
895
|
+
* how much the node grows. A search hit is a soft amber hint, a read is a
|
|
896
|
+
* filled glow, and a write is the accent at full presence — the one place
|
|
897
|
+
* this canvas spends the accent on a note, because a write is the event.
|
|
898
|
+
*/
|
|
899
|
+
export const TRAIL_STYLE: Readonly<Record<TrailGlow["kind"], { slot: ColorSlot; blend: number; growth: number }>> = {
|
|
900
|
+
searched: { slot: "warning", blend: 0.35, growth: 1.08 },
|
|
901
|
+
read: { slot: "success", blend: 0.85, growth: 1.3 },
|
|
902
|
+
wrote: { slot: "accent", blend: 1, growth: 1.5 },
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* The recall-trail layer, applied **over** the selection reducer's answer.
|
|
907
|
+
*
|
|
908
|
+
* Over, because the trail is the live event: a note the agent just wrote must
|
|
909
|
+
* read as such even when it sits in the receded cloud of an unrelated
|
|
910
|
+
* selection. It is handed the *reduced* node, and colour and size scale with
|
|
911
|
+
* the glow's strength, so the fade is a smooth return to whatever the
|
|
912
|
+
* selection layer said; the label is kept so a glowing node always names itself.
|
|
913
|
+
*/
|
|
914
|
+
export function trailOverride(
|
|
915
|
+
glow: TrailGlow | undefined,
|
|
916
|
+
/** The node as the selection layer left it, so the glow composes over recession and growth. */
|
|
917
|
+
data: Pick<RenderNode, "color" | "size" | "zIndex">,
|
|
918
|
+
scheme: GraphTheme,
|
|
919
|
+
/** The node's own label — the selection layer may have dropped it, and a glowing node always names itself. */
|
|
920
|
+
label: string,
|
|
921
|
+
): NodeDisplayOverride {
|
|
922
|
+
if (glow === undefined || !(glow.strength > 0)) return {};
|
|
923
|
+
const style = TRAIL_STYLE[glow.kind];
|
|
924
|
+
const strength = Math.min(1, glow.strength);
|
|
925
|
+
return {
|
|
926
|
+
color: blendHex(data.color, graphPalette(scheme)[style.slot], style.blend * strength),
|
|
927
|
+
size: data.size * growth(style.growth, strength),
|
|
928
|
+
zIndex: data.zIndex + HIGHLIGHT_Z_LIFT * 3,
|
|
929
|
+
label,
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
|
|
885
933
|
// --- sigma settings (§7.4) -------------------------------------------------------------
|
|
886
934
|
|
|
887
935
|
/**
|
|
@@ -929,6 +977,20 @@ export interface GraphSettings {
|
|
|
929
977
|
*/
|
|
930
978
|
export const LABEL_SIZE_THRESHOLD = (MIN_NODE_SIZE + MAX_NODE_SIZE) / 2;
|
|
931
979
|
|
|
980
|
+
/**
|
|
981
|
+
* Below this many nodes every node carries its name.
|
|
982
|
+
*
|
|
983
|
+
* The size threshold exists to keep a dense graph legible; on a vault of
|
|
984
|
+
* two or three notes it left all but one circle unnamed, and a small graph
|
|
985
|
+
* has no density to protect.
|
|
986
|
+
*/
|
|
987
|
+
export const SMALL_GRAPH_NODES = 30;
|
|
988
|
+
|
|
989
|
+
/** `labelRenderedSizeThreshold` for a graph of `nodeCount` nodes. */
|
|
990
|
+
export function labelThreshold(nodeCount: number): number {
|
|
991
|
+
return nodeCount < SMALL_GRAPH_NODES ? 0 : LABEL_SIZE_THRESHOLD;
|
|
992
|
+
}
|
|
993
|
+
|
|
932
994
|
/**
|
|
933
995
|
* Label collision grid, in screen pixels.
|
|
934
996
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GraphTheme } from "../../shared/themes";
|
|
2
2
|
import type { Point } from "../../shared/layout";
|
|
3
|
-
import type { EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, ViewBox } from "./graph.model";
|
|
4
|
-
import { edgeReducer, fadeStep, frameBox, graphSettings, nodeReducer } from "./graph.model";
|
|
3
|
+
import type { EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
|
|
4
|
+
import { edgeReducer, fadeStep, frameBox, graphSettings, labelThreshold, nodeReducer, trailOverride } from "./graph.model";
|
|
5
5
|
import type { ProjectedGraph } from "./project";
|
|
6
6
|
import { positionsOf, project, syncPositions } from "./project";
|
|
7
7
|
|
|
@@ -56,6 +56,7 @@ export interface SigmaLike {
|
|
|
56
56
|
setSetting(key: "nodeReducer", value: (id: string, data: RenderNode) => NodeDisplayOverride): unknown;
|
|
57
57
|
setSetting(key: "edgeReducer", value: (key: string, data: RenderEdge, scheme: GraphTheme) => EdgeDisplayOverride): unknown;
|
|
58
58
|
setSetting(key: "enableCameraPanning", value: boolean): unknown;
|
|
59
|
+
setSetting(key: "labelRenderedSizeThreshold", value: number): unknown;
|
|
59
60
|
setGraph(graph: ProjectedGraph): unknown;
|
|
60
61
|
/**
|
|
61
62
|
* Freeze the graph→viewport normalization onto a fixed box.
|
|
@@ -129,6 +130,8 @@ export function sigmaRenderer(
|
|
|
129
130
|
let highlight: ReadonlySet<string> | null = null;
|
|
130
131
|
/** The selection inside that neighbourhood — see `nodeReducer`. */
|
|
131
132
|
let selected: string | null = null;
|
|
133
|
+
/** Recall-trail glows, layered over the selection — see `trailOverride`. */
|
|
134
|
+
let trail: ReadonlyMap<string, TrailGlow> = new Map();
|
|
132
135
|
/**
|
|
133
136
|
* How present the highlight currently *looks*, and where it is heading.
|
|
134
137
|
*
|
|
@@ -175,7 +178,11 @@ export function sigmaRenderer(
|
|
|
175
178
|
const applyReducers = (instance: SigmaLike): void => {
|
|
176
179
|
const nodes = nodeReducer(highlight, selected, fade);
|
|
177
180
|
const edges = edgeReducer(highlight, selected, fade);
|
|
178
|
-
|
|
181
|
+
const glows = trail;
|
|
182
|
+
instance.setSetting("nodeReducer", (id, data) => {
|
|
183
|
+
const reduced = { ...data, ...nodes(id, data, scheme) };
|
|
184
|
+
return { ...reduced, ...trailOverride(glows.get(id), reduced, scheme, data.label) };
|
|
185
|
+
});
|
|
179
186
|
instance.setSetting("edgeReducer", (key, data) => ({ ...data, ...edges(key, data, scheme) }));
|
|
180
187
|
};
|
|
181
188
|
|
|
@@ -246,6 +253,7 @@ export function sigmaRenderer(
|
|
|
246
253
|
instance.on("upNode", () => endDrag(dragging));
|
|
247
254
|
instance.on("upStage", () => endDrag(null));
|
|
248
255
|
applyReducers(instance);
|
|
256
|
+
instance.setSetting("labelRenderedSizeThreshold", labelThreshold(graph.order));
|
|
249
257
|
// Freeze the view box for whatever graph arrived before mount, so the
|
|
250
258
|
// first frame is already framed and stable — see `SigmaLike.setCustomBBox`.
|
|
251
259
|
instance.setCustomBBox(boxOf());
|
|
@@ -255,6 +263,8 @@ export function sigmaRenderer(
|
|
|
255
263
|
setGraph(next: RenderGraph) {
|
|
256
264
|
graph = project(next);
|
|
257
265
|
sigma?.setGraph(graph);
|
|
266
|
+
// A graph that crossed the small-graph line gains or loses its labels.
|
|
267
|
+
sigma?.setSetting("labelRenderedSizeThreshold", labelThreshold(graph.order));
|
|
258
268
|
// Re-frame on a shape change: new nodes legitimately change the extent,
|
|
259
269
|
// and the box is what keeps the *drag* frames from also changing it.
|
|
260
270
|
// Deliberately not touched by `setPositions` — a settling simulation
|
|
@@ -296,6 +306,12 @@ export function sigmaRenderer(
|
|
|
296
306
|
if (fade !== fadeTo) armFade();
|
|
297
307
|
},
|
|
298
308
|
|
|
309
|
+
/** Replace the recall-trail glows. The caller re-sends as the fade ticks. */
|
|
310
|
+
setTrail(next: ReadonlyMap<string, TrailGlow>) {
|
|
311
|
+
trail = next;
|
|
312
|
+
if (sigma !== null) applyReducers(sigma);
|
|
313
|
+
},
|
|
314
|
+
|
|
299
315
|
onSelect(handler: (id: string | null) => void) {
|
|
300
316
|
select = handler;
|
|
301
317
|
},
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
import DOMPurify from "dompurify";
|
|
42
42
|
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
43
|
-
import type { GraphPayload, NotePayload } from "../../shared/wire";
|
|
43
|
+
import type { GraphPayload, HistoryPayload, NotePayload } from "../../shared/wire";
|
|
44
44
|
import {
|
|
45
45
|
CREATED_WORD,
|
|
46
46
|
artifactKeyOfNode,
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
EDIT_LABEL,
|
|
50
50
|
EDITED_WORD,
|
|
51
51
|
EDITOR_ARIA_LABEL,
|
|
52
|
+
EDITOR_PLACEHOLDER,
|
|
52
53
|
EMPTY_PREVIEW,
|
|
53
54
|
SAVE_LABEL,
|
|
54
55
|
SAVING_LABEL,
|
|
@@ -60,6 +61,10 @@ import {
|
|
|
60
61
|
previewAnchorOf,
|
|
61
62
|
previewCard,
|
|
62
63
|
previewPlacement,
|
|
64
|
+
RAW_EDITOR_ARIA_LABEL,
|
|
65
|
+
RAW_TAIL_ARIA_LABEL,
|
|
66
|
+
RAW_TITLE,
|
|
67
|
+
rawSummary,
|
|
63
68
|
reducePreview,
|
|
64
69
|
renderNote,
|
|
65
70
|
selectedArtifactPath,
|
|
@@ -72,7 +77,10 @@ import {
|
|
|
72
77
|
} from "./note.model";
|
|
73
78
|
import type { NoteHeaderView, PreviewElement, PreviewEvent } from "./note.model";
|
|
74
79
|
import { createDraftStore } from "./drafts";
|
|
75
|
-
import type { DraftStore } from "./drafts";
|
|
80
|
+
import type { DraftStore, SaveNote } from "./drafts";
|
|
81
|
+
import { ReplayBar, wrapMarks } from "./Replay";
|
|
82
|
+
import type { ReplayState } from "./Replay";
|
|
83
|
+
import { HISTORY_CLOSE_LABEL, HISTORY_LABEL, PLAY_INTERVAL_MS, nextStep, replayStep } from "./replay.model";
|
|
76
84
|
|
|
77
85
|
/**
|
|
78
86
|
* The custom properties the preview card is placed with, written by the
|
|
@@ -97,7 +105,12 @@ export interface NoteProps {
|
|
|
97
105
|
* has already been reported to the user by the shell, which owns the one
|
|
98
106
|
* error-reporting gesture the client has.
|
|
99
107
|
*/
|
|
100
|
-
onSave:
|
|
108
|
+
onSave: SaveNote;
|
|
109
|
+
/**
|
|
110
|
+
* Load the note's saved revisions for the history slider. The History
|
|
111
|
+
* control is shown only when this is provided.
|
|
112
|
+
*/
|
|
113
|
+
onHistory?: (slug: string) => Promise<{ ok: true; data: HistoryPayload } | { ok: false; message: string }>;
|
|
101
114
|
/** Shared across Note instances so duplicate tabs edit one in-memory body. */
|
|
102
115
|
drafts?: DraftStore;
|
|
103
116
|
/** Stable per view; distinct when the same note is mounted more than once. */
|
|
@@ -124,6 +137,8 @@ function Header({
|
|
|
124
137
|
taskSaving,
|
|
125
138
|
onToggle,
|
|
126
139
|
onSave,
|
|
140
|
+
replaying,
|
|
141
|
+
onHistory,
|
|
127
142
|
}: {
|
|
128
143
|
view: NoteHeaderView;
|
|
129
144
|
open: () => void;
|
|
@@ -132,6 +147,8 @@ function Header({
|
|
|
132
147
|
taskSaving: boolean;
|
|
133
148
|
onToggle: () => void;
|
|
134
149
|
onSave: () => void;
|
|
150
|
+
replaying: boolean;
|
|
151
|
+
onHistory: (() => void) | null;
|
|
135
152
|
}) {
|
|
136
153
|
return (
|
|
137
154
|
<header class="weave-note-head">
|
|
@@ -144,7 +161,12 @@ function Header({
|
|
|
144
161
|
</button>
|
|
145
162
|
<button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{DONE_LABEL}</button>
|
|
146
163
|
</>
|
|
147
|
-
) : <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{EDIT_LABEL}</button>}
|
|
164
|
+
) : replaying ? null : <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{EDIT_LABEL}</button>}
|
|
165
|
+
{editing || onHistory === null ? null : (
|
|
166
|
+
<button type="button" class="weave-note-edit weave-note-history" aria-pressed={replaying} onClick={onHistory}>
|
|
167
|
+
{replaying ? HISTORY_CLOSE_LABEL : HISTORY_LABEL}
|
|
168
|
+
</button>
|
|
169
|
+
)}
|
|
148
170
|
<button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
|
|
149
171
|
<span class="weave-note-open-mark" aria-hidden="true">↗</span>
|
|
150
172
|
</button>
|
|
@@ -201,7 +223,16 @@ export function Note(props: NoteProps) {
|
|
|
201
223
|
const dispatch = (event: PreviewEvent): void => void sendPreview((state) => reducePreview(state, event));
|
|
202
224
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
203
225
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
|
226
|
+
const editorRef = useRef<HTMLTextAreaElement | null>(null);
|
|
227
|
+
// Set by the gesture that opened the editor *in this column*, consumed by
|
|
228
|
+
// the layout effect below once the textarea exists. A flag rather than
|
|
229
|
+
// "focus whenever a draft appears": the same note open in a second pane
|
|
230
|
+
// shares the draft, and must not take the cursor away from this one.
|
|
231
|
+
const focusEditor = useRef(false);
|
|
204
232
|
const draft = note === null ? null : drafts.get(note.slug);
|
|
233
|
+
// The `## Raw` box under the editor starts folded: a long dictation would
|
|
234
|
+
// otherwise outgrow the note it belongs to.
|
|
235
|
+
const [rawOpen, setRawOpen] = useState(false);
|
|
205
236
|
const taskSaving = note !== null && drafts.isTaskSaving(note.slug);
|
|
206
237
|
const save = async (): Promise<void> => {
|
|
207
238
|
if (note !== null && !drafts.isTaskSaving(note.slug)) await drafts.save(note.slug, props.onSave);
|
|
@@ -239,17 +270,92 @@ export function Note(props: NoteProps) {
|
|
|
239
270
|
useEffect(() => {
|
|
240
271
|
if (note !== null && defaultOpened.current !== note.slug) {
|
|
241
272
|
defaultOpened.current = note.slug;
|
|
242
|
-
if (props.defaultEdit && drafts.get(note.slug) === null)
|
|
273
|
+
if (props.defaultEdit && drafts.get(note.slug) === null) {
|
|
274
|
+
focusEditor.current = true;
|
|
275
|
+
drafts.open(note.slug, body);
|
|
276
|
+
}
|
|
243
277
|
}
|
|
244
278
|
}, [note?.slug, props.defaultEdit]);
|
|
245
279
|
|
|
246
280
|
const toggleEdit = (): void => {
|
|
247
281
|
if (note === null || drafts.isTaskSaving(note.slug)) return;
|
|
248
|
-
if (drafts.get(note.slug) === null)
|
|
249
|
-
|
|
282
|
+
if (drafts.get(note.slug) === null) {
|
|
283
|
+
focusEditor.current = true;
|
|
284
|
+
drafts.open(note.slug, body);
|
|
285
|
+
} else if (!drafts.isDirty(note.slug) || window.confirm(DISCARD_PROMPT)) drafts.discard(note.slug);
|
|
286
|
+
};
|
|
287
|
+
// Both editor boxes: bound here, not in `keys.model.ts`, because a global
|
|
288
|
+
// binding is a workspace-wide claim and Escape must not reach the shell.
|
|
289
|
+
const editorKeys = (event: KeyboardEvent): void => {
|
|
290
|
+
if (event.key === "Escape") {
|
|
291
|
+
event.stopPropagation();
|
|
292
|
+
toggleEdit();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (event.key.toLowerCase() !== "s" || !(event.metaKey || event.ctrlKey)) return;
|
|
296
|
+
event.preventDefault();
|
|
297
|
+
void save();
|
|
250
298
|
};
|
|
251
299
|
const card = preview.anchor === null ? null : previewCard(props.graph, preview.anchor);
|
|
252
300
|
|
|
301
|
+
// Cursor in the textarea, after the last word, the moment it appears.
|
|
302
|
+
useLayoutEffect(() => {
|
|
303
|
+
const editor = editorRef.current;
|
|
304
|
+
if (editor === null || !focusEditor.current) return;
|
|
305
|
+
focusEditor.current = false;
|
|
306
|
+
editor.focus();
|
|
307
|
+
editor.setSelectionRange(editor.value.length, editor.value.length);
|
|
308
|
+
}, [draft !== null]);
|
|
309
|
+
|
|
310
|
+
// The history slider. Held per column, like the preview card: it is a view
|
|
311
|
+
// of the note on screen, and closes when the note changes.
|
|
312
|
+
const [replay, setReplay] = useState<ReplayState | null>(null);
|
|
313
|
+
const replaySlug = useRef<string | null>(null);
|
|
314
|
+
const loadHistory = props.onHistory;
|
|
315
|
+
const toggleHistory = (): void => {
|
|
316
|
+
if (note === null || loadHistory === undefined) return;
|
|
317
|
+
if (replay !== null) {
|
|
318
|
+
replaySlug.current = null;
|
|
319
|
+
setReplay(null);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const slug = note.slug;
|
|
323
|
+
replaySlug.current = slug;
|
|
324
|
+
setReplay({ revisions: null, enabled: true, trimmed: false, index: 0, playing: false, error: null });
|
|
325
|
+
void loadHistory(slug).then((result) => {
|
|
326
|
+
if (replaySlug.current !== slug) return;
|
|
327
|
+
setReplay(result.ok
|
|
328
|
+
? { revisions: result.data.revisions, enabled: result.data.enabled, trimmed: result.data.trimmed, index: Math.max(0, result.data.revisions.length - 1), playing: false, error: null }
|
|
329
|
+
: { revisions: null, enabled: true, trimmed: false, index: 0, playing: false, error: `Could not load history: ${result.message}` });
|
|
330
|
+
});
|
|
331
|
+
};
|
|
332
|
+
const step = replay?.revisions == null ? null : replayStep(replay.revisions, replay.index);
|
|
333
|
+
const replayHtml = useMemo(
|
|
334
|
+
() => (step === null || index === null ? null : renderNote(DOMPurify, step.marked, index)),
|
|
335
|
+
[step?.marked, index],
|
|
336
|
+
);
|
|
337
|
+
useLayoutEffect(() => {
|
|
338
|
+
replaySlug.current = null;
|
|
339
|
+
setReplay(null);
|
|
340
|
+
setRawOpen(false);
|
|
341
|
+
}, [note === null ? null : note.slug]);
|
|
342
|
+
useLayoutEffect(() => {
|
|
343
|
+
if (replayHtml === null || bodyRef.current === null) return;
|
|
344
|
+
wrapMarks(bodyRef.current)?.scrollIntoView?.({ block: "nearest" });
|
|
345
|
+
}, [replayHtml]);
|
|
346
|
+
useEffect(() => {
|
|
347
|
+
if (replay === null || !replay.playing || replay.revisions === null) return;
|
|
348
|
+
const count = replay.revisions.length;
|
|
349
|
+
const timer = window.setTimeout(() => {
|
|
350
|
+
setReplay((state) => {
|
|
351
|
+
if (state === null) return state;
|
|
352
|
+
const next = nextStep(state.index, count);
|
|
353
|
+
return next === null ? { ...state, playing: false } : { ...state, index: next };
|
|
354
|
+
});
|
|
355
|
+
}, PLAY_INTERVAL_MS);
|
|
356
|
+
return () => window.clearTimeout(timer);
|
|
357
|
+
}, [replay]);
|
|
358
|
+
|
|
253
359
|
useLayoutEffect(() => {
|
|
254
360
|
if (note !== null) drafts.taskBody(note.slug, note.body, props.loadVersion);
|
|
255
361
|
}, [drafts, note === null ? null : note.slug, note?.body, props.loadVersion]);
|
|
@@ -348,27 +454,67 @@ export function Note(props: NoteProps) {
|
|
|
348
454
|
taskSaving={taskSaving}
|
|
349
455
|
onToggle={toggleEdit}
|
|
350
456
|
onSave={() => void save()}
|
|
457
|
+
replaying={replay !== null}
|
|
458
|
+
onHistory={loadHistory === undefined ? null : toggleHistory}
|
|
351
459
|
/>
|
|
460
|
+
{replay === null ? null : (
|
|
461
|
+
<ReplayBar
|
|
462
|
+
state={replay}
|
|
463
|
+
onIndex={(value) => setReplay((state) => state === null ? state : { ...state, index: value, playing: false })}
|
|
464
|
+
onPlay={() => setReplay((state) => {
|
|
465
|
+
if (state === null || state.revisions === null) return state;
|
|
466
|
+
if (state.playing) return { ...state, playing: false };
|
|
467
|
+
// Playing from the end starts over: that is what "watch it being written" means.
|
|
468
|
+
const restart = state.index >= state.revisions.length - 1;
|
|
469
|
+
return { ...state, index: restart ? 0 : state.index, playing: true };
|
|
470
|
+
})}
|
|
471
|
+
/>
|
|
472
|
+
)}
|
|
352
473
|
{draft !== null ? (
|
|
474
|
+
<>
|
|
353
475
|
<textarea
|
|
476
|
+
ref={editorRef}
|
|
354
477
|
class="weave-note-editor"
|
|
355
478
|
aria-label={EDITOR_ARIA_LABEL}
|
|
479
|
+
placeholder={EDITOR_PLACEHOLDER}
|
|
356
480
|
spellcheck={props.spellcheck ?? true}
|
|
357
481
|
value={draft.body}
|
|
358
482
|
onInput={(event) => drafts.edit(note.slug, (event.target as HTMLTextAreaElement).value)}
|
|
359
|
-
onKeyDown={
|
|
360
|
-
// Bound here, not in `keys.model.ts`: a global binding is a
|
|
361
|
-
// workspace-wide claim, and Escape must not reach the shell.
|
|
362
|
-
if (event.key === "Escape") {
|
|
363
|
-
event.stopPropagation();
|
|
364
|
-
toggleEdit();
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
if (event.key.toLowerCase() !== "s" || !(event.metaKey || event.ctrlKey)) return;
|
|
368
|
-
event.preventDefault();
|
|
369
|
-
void save();
|
|
370
|
-
}}
|
|
483
|
+
onKeyDown={editorKeys}
|
|
371
484
|
/>
|
|
485
|
+
{/* A box of its own, never the body's textarea: text typed below
|
|
486
|
+
the heading there used to be dropped on save without a word. */}
|
|
487
|
+
{draft.tail === "" ? null : (
|
|
488
|
+
<section class="weave-raw" aria-label={RAW_TAIL_ARIA_LABEL} data-open={rawOpen ? "" : undefined}>
|
|
489
|
+
<div class="weave-raw-head">
|
|
490
|
+
<button
|
|
491
|
+
type="button"
|
|
492
|
+
class="weave-raw-toggle"
|
|
493
|
+
aria-expanded={rawOpen}
|
|
494
|
+
aria-controls={`${props.idPrefix ?? generatedId}-weave-raw`}
|
|
495
|
+
onClick={() => setRawOpen((open) => !open)}
|
|
496
|
+
>
|
|
497
|
+
<svg class="weave-raw-chevron" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
|
498
|
+
<path d="M4.5 2.5 8 6l-3.5 3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
499
|
+
</svg>
|
|
500
|
+
{RAW_TITLE}
|
|
501
|
+
</button>
|
|
502
|
+
<span class="weave-raw-hint">{rawSummary(draft.raw, rawOpen)}</span>
|
|
503
|
+
</div>
|
|
504
|
+
{rawOpen ? (
|
|
505
|
+
<textarea
|
|
506
|
+
id={`${props.idPrefix ?? generatedId}-weave-raw`}
|
|
507
|
+
class="weave-raw-editor"
|
|
508
|
+
aria-label={RAW_EDITOR_ARIA_LABEL}
|
|
509
|
+
spellcheck={props.spellcheck ?? true}
|
|
510
|
+
value={draft.raw}
|
|
511
|
+
onInput={(event) => drafts.editRaw(note.slug, (event.target as HTMLTextAreaElement).value)}
|
|
512
|
+
onKeyDown={editorKeys}
|
|
513
|
+
/>
|
|
514
|
+
) : null}
|
|
515
|
+
</section>
|
|
516
|
+
)}
|
|
517
|
+
</>
|
|
372
518
|
) : (
|
|
373
519
|
<div
|
|
374
520
|
ref={bodyRef}
|
|
@@ -403,6 +549,11 @@ export function Note(props: NoteProps) {
|
|
|
403
549
|
onClick={(event) => {
|
|
404
550
|
const checkbox = event.target as HTMLInputElement;
|
|
405
551
|
const taskIndex = taskCheckboxIndexOf(checkbox);
|
|
552
|
+
// A past version is read-only: its checkboxes must not write to the note.
|
|
553
|
+
if (taskIndex !== null && replayHtml !== null) {
|
|
554
|
+
event.preventDefault();
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
406
557
|
if (taskIndex !== null) {
|
|
407
558
|
void saveTask(checkbox, taskIndex);
|
|
408
559
|
return;
|
|
@@ -434,7 +585,7 @@ export function Note(props: NoteProps) {
|
|
|
434
585
|
props.onSelect(target, event.metaKey || event.ctrlKey || event.shiftKey);
|
|
435
586
|
}}
|
|
436
587
|
// Sanitised by `renderNote`'s three layers — see note.model.ts.
|
|
437
|
-
dangerouslySetInnerHTML={{ __html: html }}
|
|
588
|
+
dangerouslySetInnerHTML={{ __html: replayHtml ?? html }}
|
|
438
589
|
/>
|
|
439
590
|
)}
|
|
440
591
|
{card === null ? null : (
|