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.
- package/README.md +40 -16
- package/package.json +2 -2
- package/src/core/frontmatter.ts +3 -3
- package/src/core/graph/model.ts +2 -2
- package/src/core/graph/wikilinks.ts +1 -1
- package/src/core/types.ts +1 -1
- package/src/pi/viewer/tui/surface/detail.ts +1 -1
- package/src/web/client/dist/app.js +239 -243
- package/src/web/client/graph/ForceTuner.tsx +14 -98
- package/src/web/client/graph/Graph.tsx +29 -74
- package/src/web/client/graph/column.model.ts +22 -15
- package/src/web/client/graph/graph.model.ts +31 -63
- package/src/web/client/graph/groups.ts +9 -7
- package/src/web/client/graph/positions.ts +3 -3
- package/src/web/client/graph/renderer.dom.ts +2 -2
- package/src/web/client/graph/renderer.ts +14 -5
- package/src/web/client/graph/tuner.model.ts +6 -180
- package/src/web/client/main.tsx +1 -4
- package/src/web/client/note/Note.tsx +77 -92
- package/src/web/client/note/drafts.ts +172 -0
- package/src/web/client/note/note.model.ts +2 -34
- package/src/web/client/search/SearchPalette.tsx +9 -8
- package/src/web/client/shell/ContextRail.tsx +8 -9
- package/src/web/client/shell/Settings.tsx +71 -0
- package/src/web/client/shell/Shell.tsx +409 -255
- package/src/web/client/shell/StatusBar.tsx +2 -6
- package/src/web/client/shell/focus.model.ts +2 -2
- package/src/web/client/shell/keys.model.ts +40 -39
- package/src/web/client/shell/keys.ts +1 -0
- package/src/web/client/shell/shell.model.ts +32 -128
- package/src/web/client/shell/theme.model.ts +27 -97
- package/src/web/client/shell/theme.ts +32 -238
- package/src/web/client/shell/workspace.css.ts +140 -0
- package/src/web/client/tree/Tree.tsx +43 -14
- package/src/web/client/tree/tree.model.ts +19 -0
- package/src/web/client/workspace.ts +19 -1
- package/src/web/server/backup.ts +59 -0
- package/src/web/server/routes.ts +45 -3
- package/src/web/server/server.ts +5 -0
- package/src/web/server/workspace-state.ts +121 -0
- package/src/web/shared/layout.ts +5 -6
- package/src/web/shared/preferences.ts +76 -0
- package/src/web/shared/themes.ts +92 -0
- package/src/web/shared/workspace.ts +310 -0
- package/src/web/client/selection.storage.ts +0 -69
- package/src/web/client/shell/Columns.tsx +0 -139
- package/src/web/client/shell/Divider.tsx +0 -15
- package/src/web/client/shell/Header.tsx +0 -119
- package/src/web/client/shell/drag.model.ts +0 -35
- 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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
}
|
package/src/web/client/main.tsx
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
15
|
-
* the shell guards
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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,
|
|
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
|
|
196
|
-
const
|
|
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
|
|
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 (
|
|
215
|
+
if (note === null || drafts.isTaskSaving(note.slug)) {
|
|
252
216
|
restore();
|
|
253
217
|
return;
|
|
254
218
|
}
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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 (
|
|
275
|
-
else if (!
|
|
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
|
-
//
|
|
288
|
-
//
|
|
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",
|
|
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) =>
|
|
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={
|
|
443
|
+
id={previewId}
|
|
459
444
|
role="tooltip"
|
|
460
445
|
class={`weave-preview${card.ghost ? " weave-preview-ghost" : ""}`}
|
|
461
446
|
>
|