pi-weave 0.3.0 → 0.3.2

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.
Files changed (50) hide show
  1. package/README.md +40 -16
  2. package/package.json +2 -2
  3. package/src/core/frontmatter.ts +3 -3
  4. package/src/core/graph/model.ts +2 -2
  5. package/src/core/graph/wikilinks.ts +1 -1
  6. package/src/core/types.ts +1 -1
  7. package/src/pi/viewer/tui/surface/detail.ts +1 -1
  8. package/src/web/client/dist/app.js +239 -243
  9. package/src/web/client/graph/ForceTuner.tsx +14 -98
  10. package/src/web/client/graph/Graph.tsx +29 -74
  11. package/src/web/client/graph/column.model.ts +22 -15
  12. package/src/web/client/graph/graph.model.ts +31 -63
  13. package/src/web/client/graph/groups.ts +9 -7
  14. package/src/web/client/graph/positions.ts +3 -3
  15. package/src/web/client/graph/renderer.dom.ts +2 -2
  16. package/src/web/client/graph/renderer.ts +14 -5
  17. package/src/web/client/graph/tuner.model.ts +6 -180
  18. package/src/web/client/main.tsx +1 -4
  19. package/src/web/client/note/Note.tsx +77 -92
  20. package/src/web/client/note/drafts.ts +172 -0
  21. package/src/web/client/note/note.model.ts +2 -34
  22. package/src/web/client/search/SearchPalette.tsx +9 -8
  23. package/src/web/client/shell/ContextRail.tsx +8 -9
  24. package/src/web/client/shell/Settings.tsx +71 -0
  25. package/src/web/client/shell/Shell.tsx +409 -255
  26. package/src/web/client/shell/StatusBar.tsx +2 -6
  27. package/src/web/client/shell/focus.model.ts +2 -2
  28. package/src/web/client/shell/keys.model.ts +40 -39
  29. package/src/web/client/shell/keys.ts +1 -0
  30. package/src/web/client/shell/shell.model.ts +32 -128
  31. package/src/web/client/shell/theme.model.ts +27 -97
  32. package/src/web/client/shell/theme.ts +32 -238
  33. package/src/web/client/shell/workspace.css.ts +140 -0
  34. package/src/web/client/tree/Tree.tsx +43 -14
  35. package/src/web/client/tree/tree.model.ts +19 -0
  36. package/src/web/client/workspace.ts +19 -1
  37. package/src/web/server/backup.ts +59 -0
  38. package/src/web/server/routes.ts +45 -3
  39. package/src/web/server/server.ts +5 -0
  40. package/src/web/server/workspace-state.ts +121 -0
  41. package/src/web/shared/layout.ts +5 -6
  42. package/src/web/shared/preferences.ts +76 -0
  43. package/src/web/shared/themes.ts +92 -0
  44. package/src/web/shared/workspace.ts +310 -0
  45. package/src/web/client/selection.storage.ts +0 -69
  46. package/src/web/client/shell/Columns.tsx +0 -139
  47. package/src/web/client/shell/Divider.tsx +0 -15
  48. package/src/web/client/shell/Header.tsx +0 -119
  49. package/src/web/client/shell/drag.model.ts +0 -35
  50. package/src/web/client/shell/layout.model.ts +0 -132
@@ -1,5 +1,6 @@
1
+ import type { GraphTheme } from "../../shared/themes";
1
2
  import type { Point } from "../../shared/layout";
2
- import type { ColorScheme, EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, ViewBox } from "./graph.model";
3
+ import type { EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, ViewBox } from "./graph.model";
3
4
  import { edgeReducer, fadeStep, frameBox, graphSettings, nodeReducer } from "./graph.model";
4
5
  import type { ProjectedGraph } from "./project";
5
6
  import { positionsOf, project, syncPositions } from "./project";
@@ -25,7 +26,7 @@ export interface RenderContainer {
25
26
  * driven by a recording fake in a test and by `createSigmaRenderer` in the
26
27
  * browser — the same port-shaped injection `api.ts` uses for `fetch`.
27
28
  */
28
- export type RendererFactory = (scheme: ColorScheme) => GraphRenderer;
29
+ export type RendererFactory = (scheme: GraphTheme) => GraphRenderer;
29
30
 
30
31
  // --- the sigma port -----------------------------------------------------------------
31
32
 
@@ -44,6 +45,7 @@ export type RendererFactory = (scheme: ColorScheme) => GraphRenderer;
44
45
  */
45
46
  export interface SigmaLike {
46
47
  on(event: "clickNode", handler: (payload: { node: string }) => void): unknown;
48
+ on(event: "doubleClickNode", handler: (payload: { node: string; preventSigmaDefault(): void }) => void): unknown;
47
49
  on(event: "clickStage", handler: () => void): unknown;
48
50
  on(event: "downNode", handler: (payload: { node: string }) => void): unknown;
49
51
  on(event: "enterNode", handler: (payload: { node: string }) => void): unknown;
@@ -52,7 +54,7 @@ export interface SigmaLike {
52
54
  on(event: "upNode" | "upStage", handler: () => void): unknown;
53
55
  viewportToGraph(position: { x: number; y: number }): Point;
54
56
  setSetting(key: "nodeReducer", value: (id: string, data: RenderNode) => NodeDisplayOverride): unknown;
55
- setSetting(key: "edgeReducer", value: (key: string, data: RenderEdge, scheme: ColorScheme) => EdgeDisplayOverride): unknown;
57
+ setSetting(key: "edgeReducer", value: (key: string, data: RenderEdge, scheme: GraphTheme) => EdgeDisplayOverride): unknown;
56
58
  setSetting(key: "enableCameraPanning", value: boolean): unknown;
57
59
  setGraph(graph: ProjectedGraph): unknown;
58
60
  /**
@@ -114,7 +116,7 @@ export const rafClock = (host: {
114
116
 
115
117
  export function sigmaRenderer(
116
118
  create: (graph: ProjectedGraph, container: RenderContainer, settings: GraphSettings) => SigmaLike,
117
- scheme: ColorScheme,
119
+ scheme: GraphTheme,
118
120
  /**
119
121
  * The fade clock, or omitted for no animation at all — in which case every
120
122
  * highlight change lands at full strength on the next frame, which is the
@@ -146,6 +148,7 @@ export function sigmaRenderer(
146
148
  let dragMove: (id: string, at: Point) => void = () => {};
147
149
  let dragEnd: (id: string) => void = () => {};
148
150
  let dragging: string | null = null;
151
+ let nodeMoved = false;
149
152
 
150
153
  const endDrag = (id: string | null): void => {
151
154
  if (dragging === null) return;
@@ -208,16 +211,22 @@ export function sigmaRenderer(
208
211
  const instance = create(graph, container, graphSettings(scheme));
209
212
  // §1.3's context bus: a click writes `selectedId`, and the note column,
210
213
  // the tree and the context rail all recompute from it.
211
- instance.on("clickNode", ({ node }) => select(node));
214
+ instance.on("clickNode", ({ node }) => { if (!nodeMoved) select(node); });
215
+ instance.on("doubleClickNode", ({ node, preventSigmaDefault }) => {
216
+ preventSigmaDefault();
217
+ if (!nodeMoved) select(node);
218
+ });
212
219
  instance.on("clickStage", () => select(null));
213
220
  instance.on("downNode", ({ node }) => {
214
221
  dragging = node;
222
+ nodeMoved = false;
215
223
  // Hold the view still while the node follows the cursor (see endDrag).
216
224
  sigma?.setSetting("enableCameraPanning", false);
217
225
  dragStart(node);
218
226
  });
219
227
  instance.on("moveBody", (payload) => {
220
228
  if (dragging !== null) {
229
+ nodeMoved = true;
221
230
  // Sigma's captor pans the camera on every mouse move while the
222
231
  // button is down; during a node drag that is the pan the gesture
223
232
  // must not be. `preventSigmaDefault` is the captor's own gate — it
@@ -1,126 +1,10 @@
1
- /**
2
- * The hidden force tuner's logic (docs/weave-workspace.md §15.7).
3
- *
4
- * The graph's arrangement is decided by six numbers in `shared/layout.ts`, and
5
- * the difference between "one hairball" and "legible groups" is a *ratio*
6
- * between them that is far easier to see than to derive. So there is a panel
7
- * of sliders behind a URL flag, and everything about it that is not a DOM node
8
- * lives here — §10's rule, the same split `Graph.tsx` / `column.model.ts`
9
- * already uses.
10
- *
11
- * ## Why a query flag and not a setting
12
- *
13
- * The tuner is a *developer* affordance with a human in the loop; it is not a
14
- * feature and must not read as one. A URL string is the cheapest gate that is
15
- * genuinely hidden (no menu entry, no chord to collide with §11 P4's
16
- * keymap, no persisted state to leak into a normal session) while staying
17
- * reachable on a running workspace with no rebuild. A build-time `define`
18
- * would have been more hidden still and was rejected: it breaks the
19
- * byte-reproducible `build:web:check` contract for a panel that is already
20
- * unreachable without the flag.
21
- *
22
- * ## Tier rules (§2)
23
- *
24
- * `src/web/client/**`, but nothing here touches the DOM or npm — the flag
25
- * arrives as a `location.search` *string*, so this module compiles under the
26
- * root `tsconfig.json` and its tests are ordinary ones.
27
- */
28
-
29
- import type { ForceConstants } from "../../shared/layout";
30
- import { FORCE_DEFAULTS } from "../../shared/layout";
31
-
32
- // --- the gate ----------------------------------------------------------------------
33
-
34
- /** The query parameter that opens the panel. Documented in §15.7. */
35
- export const SLIDERS_FLAG = "sliders";
36
-
37
- /**
38
- * Whether `location.search` asks for the tuner.
39
- *
40
- * `?sliders`, `?sliders=1` and `?sliders=true` all open it; `?sliders=0` and
41
- * `?sliders=false` do not, so a bookmarked URL can carry the parameter in the
42
- * off position. Anything unparseable is off — a typo must not silently put a
43
- * debug panel over a user's graph.
44
- *
45
- * Hand-parsed rather than via `URLSearchParams`, which is a DOM/Node global
46
- * this tier's `tsconfig` does not provide.
47
- */
48
- export function slidersFlag(search: string): boolean {
49
- for (const pair of search.replace(/^\?/, "").split("&")) {
50
- const eq = pair.indexOf("=");
51
- const name = eq === -1 ? pair : pair.slice(0, eq);
52
- if (name !== SLIDERS_FLAG) continue;
53
- const value = eq === -1 ? "" : pair.slice(eq + 1).toLowerCase();
54
- return value !== "0" && value !== "false";
55
- }
56
- return false;
57
- }
58
-
59
- // --- the sliders --------------------------------------------------------------------
1
+ /** Parsing and formatting for Settings → Interface graph sliders. */
2
+ import { FORCE_DEFAULTS, type ForceConstants } from "../../shared/layout";
3
+ import { FORCE_SLIDERS } from "../../shared/preferences";
4
+ import type { SliderSpec } from "../../shared/preferences";
5
+ export { FORCE_SLIDERS };
6
+ export type { SliderSpec };
60
7
 
61
- /** One slider: which constant it drives, and the range it may drive it over. */
62
- export interface SliderSpec {
63
- readonly key: keyof ForceConstants;
64
- readonly label: string;
65
- /** One line under the label saying what moving it does. */
66
- readonly hint: string;
67
- readonly min: number;
68
- readonly max: number;
69
- readonly step: number;
70
- }
71
-
72
- /**
73
- * The panel, in the order the forces actually fight each other: cohesion
74
- * first (what holds a group together), then repulsion (what pushes groups
75
- * apart), then gravity (what pulls everything back in). Reading top to bottom
76
- * is reading the tug-of-war.
77
- *
78
- * Ranges are generous on purpose — the point of the exercise is that the
79
- * shipped values are in the wrong region, so a range hugging them would hide
80
- * the answer. `chargeMax` tops out at 4000, which is past any useful value on
81
- * a graph that spans a few thousand units; that top of the range is
82
- * effectively "uncapped".
83
- */
84
- export const FORCE_SLIDERS: readonly SliderSpec[] = [
85
- { key: "containsStrength", label: "contains strength", hint: "how hard a parent holds its children — group cohesion", min: 0, max: 1, step: 0.01 },
86
- { key: "containsRest", label: "contains rest", hint: "how far children sit from their parent — rosette radius", min: 10, max: 300, step: 5 },
87
- { key: "relationStrength", label: "relation strength", hint: "pull of links-to / mentions across groups", min: 0, max: 1, step: 0.01 },
88
- { key: "relationDistance", label: "relation distance", hint: "how long those cross-group springs are", min: 20, max: 600, step: 10 },
89
- { key: "charge", label: "charge", hint: "node-to-node repulsion — more negative pushes harder", min: -1200, max: 0, step: 10 },
90
- { key: "chargeMax", label: "charge range", hint: "distance past which repulsion stops — caps overall spread", min: 100, max: 4000, step: 50 },
91
- { key: "center", label: "centre gravity", hint: "pull toward the origin — too much makes one blob", min: 0, max: 0.3, step: 0.005 },
92
- ];
93
-
94
- /**
95
- * The arrangement this whole exercise replaced, kept as a **comparison**.
96
- *
97
- * Not a suggestion any more — `FORCE_DEFAULTS` is now the chosen answer, found
98
- * through these sliders and frozen in `shared/layout.ts`. This is the *before*
99
- * picture, one button press away, so the next person to open the panel can see
100
- * in one click what the constants are for rather than reading a changelog.
101
- *
102
- * Measured at 300 ticks, worst-case gap between the big branches' bounding
103
- * boxes (negative = the branches interleave, which is the hairball):
104
- * `siblingBlobsGraph` **−312** here against **+39** shipped, `repoLikeGraph`
105
- * **−274** against **+133**.
106
- */
107
- export const HAIRBALL: Readonly<ForceConstants> = {
108
- containsRest: 90,
109
- containsStrength: 0.02,
110
- relationDistance: 170,
111
- relationStrength: 0.05,
112
- charge: -50,
113
- chargeMax: Infinity,
114
- center: 0.09,
115
- };
116
-
117
- /**
118
- * A slider's value for `<input type="range">`.
119
- *
120
- * `chargeMax` defaults to `Infinity`, which is not a number a range input can
121
- * hold, so it reads as the top of its range — the position that means "no
122
- * meaningful cap", which is what `Infinity` is.
123
- */
124
8
  export function sliderValue(forces: ForceConstants, spec: SliderSpec): number {
125
9
  const raw = forces[spec.key];
126
10
  if (!Number.isFinite(raw)) return spec.max;
@@ -147,64 +31,6 @@ export function formatValue(value: number): string {
147
31
  return Number.isInteger(value) ? String(value) : value.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
148
32
  }
149
33
 
150
- // --- handing the numbers back ----------------------------------------------------------
151
-
152
- /**
153
- * The current constants as the literal that belongs in `shared/layout.ts`.
154
- *
155
- * The whole point of the tuner is that a human finds the values and they then
156
- * get *frozen into the source*, so the last step of the loop is transcription
157
- * — and transcribing seven floats by eye off a panel is exactly where a digit
158
- * gets dropped. The Copy button emits the block verbatim.
159
- */
160
- export function forcesSnippet(forces: ForceConstants): string {
161
- const line = (key: keyof ForceConstants): string => ` ${key}: ${Number.isFinite(forces[key]) ? forces[key] : "Infinity"},`;
162
- return ["export const FORCES: ForceConstants = {", ...FORCE_SLIDERS.map((spec) => line(spec.key)), "};"].join("\n");
163
- }
164
-
165
- /** True when nothing has been moved off the shipped values. Drives "Reset". */
166
34
  export function isDefault(forces: ForceConstants): boolean {
167
35
  return FORCE_SLIDERS.every((spec) => Object.is(forces[spec.key], FORCE_DEFAULTS[spec.key]));
168
36
  }
169
-
170
- // --- the group-colour setting (§15.8) ------------------------------------------------
171
-
172
- /**
173
- * Where the group-colour choice is remembered.
174
- *
175
- * Versioned like the position cache, and stored separately from it: a palette
176
- * preference must survive the layout invalidation that a force change causes,
177
- * or tuning the physics would silently reset the user's colours.
178
- */
179
- export const GROUP_COLORS_STORAGE_KEY = "pi-weave.graph.groupColors.v1";
180
-
181
- /** The two-method storage port, same shape as `PositionStorage`. */
182
- export interface SettingStorage {
183
- getItem(key: string): string | null;
184
- setItem(key: string, value: string): void;
185
- }
186
-
187
- /**
188
- * Whether to colour by group. Defaults to **on**.
189
- *
190
- * On by default because the forces go to real trouble to separate the groups
191
- * and leaving them one colour wastes that. A throwing `getItem` (Safari
192
- * private browsing, partitioned storage) is a default, not an error — the
193
- * same answer `loadPositions` gives.
194
- */
195
- export function loadGroupColors(storage: SettingStorage): boolean {
196
- try {
197
- return storage.getItem(GROUP_COLORS_STORAGE_KEY) !== "off";
198
- } catch {
199
- return true;
200
- }
201
- }
202
-
203
- /** Persist the choice, best-effort. A full or refusing quota is not an error. */
204
- export function saveGroupColors(storage: SettingStorage, on: boolean): void {
205
- try {
206
- storage.setItem(GROUP_COLORS_STORAGE_KEY, on ? "on" : "off");
207
- } catch {
208
- // A preference that does not survive a reload still works this session.
209
- }
210
- }
@@ -13,7 +13,6 @@
13
13
  import { render } from "preact";
14
14
  import { BOOTSTRAP_ELEMENT_ID } from "../shared/wire";
15
15
  import { readBootstrap } from "./bootstrap";
16
- import { slidersFlag } from "./graph/tuner.model";
17
16
  import { Shell } from "./shell/Shell";
18
17
  import { installTheme } from "./shell/theme";
19
18
  import { loadTheme, themeAttr } from "./shell/theme.model";
@@ -34,9 +33,7 @@ if (host !== null) {
34
33
  cwd={boot.cwd}
35
34
  initialWidth={window.innerWidth}
36
35
  platform={navigator.platform}
37
- // The fourth DOM read that cannot be injected further up. `slidersFlag`
38
- // owns the parsing, so this stays a read and the decision stays tested.
39
- tuner={slidersFlag(window.location.search)}
36
+
40
37
  />,
41
38
  host,
42
39
  );
@@ -11,8 +11,8 @@
11
11
  * The explicit Edit control swaps the body for a `<textarea>`; `draft` is
12
12
  * `null` in read mode, so text and mode cannot disagree. No reducer — the 686-line one that
13
13
  * stood here resolved conflicts against a revision the server no longer
14
- * issues. Being component state, the draft is out of the 2 s poll's reach;
15
- * the shell guards it through the `editor` slot below.
14
+ * issues. Drafts are keyed by note identity in the shared in-memory store, out of the
15
+ * 2 s poll's reach; the shell guards removal of their final visible tab.
16
16
  *
17
17
  * `dangerouslySetInnerHTML` is used deliberately for sanitized note HTML. The
18
18
  * alternative is parsing marked's output into a Preact tree, which means a
@@ -39,7 +39,7 @@
39
39
  */
40
40
 
41
41
  import DOMPurify from "dompurify";
42
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
42
+ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
43
43
  import type { GraphPayload, NotePayload } from "../../shared/wire";
44
44
  import {
45
45
  CREATED_WORD,
@@ -50,15 +50,11 @@ import {
50
50
  EDITED_WORD,
51
51
  EDITOR_ARIA_LABEL,
52
52
  EMPTY_PREVIEW,
53
- PREVIEW_ID,
54
53
  SAVE_LABEL,
55
54
  SAVING_LABEL,
56
55
  TASK_CHECKBOX_ATTR,
57
56
  WIKILINK_ATTR,
58
- draftDirty,
59
- draftMoved,
60
57
  hasTextSelection,
61
- saveLanded,
62
58
  noteEmptyMessage,
63
59
  noteHeader,
64
60
  previewAnchorOf,
@@ -75,6 +71,8 @@ import {
75
71
  wikilinkTargetOf,
76
72
  } from "./note.model";
77
73
  import type { NoteHeaderView, PreviewElement, PreviewEvent } from "./note.model";
74
+ import { createDraftStore } from "./drafts";
75
+ import type { DraftStore } from "./drafts";
78
76
 
79
77
  /**
80
78
  * The custom properties the preview card is placed with, written by the
@@ -86,11 +84,13 @@ const PREVIEW_X = "weave-preview-x";
86
84
  const PREVIEW_Y = "weave-preview-y";
87
85
 
88
86
  export interface NoteProps {
87
+ spellcheck?: boolean;
88
+ defaultEdit?: boolean;
89
89
  note: NotePayload | null;
90
90
  loadFailed: boolean;
91
91
  graph: GraphPayload | null;
92
92
  selectedId: string | null;
93
- onSelect: (id: string) => void;
93
+ onSelect: (id: string, newTab?: boolean) => void;
94
94
  onOpen: (slug: string) => void;
95
95
  /**
96
96
  * Save the note's body. Resolves `true` when the write landed; a `false`
@@ -98,12 +98,12 @@ export interface NoteProps {
98
98
  * error-reporting gesture the client has.
99
99
  */
100
100
  onSave: (slug: string, body: string) => Promise<boolean>;
101
- /**
102
- * The shell's handle on the open editor — the `fit` pattern again. Guards
103
- * both *ask* (navigate, unload) and *close* (a tree rename of this note).
104
- * Functions, not values, or a mount-time guard answers "clean" forever.
105
- */
106
- editor: { current: { dirty(): boolean; discard(): void } | null };
101
+ /** Shared across Note instances so duplicate tabs edit one in-memory body. */
102
+ drafts?: DraftStore;
103
+ /** Stable per view; distinct when the same note is mounted more than once. */
104
+ idPrefix?: string;
105
+ /** Monotonic identity of the document load that produced `note`. */
106
+ loadVersion: number;
107
107
  /** Epoch ms for relative times. Injected so the render is deterministic. */
108
108
  now: number;
109
109
  }
@@ -121,6 +121,7 @@ function Header({
121
121
  open,
122
122
  editing,
123
123
  saving,
124
+ taskSaving,
124
125
  onToggle,
125
126
  onSave,
126
127
  }: {
@@ -128,6 +129,7 @@ function Header({
128
129
  open: () => void;
129
130
  editing: boolean;
130
131
  saving: boolean;
132
+ taskSaving: boolean;
131
133
  onToggle: () => void;
132
134
  onSave: () => void;
133
135
  }) {
@@ -137,12 +139,12 @@ function Header({
137
139
  <p class="weave-note-meta">
138
140
  {editing ? (
139
141
  <>
140
- <button type="button" class="weave-note-save" disabled={saving} onClick={onSave}>
142
+ <button type="button" class="weave-note-save" disabled={saving || taskSaving} onClick={onSave}>
141
143
  {saving ? SAVING_LABEL : SAVE_LABEL}
142
144
  </button>
143
- <button type="button" class="weave-note-edit" onClick={onToggle}>{DONE_LABEL}</button>
145
+ <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{DONE_LABEL}</button>
144
146
  </>
145
- ) : <button type="button" class="weave-note-edit" onClick={onToggle}>{EDIT_LABEL}</button>}
147
+ ) : <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{EDIT_LABEL}</button>}
146
148
  <button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
147
149
  <span class="weave-note-open-mark" aria-hidden="true">↗</span>
148
150
  </button>
@@ -168,7 +170,14 @@ function Header({
168
170
  }
169
171
 
170
172
  export function Note(props: NoteProps) {
173
+ const localDrafts = useRef(createDraftStore());
174
+ const drafts = props.drafts ?? localDrafts.current;
175
+ const generatedId = useId();
176
+ const previewId = `${props.idPrefix ?? generatedId}-weave-preview`;
177
+ const [storeVersion, setDraftVersion] = useState(0);
178
+ useEffect(() => drafts.subscribe(() => setDraftVersion((version) => version + 1)), [drafts]);
171
179
  const note = props.note?.note ?? null;
180
+ const body = note === null ? "" : drafts.getTaskBody(note.slug, note.body);
172
181
  const artifactPath = selectedArtifactPath(props.graph, props.selectedId);
173
182
  const empty = noteEmptyMessage(props.selectedId, note, props.loadFailed);
174
183
 
@@ -180,8 +189,8 @@ export function Note(props: NoteProps) {
180
189
  // only the *result* is memoized.
181
190
  const index = useMemo(() => (note === null ? null : wikiIndex(props.graph, note.slug)), [note, props.graph]);
182
191
  const html = useMemo(
183
- () => (note === null || index === null ? "" : renderNote(DOMPurify, note.body, index)),
184
- [note, index],
192
+ () => (note === null || index === null ? "" : renderNote(DOMPurify, body, index)),
193
+ [note, body, index],
185
194
  );
186
195
 
187
196
  // The hover card. The reducer lives in `note.model.ts`; this is its
@@ -192,90 +201,59 @@ export function Note(props: NoteProps) {
192
201
  const dispatch = (event: PreviewEvent): void => void sendPreview((state) => reducePreview(state, event));
193
202
  const bodyRef = useRef<HTMLDivElement | null>(null);
194
203
  const cardRef = useRef<HTMLDivElement | null>(null);
195
- const taskSaving = useRef(false);
196
- const taskSource = useRef<{ slug: string; serverBody: string; body: string } | null>(null);
197
- if (note === null) taskSource.current = null;
198
- else if (taskSource.current === null || taskSource.current.slug !== note.slug || taskSource.current.serverBody !== note.body) {
199
- taskSource.current = { slug: note.slug, serverBody: note.body, body: note.body };
200
- }
201
-
202
- const [draft, setDraft] = useState<string | null>(null);
203
- const [saving, setSaving] = useState(false);
204
- const dirty = note !== null && draft !== null && draftDirty(draft, note.body);
205
- // Bumped on open and close, so a late reply knows it is late (`saveLanded`).
206
- const session = useRef(0);
207
- // The draft *now*: `save` outlives the render that captured its own copy.
208
- const draftRef = useRef(draft);
209
- draftRef.current = draft;
210
-
211
- // During render, not in an effect: an effect runs after paint, leaving a
212
- // window where the slot is still clean and a click slips the guard.
213
- props.editor.current = { dirty: () => dirty, discard: () => close() };
214
- useEffect(() => () => {
215
- props.editor.current = null;
216
- }, [props.editor]);
217
-
218
- const close = (): void => {
219
- session.current += 1;
220
- setDraft(null);
221
- setSaving(false);
222
- };
223
-
224
- const open = (body: string): void => {
225
- session.current += 1;
226
- setDraft(body);
227
- };
228
-
204
+ const draft = note === null ? null : drafts.get(note.slug);
205
+ const taskSaving = note !== null && drafts.isTaskSaving(note.slug);
229
206
  const save = async (): Promise<void> => {
230
- if (note === null || draft === null || saving) return;
231
- const issued = session.current;
232
- // The textarea stays enabled through the request, so the draft can move
233
- // underneath it; the reply is only ever about these bytes.
234
- const sent = draft;
235
- setSaving(true);
236
- const ok = await props.onSave(note.slug, sent);
237
- // A reply for an editor that is gone must not close the one now open.
238
- if (!saveLanded(issued, session.current)) return;
239
- // Stay open on failure, and on success the user typed through: both hold
240
- // text the server does not have, and closing would discard it.
241
- if (!ok || draftMoved(sent, draftRef.current)) setSaving(false);
242
- else close();
207
+ if (note !== null && !drafts.isTaskSaving(note.slug)) await drafts.save(note.slug, props.onSave);
243
208
  };
244
209
 
245
210
  const saveTask = async (checkbox: HTMLInputElement, taskIndex: number): Promise<void> => {
246
- const source = taskSource.current;
247
211
  const restore = (): void => {
248
212
  checkbox.checked = !checkbox.checked;
249
213
  checkbox.setAttribute("aria-label", taskCheckboxLabel(checkbox.checked));
250
214
  };
251
- if (source === null || taskSaving.current) {
215
+ if (note === null || drafts.isTaskSaving(note.slug)) {
252
216
  restore();
253
217
  return;
254
218
  }
255
- const body = toggleTaskCheckbox(source.body, taskIndex);
256
- if (body === null) {
219
+ const nextBody = toggleTaskCheckbox(drafts.getTaskBody(note.slug, note.body), taskIndex);
220
+ if (nextBody === null) {
257
221
  restore();
258
222
  return;
259
223
  }
260
224
 
261
225
  const inputs = [...(bodyRef.current?.querySelectorAll<HTMLInputElement>(`input[${TASK_CHECKBOX_ATTR}]`) ?? [])];
262
- taskSaving.current = true;
263
226
  checkbox.setAttribute("aria-label", taskCheckboxLabel(checkbox.checked));
264
227
  for (const input of inputs) input.disabled = true;
265
- const ok = await props.onSave(source.slug, body);
266
- if (ok) source.body = body;
267
- else restore();
268
- for (const input of inputs) input.disabled = false;
269
- taskSaving.current = false;
228
+ try {
229
+ if (!await drafts.saveTask(note.slug, nextBody, props.onSave)) restore();
230
+ } catch (error) {
231
+ restore();
232
+ throw error;
233
+ } finally {
234
+ for (const input of inputs) input.disabled = drafts.isTaskSaving(note.slug);
235
+ }
270
236
  };
271
237
 
238
+ const defaultOpened = useRef<string | null>(null);
239
+ useEffect(() => {
240
+ if (note !== null && defaultOpened.current !== note.slug) {
241
+ defaultOpened.current = note.slug;
242
+ if (props.defaultEdit && drafts.get(note.slug) === null) drafts.open(note.slug, body);
243
+ }
244
+ }, [note?.slug, props.defaultEdit]);
245
+
272
246
  const toggleEdit = (): void => {
273
- if (note === null) return;
274
- if (draft === null) open(note.body);
275
- else if (!dirty || window.confirm(DISCARD_PROMPT)) close();
247
+ if (note === null || drafts.isTaskSaving(note.slug)) return;
248
+ if (drafts.get(note.slug) === null) drafts.open(note.slug, body);
249
+ else if (!drafts.isDirty(note.slug) || window.confirm(DISCARD_PROMPT)) drafts.discard(note.slug);
276
250
  };
277
251
  const card = preview.anchor === null ? null : previewCard(props.graph, preview.anchor);
278
252
 
253
+ useLayoutEffect(() => {
254
+ if (note !== null) drafts.taskBody(note.slug, note.body, props.loadVersion);
255
+ }, [drafts, note === null ? null : note.slug, note?.body, props.loadVersion]);
256
+
279
257
  // A card whose target note left the screen is a claim about a document that
280
258
  // is gone. Navigating (or an SSE swap of the open note, which is what
281
259
  // `note.slug` changing underneath the pointer looks like) closes it rather
@@ -284,12 +262,18 @@ export function Note(props: NoteProps) {
284
262
  // slug changes as rarely as navigation happens.
285
263
  useLayoutEffect(() => {
286
264
  dispatch({ type: "hide" });
287
- // A different note is a different document: an open editor holding the
288
- // previous one's text would save it over this one. The shell's guard is
289
- // what asks before a dirty draft gets here.
290
- close();
265
+ // Drafts belong to note identity and survive this Note instance changing
266
+ // documents or unmounting.
291
267
  }, [note === null ? null : note.slug]);
292
268
 
269
+ useLayoutEffect(() => {
270
+ if (note === null) return;
271
+ const saving = drafts.isTaskSaving(note.slug);
272
+ for (const input of bodyRef.current?.querySelectorAll<HTMLInputElement>(`input[${TASK_CHECKBOX_ATTR}]`) ?? []) {
273
+ input.disabled = saving;
274
+ }
275
+ }, [note === null ? null : note.slug, storeVersion, html]);
276
+
293
277
  // Wired after the card mounts, because both jobs depend on the live DOM:
294
278
  // the card's size (the placement decision needs measured dimensions, which
295
279
  // no render can know), and `aria-describedby`, which the link has to carry
@@ -302,7 +286,7 @@ export function Note(props: NoteProps) {
302
286
  if (body !== null) {
303
287
  const slug = preview.anchor?.slug ?? null;
304
288
  for (const link of body.querySelectorAll(`a[${WIKILINK_ATTR}]`)) {
305
- if (slug !== null && link.getAttribute(WIKILINK_ATTR) === slug) link.setAttribute("aria-describedby", PREVIEW_ID);
289
+ if (slug !== null && link.getAttribute(WIKILINK_ATTR) === slug) link.setAttribute("aria-describedby", previewId);
306
290
  else link.removeAttribute("aria-describedby");
307
291
  }
308
292
  }
@@ -322,7 +306,7 @@ export function Note(props: NoteProps) {
322
306
  // `setProperty` is the path it has no hook on.
323
307
  element.style.setProperty(`--${PREVIEW_X}`, `${spot.x}px`);
324
308
  element.style.setProperty(`--${PREVIEW_Y}`, `${spot.y}px`);
325
- }, [preview, card]);
309
+ }, [preview, card, previewId]);
326
310
 
327
311
  if (artifactPath !== null) {
328
312
  const artifact = props.graph?.model.nodes.find((node) => node.id === props.selectedId);
@@ -360,7 +344,8 @@ export function Note(props: NoteProps) {
360
344
  view={header}
361
345
  open={() => props.onOpen(note.slug)}
362
346
  editing={draft !== null}
363
- saving={saving}
347
+ saving={draft?.saving ?? false}
348
+ taskSaving={taskSaving}
364
349
  onToggle={toggleEdit}
365
350
  onSave={() => void save()}
366
351
  />
@@ -368,9 +353,9 @@ export function Note(props: NoteProps) {
368
353
  <textarea
369
354
  class="weave-note-editor"
370
355
  aria-label={EDITOR_ARIA_LABEL}
371
- spellcheck
372
- value={draft}
373
- onInput={(event) => setDraft((event.target as HTMLTextAreaElement).value)}
356
+ spellcheck={props.spellcheck ?? true}
357
+ value={draft.body}
358
+ onInput={(event) => drafts.edit(note.slug, (event.target as HTMLTextAreaElement).value)}
374
359
  onKeyDown={(event) => {
375
360
  // Bound here, not in `keys.model.ts`: a global binding is a
376
361
  // workspace-wide claim, and Escape must not reach the shell.
@@ -429,7 +414,7 @@ export function Note(props: NoteProps) {
429
414
  if (hasTextSelection(selection)) return;
430
415
  const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
431
416
  // A wikilink carries no href, so route it onto the context bus.
432
- if (target !== null) props.onSelect(target);
417
+ if (target !== null) props.onSelect(target, event.metaKey || event.ctrlKey || event.shiftKey);
433
418
  }}
434
419
  onKeyDown={(event) => {
435
420
  // Escape is first so the card closes on the gesture a keyboard
@@ -446,7 +431,7 @@ export function Note(props: NoteProps) {
446
431
  const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
447
432
  if (target === null) return;
448
433
  event.preventDefault();
449
- props.onSelect(target);
434
+ props.onSelect(target, event.metaKey || event.ctrlKey || event.shiftKey);
450
435
  }}
451
436
  // Sanitised by `renderNote`'s three layers — see note.model.ts.
452
437
  dangerouslySetInnerHTML={{ __html: html }}
@@ -455,7 +440,7 @@ export function Note(props: NoteProps) {
455
440
  {card === null ? null : (
456
441
  <div
457
442
  ref={cardRef}
458
- id={PREVIEW_ID}
443
+ id={previewId}
459
444
  role="tooltip"
460
445
  class={`weave-preview${card.ghost ? " weave-preview-ghost" : ""}`}
461
446
  >