pi-weave 0.3.0 → 0.3.1
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 +27 -16
- package/package.json +1 -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 +210 -178
- package/src/web/client/graph/Graph.tsx +18 -0
- package/src/web/client/graph/column.model.ts +19 -2
- package/src/web/client/graph/graph.model.ts +1 -1
- package/src/web/client/graph/positions.ts +1 -1
- package/src/web/client/graph/renderer.ts +9 -1
- package/src/web/client/note/Note.tsx +66 -91
- 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/Shell.tsx +371 -254
- package/src/web/client/shell/StatusBar.tsx +1 -1
- package/src/web/client/shell/focus.model.ts +2 -2
- package/src/web/client/shell/keys.model.ts +26 -31
- package/src/web/client/shell/keys.ts +1 -0
- package/src/web/client/shell/shell.model.ts +31 -107
- package/src/web/client/shell/theme.model.ts +4 -4
- package/src/web/client/shell/theme.ts +12 -93
- package/src/web/client/shell/workspace.css.ts +117 -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/routes.ts +34 -3
- package/src/web/server/server.ts +5 -0
- package/src/web/server/workspace-state.ts +121 -0
- package/src/web/shared/workspace.ts +270 -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
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
graphClick,
|
|
43
43
|
graphColumnModel,
|
|
44
44
|
graphCountLabel,
|
|
45
|
+
graphPreview,
|
|
45
46
|
hoverHighlight,
|
|
46
47
|
} from "./column.model";
|
|
47
48
|
import type { PositionStorage } from "./positions";
|
|
@@ -54,11 +55,14 @@ import type { GraphSimulation } from "./dynamics";
|
|
|
54
55
|
import { ForceTuner } from "./ForceTuner";
|
|
55
56
|
import { POSITIONS_STORAGE_KEY } from "./positions";
|
|
56
57
|
import { loadGroupColors, saveGroupColors } from "./tuner.model";
|
|
58
|
+
import { Icon } from "../tree/Tree";
|
|
57
59
|
|
|
58
60
|
export interface GraphProps {
|
|
59
61
|
graph: GraphPayload | null;
|
|
60
62
|
selectedId: string | null;
|
|
61
63
|
onSelect: (id: string | null) => void;
|
|
64
|
+
previewId: string | null;
|
|
65
|
+
onOpen: (id: string) => void;
|
|
62
66
|
/** Injected: `createSigmaRenderer` in the browser, a fake in a test. */
|
|
63
67
|
renderer: RendererFactory;
|
|
64
68
|
/** Injected: `localStorage`. See `positions.ts` for why it is a port. */
|
|
@@ -170,6 +174,7 @@ export function Graph(props: GraphProps) {
|
|
|
170
174
|
() => graphColumnModel(props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, groupColors),
|
|
171
175
|
[props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, forceRev, groupColors],
|
|
172
176
|
);
|
|
177
|
+
const preview = graphPreview(props.graph, props.previewId);
|
|
173
178
|
|
|
174
179
|
// Read by the mount-time `onSelect`, which outlives this render.
|
|
175
180
|
const live = useRef({ view, model, onSelect: props.onSelect, selectedId: props.selectedId });
|
|
@@ -276,6 +281,19 @@ export function Graph(props: GraphProps) {
|
|
|
276
281
|
{/* `tabIndex={-1}` is the `⌘3` focus target — see `Note.tsx`'s matching
|
|
277
282
|
comment. The tree's target is the rows `<ul>`, which has its own. */}
|
|
278
283
|
<div class="weave-graph-canvas" ref={canvas} role="img" aria-label="Knowledge graph" tabIndex={-1} />
|
|
284
|
+
{preview === null ? null : (
|
|
285
|
+
<aside class="weave-graph-preview" aria-label="Selected graph node">
|
|
286
|
+
<div class="weave-graph-preview-info">
|
|
287
|
+
<span class="weave-kind" aria-hidden="true"><Icon name={preview.icon} class="weave-icon" /></span>
|
|
288
|
+
<div class="weave-graph-preview-copy">
|
|
289
|
+
<span class="weave-graph-preview-kind">{preview.kind}</span>
|
|
290
|
+
<strong class="weave-graph-preview-title" title={preview.title}>{preview.title}</strong>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
<button type="button" class="weave-graph-preview-close" aria-label="Clear graph selection" onClick={() => props.onSelect(null)}>×</button>
|
|
294
|
+
<button type="button" class="weave-chip" onClick={() => props.onOpen(preview.id)}>Open in new tab</button>
|
|
295
|
+
</aside>
|
|
296
|
+
)}
|
|
279
297
|
{tunerOpen ? (
|
|
280
298
|
<ForceTuner
|
|
281
299
|
groupColors={groupColors}
|
|
@@ -43,7 +43,8 @@ import type { Point } from "../../shared/layout";
|
|
|
43
43
|
import type { ClusterAggregate, ClusterInfo, ViewGraphModel } from "../../shared/view";
|
|
44
44
|
import { clusterAggregate, focusNeighborhood } from "../../shared/view";
|
|
45
45
|
import type { GraphPayload, WireGraphEdge } from "../../shared/wire";
|
|
46
|
-
import { viewModel } from "../tree/tree.model";
|
|
46
|
+
import { kindIcon, viewModel } from "../tree/tree.model";
|
|
47
|
+
import type { IconName } from "../shell/icons.model";
|
|
47
48
|
import type { ColorScheme, RenderGraph } from "./graph.model";
|
|
48
49
|
import { EMPTY_RENDER_GRAPH, renderGraph } from "./graph.model";
|
|
49
50
|
import { groupNodeColors } from "./groups";
|
|
@@ -82,6 +83,22 @@ export interface GraphViewState {
|
|
|
82
83
|
readonly expanded: ReadonlySet<string>;
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
export interface GraphPreview {
|
|
87
|
+
readonly id: string;
|
|
88
|
+
readonly title: string;
|
|
89
|
+
readonly kind: string;
|
|
90
|
+
readonly icon: IconName;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The original graph node selected for preview, including collapsed folders. */
|
|
94
|
+
export function graphPreview(payload: GraphPayload | null, id: string | null): GraphPreview | null {
|
|
95
|
+
if (payload === null || id === null) return null;
|
|
96
|
+
const node = payload.model.nodes.find((candidate) => candidate.id === id);
|
|
97
|
+
if (node === undefined) return null;
|
|
98
|
+
const kind = id.startsWith("artifact:") ? "HTML artifact" : id.startsWith("vfolder:") ? "Folder" : node.kind;
|
|
99
|
+
return { id, title: node.label, kind, icon: kindIcon(node.kind) };
|
|
100
|
+
}
|
|
101
|
+
|
|
85
102
|
/**
|
|
86
103
|
* The state a freshly loaded graph opens in: **fully expanded**.
|
|
87
104
|
*
|
|
@@ -169,7 +186,7 @@ export function highlightFor(edges: readonly WireGraphEdge[], selectedId: string
|
|
|
169
186
|
* The highlight while the pointer is over a node — **hover wins, selection is
|
|
170
187
|
* what it falls back to**.
|
|
171
188
|
*
|
|
172
|
-
*
|
|
189
|
+
* The reference interaction, and the reason it is the same function as
|
|
173
190
|
* {@link highlightFor} rather than a second visual language: hovering asks
|
|
174
191
|
* exactly the question clicking asks ("what is one hop from here?"), so the
|
|
175
192
|
* two must produce the same set and reach the same reducers. Anything else
|
|
@@ -443,7 +443,7 @@ export const HOVER_LABEL_SHADOW = 6;
|
|
|
443
443
|
/**
|
|
444
444
|
* Sigma's `defaultDrawNodeHover`, as a scheme-bound pure function (§7.4).
|
|
445
445
|
*
|
|
446
|
-
*
|
|
446
|
+
* The reference gesture: the hovered node's name floats
|
|
447
447
|
* **centred underneath it**, in the theme's own text colour, with no
|
|
448
448
|
* container. The alternative shipped by sigma is a white rounded box with the
|
|
449
449
|
* text to the node's *right* — wrong colour in both schemes, and wrong place
|
|
@@ -175,7 +175,7 @@ export function graphShapeKey(nodes: readonly WireGraphNode[], edges: readonly W
|
|
|
175
175
|
* watcher fires, the graph refetches, and a picture the user was reading
|
|
176
176
|
* rearranges itself for no visible reason. Never re-running leaves new nodes
|
|
177
177
|
* unplaced and therefore undrawn (`renderGraph` drops a node with no
|
|
178
|
-
* position), so a note created
|
|
178
|
+
* position), so a note created outside this application would never appear.
|
|
179
179
|
*
|
|
180
180
|
* The shape key is exactly the line between those: it changes iff a node or an
|
|
181
181
|
* edge was added or removed, which is precisely when the existing positions
|
|
@@ -44,6 +44,7 @@ export type RendererFactory = (scheme: ColorScheme) => GraphRenderer;
|
|
|
44
44
|
*/
|
|
45
45
|
export interface SigmaLike {
|
|
46
46
|
on(event: "clickNode", handler: (payload: { node: string }) => void): unknown;
|
|
47
|
+
on(event: "doubleClickNode", handler: (payload: { node: string; preventSigmaDefault(): void }) => void): unknown;
|
|
47
48
|
on(event: "clickStage", handler: () => void): unknown;
|
|
48
49
|
on(event: "downNode", handler: (payload: { node: string }) => void): unknown;
|
|
49
50
|
on(event: "enterNode", handler: (payload: { node: string }) => void): unknown;
|
|
@@ -146,6 +147,7 @@ export function sigmaRenderer(
|
|
|
146
147
|
let dragMove: (id: string, at: Point) => void = () => {};
|
|
147
148
|
let dragEnd: (id: string) => void = () => {};
|
|
148
149
|
let dragging: string | null = null;
|
|
150
|
+
let nodeMoved = false;
|
|
149
151
|
|
|
150
152
|
const endDrag = (id: string | null): void => {
|
|
151
153
|
if (dragging === null) return;
|
|
@@ -208,16 +210,22 @@ export function sigmaRenderer(
|
|
|
208
210
|
const instance = create(graph, container, graphSettings(scheme));
|
|
209
211
|
// §1.3's context bus: a click writes `selectedId`, and the note column,
|
|
210
212
|
// the tree and the context rail all recompute from it.
|
|
211
|
-
instance.on("clickNode", ({ node }) => select(node));
|
|
213
|
+
instance.on("clickNode", ({ node }) => { if (!nodeMoved) select(node); });
|
|
214
|
+
instance.on("doubleClickNode", ({ node, preventSigmaDefault }) => {
|
|
215
|
+
preventSigmaDefault();
|
|
216
|
+
if (!nodeMoved) select(node);
|
|
217
|
+
});
|
|
212
218
|
instance.on("clickStage", () => select(null));
|
|
213
219
|
instance.on("downNode", ({ node }) => {
|
|
214
220
|
dragging = node;
|
|
221
|
+
nodeMoved = false;
|
|
215
222
|
// Hold the view still while the node follows the cursor (see endDrag).
|
|
216
223
|
sigma?.setSetting("enableCameraPanning", false);
|
|
217
224
|
dragStart(node);
|
|
218
225
|
});
|
|
219
226
|
instance.on("moveBody", (payload) => {
|
|
220
227
|
if (dragging !== null) {
|
|
228
|
+
nodeMoved = true;
|
|
221
229
|
// Sigma's captor pans the camera on every mouse move while the
|
|
222
230
|
// button is down; during a node drag that is the pan the gesture
|
|
223
231
|
// must not be. `preventSigmaDefault` is the captor's own gate — it
|
|
@@ -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
|
|
@@ -90,7 +88,7 @@ export interface NoteProps {
|
|
|
90
88
|
loadFailed: boolean;
|
|
91
89
|
graph: GraphPayload | null;
|
|
92
90
|
selectedId: string | null;
|
|
93
|
-
onSelect: (id: string) => void;
|
|
91
|
+
onSelect: (id: string, newTab?: boolean) => void;
|
|
94
92
|
onOpen: (slug: string) => void;
|
|
95
93
|
/**
|
|
96
94
|
* Save the note's body. Resolves `true` when the write landed; a `false`
|
|
@@ -98,12 +96,12 @@ export interface NoteProps {
|
|
|
98
96
|
* error-reporting gesture the client has.
|
|
99
97
|
*/
|
|
100
98
|
onSave: (slug: string, body: string) => Promise<boolean>;
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
99
|
+
/** Shared across Note instances so duplicate tabs edit one in-memory body. */
|
|
100
|
+
drafts?: DraftStore;
|
|
101
|
+
/** Stable per view; distinct when the same note is mounted more than once. */
|
|
102
|
+
idPrefix?: string;
|
|
103
|
+
/** Monotonic identity of the document load that produced `note`. */
|
|
104
|
+
loadVersion: number;
|
|
107
105
|
/** Epoch ms for relative times. Injected so the render is deterministic. */
|
|
108
106
|
now: number;
|
|
109
107
|
}
|
|
@@ -121,6 +119,7 @@ function Header({
|
|
|
121
119
|
open,
|
|
122
120
|
editing,
|
|
123
121
|
saving,
|
|
122
|
+
taskSaving,
|
|
124
123
|
onToggle,
|
|
125
124
|
onSave,
|
|
126
125
|
}: {
|
|
@@ -128,6 +127,7 @@ function Header({
|
|
|
128
127
|
open: () => void;
|
|
129
128
|
editing: boolean;
|
|
130
129
|
saving: boolean;
|
|
130
|
+
taskSaving: boolean;
|
|
131
131
|
onToggle: () => void;
|
|
132
132
|
onSave: () => void;
|
|
133
133
|
}) {
|
|
@@ -137,12 +137,12 @@ function Header({
|
|
|
137
137
|
<p class="weave-note-meta">
|
|
138
138
|
{editing ? (
|
|
139
139
|
<>
|
|
140
|
-
<button type="button" class="weave-note-save" disabled={saving} onClick={onSave}>
|
|
140
|
+
<button type="button" class="weave-note-save" disabled={saving || taskSaving} onClick={onSave}>
|
|
141
141
|
{saving ? SAVING_LABEL : SAVE_LABEL}
|
|
142
142
|
</button>
|
|
143
|
-
<button type="button" class="weave-note-edit" onClick={onToggle}>{DONE_LABEL}</button>
|
|
143
|
+
<button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{DONE_LABEL}</button>
|
|
144
144
|
</>
|
|
145
|
-
) : <button type="button" class="weave-note-edit" onClick={onToggle}>{EDIT_LABEL}</button>}
|
|
145
|
+
) : <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{EDIT_LABEL}</button>}
|
|
146
146
|
<button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
|
|
147
147
|
<span class="weave-note-open-mark" aria-hidden="true">↗</span>
|
|
148
148
|
</button>
|
|
@@ -168,7 +168,14 @@ function Header({
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
export function Note(props: NoteProps) {
|
|
171
|
+
const localDrafts = useRef(createDraftStore());
|
|
172
|
+
const drafts = props.drafts ?? localDrafts.current;
|
|
173
|
+
const generatedId = useId();
|
|
174
|
+
const previewId = `${props.idPrefix ?? generatedId}-weave-preview`;
|
|
175
|
+
const [storeVersion, setDraftVersion] = useState(0);
|
|
176
|
+
useEffect(() => drafts.subscribe(() => setDraftVersion((version) => version + 1)), [drafts]);
|
|
171
177
|
const note = props.note?.note ?? null;
|
|
178
|
+
const body = note === null ? "" : drafts.getTaskBody(note.slug, note.body);
|
|
172
179
|
const artifactPath = selectedArtifactPath(props.graph, props.selectedId);
|
|
173
180
|
const empty = noteEmptyMessage(props.selectedId, note, props.loadFailed);
|
|
174
181
|
|
|
@@ -180,8 +187,8 @@ export function Note(props: NoteProps) {
|
|
|
180
187
|
// only the *result* is memoized.
|
|
181
188
|
const index = useMemo(() => (note === null ? null : wikiIndex(props.graph, note.slug)), [note, props.graph]);
|
|
182
189
|
const html = useMemo(
|
|
183
|
-
() => (note === null || index === null ? "" : renderNote(DOMPurify,
|
|
184
|
-
[note, index],
|
|
190
|
+
() => (note === null || index === null ? "" : renderNote(DOMPurify, body, index)),
|
|
191
|
+
[note, body, index],
|
|
185
192
|
);
|
|
186
193
|
|
|
187
194
|
// The hover card. The reducer lives in `note.model.ts`; this is its
|
|
@@ -192,90 +199,51 @@ export function Note(props: NoteProps) {
|
|
|
192
199
|
const dispatch = (event: PreviewEvent): void => void sendPreview((state) => reducePreview(state, event));
|
|
193
200
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
194
201
|
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
|
-
|
|
202
|
+
const draft = note === null ? null : drafts.get(note.slug);
|
|
203
|
+
const taskSaving = note !== null && drafts.isTaskSaving(note.slug);
|
|
229
204
|
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();
|
|
205
|
+
if (note !== null && !drafts.isTaskSaving(note.slug)) await drafts.save(note.slug, props.onSave);
|
|
243
206
|
};
|
|
244
207
|
|
|
245
208
|
const saveTask = async (checkbox: HTMLInputElement, taskIndex: number): Promise<void> => {
|
|
246
|
-
const source = taskSource.current;
|
|
247
209
|
const restore = (): void => {
|
|
248
210
|
checkbox.checked = !checkbox.checked;
|
|
249
211
|
checkbox.setAttribute("aria-label", taskCheckboxLabel(checkbox.checked));
|
|
250
212
|
};
|
|
251
|
-
if (
|
|
213
|
+
if (note === null || drafts.isTaskSaving(note.slug)) {
|
|
252
214
|
restore();
|
|
253
215
|
return;
|
|
254
216
|
}
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
217
|
+
const nextBody = toggleTaskCheckbox(drafts.getTaskBody(note.slug, note.body), taskIndex);
|
|
218
|
+
if (nextBody === null) {
|
|
257
219
|
restore();
|
|
258
220
|
return;
|
|
259
221
|
}
|
|
260
222
|
|
|
261
223
|
const inputs = [...(bodyRef.current?.querySelectorAll<HTMLInputElement>(`input[${TASK_CHECKBOX_ATTR}]`) ?? [])];
|
|
262
|
-
taskSaving.current = true;
|
|
263
224
|
checkbox.setAttribute("aria-label", taskCheckboxLabel(checkbox.checked));
|
|
264
225
|
for (const input of inputs) input.disabled = true;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
226
|
+
try {
|
|
227
|
+
if (!await drafts.saveTask(note.slug, nextBody, props.onSave)) restore();
|
|
228
|
+
} catch (error) {
|
|
229
|
+
restore();
|
|
230
|
+
throw error;
|
|
231
|
+
} finally {
|
|
232
|
+
for (const input of inputs) input.disabled = drafts.isTaskSaving(note.slug);
|
|
233
|
+
}
|
|
270
234
|
};
|
|
271
235
|
|
|
272
236
|
const toggleEdit = (): void => {
|
|
273
|
-
if (note === null) return;
|
|
274
|
-
if (
|
|
275
|
-
else if (!
|
|
237
|
+
if (note === null || drafts.isTaskSaving(note.slug)) return;
|
|
238
|
+
if (drafts.get(note.slug) === null) drafts.open(note.slug, body);
|
|
239
|
+
else if (!drafts.isDirty(note.slug) || window.confirm(DISCARD_PROMPT)) drafts.discard(note.slug);
|
|
276
240
|
};
|
|
277
241
|
const card = preview.anchor === null ? null : previewCard(props.graph, preview.anchor);
|
|
278
242
|
|
|
243
|
+
useLayoutEffect(() => {
|
|
244
|
+
if (note !== null) drafts.taskBody(note.slug, note.body, props.loadVersion);
|
|
245
|
+
}, [drafts, note === null ? null : note.slug, note?.body, props.loadVersion]);
|
|
246
|
+
|
|
279
247
|
// A card whose target note left the screen is a claim about a document that
|
|
280
248
|
// is gone. Navigating (or an SSE swap of the open note, which is what
|
|
281
249
|
// `note.slug` changing underneath the pointer looks like) closes it rather
|
|
@@ -284,12 +252,18 @@ export function Note(props: NoteProps) {
|
|
|
284
252
|
// slug changes as rarely as navigation happens.
|
|
285
253
|
useLayoutEffect(() => {
|
|
286
254
|
dispatch({ type: "hide" });
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
// what asks before a dirty draft gets here.
|
|
290
|
-
close();
|
|
255
|
+
// Drafts belong to note identity and survive this Note instance changing
|
|
256
|
+
// documents or unmounting.
|
|
291
257
|
}, [note === null ? null : note.slug]);
|
|
292
258
|
|
|
259
|
+
useLayoutEffect(() => {
|
|
260
|
+
if (note === null) return;
|
|
261
|
+
const saving = drafts.isTaskSaving(note.slug);
|
|
262
|
+
for (const input of bodyRef.current?.querySelectorAll<HTMLInputElement>(`input[${TASK_CHECKBOX_ATTR}]`) ?? []) {
|
|
263
|
+
input.disabled = saving;
|
|
264
|
+
}
|
|
265
|
+
}, [note === null ? null : note.slug, storeVersion, html]);
|
|
266
|
+
|
|
293
267
|
// Wired after the card mounts, because both jobs depend on the live DOM:
|
|
294
268
|
// the card's size (the placement decision needs measured dimensions, which
|
|
295
269
|
// no render can know), and `aria-describedby`, which the link has to carry
|
|
@@ -302,7 +276,7 @@ export function Note(props: NoteProps) {
|
|
|
302
276
|
if (body !== null) {
|
|
303
277
|
const slug = preview.anchor?.slug ?? null;
|
|
304
278
|
for (const link of body.querySelectorAll(`a[${WIKILINK_ATTR}]`)) {
|
|
305
|
-
if (slug !== null && link.getAttribute(WIKILINK_ATTR) === slug) link.setAttribute("aria-describedby",
|
|
279
|
+
if (slug !== null && link.getAttribute(WIKILINK_ATTR) === slug) link.setAttribute("aria-describedby", previewId);
|
|
306
280
|
else link.removeAttribute("aria-describedby");
|
|
307
281
|
}
|
|
308
282
|
}
|
|
@@ -322,7 +296,7 @@ export function Note(props: NoteProps) {
|
|
|
322
296
|
// `setProperty` is the path it has no hook on.
|
|
323
297
|
element.style.setProperty(`--${PREVIEW_X}`, `${spot.x}px`);
|
|
324
298
|
element.style.setProperty(`--${PREVIEW_Y}`, `${spot.y}px`);
|
|
325
|
-
}, [preview, card]);
|
|
299
|
+
}, [preview, card, previewId]);
|
|
326
300
|
|
|
327
301
|
if (artifactPath !== null) {
|
|
328
302
|
const artifact = props.graph?.model.nodes.find((node) => node.id === props.selectedId);
|
|
@@ -360,7 +334,8 @@ export function Note(props: NoteProps) {
|
|
|
360
334
|
view={header}
|
|
361
335
|
open={() => props.onOpen(note.slug)}
|
|
362
336
|
editing={draft !== null}
|
|
363
|
-
saving={saving}
|
|
337
|
+
saving={draft?.saving ?? false}
|
|
338
|
+
taskSaving={taskSaving}
|
|
364
339
|
onToggle={toggleEdit}
|
|
365
340
|
onSave={() => void save()}
|
|
366
341
|
/>
|
|
@@ -369,8 +344,8 @@ export function Note(props: NoteProps) {
|
|
|
369
344
|
class="weave-note-editor"
|
|
370
345
|
aria-label={EDITOR_ARIA_LABEL}
|
|
371
346
|
spellcheck
|
|
372
|
-
value={draft}
|
|
373
|
-
onInput={(event) =>
|
|
347
|
+
value={draft.body}
|
|
348
|
+
onInput={(event) => drafts.edit(note.slug, (event.target as HTMLTextAreaElement).value)}
|
|
374
349
|
onKeyDown={(event) => {
|
|
375
350
|
// Bound here, not in `keys.model.ts`: a global binding is a
|
|
376
351
|
// workspace-wide claim, and Escape must not reach the shell.
|
|
@@ -429,7 +404,7 @@ export function Note(props: NoteProps) {
|
|
|
429
404
|
if (hasTextSelection(selection)) return;
|
|
430
405
|
const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
|
|
431
406
|
// A wikilink carries no href, so route it onto the context bus.
|
|
432
|
-
if (target !== null) props.onSelect(target);
|
|
407
|
+
if (target !== null) props.onSelect(target, event.metaKey || event.ctrlKey || event.shiftKey);
|
|
433
408
|
}}
|
|
434
409
|
onKeyDown={(event) => {
|
|
435
410
|
// Escape is first so the card closes on the gesture a keyboard
|
|
@@ -446,7 +421,7 @@ export function Note(props: NoteProps) {
|
|
|
446
421
|
const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
|
|
447
422
|
if (target === null) return;
|
|
448
423
|
event.preventDefault();
|
|
449
|
-
props.onSelect(target);
|
|
424
|
+
props.onSelect(target, event.metaKey || event.ctrlKey || event.shiftKey);
|
|
450
425
|
}}
|
|
451
426
|
// Sanitised by `renderNote`'s three layers — see note.model.ts.
|
|
452
427
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
@@ -455,7 +430,7 @@ export function Note(props: NoteProps) {
|
|
|
455
430
|
{card === null ? null : (
|
|
456
431
|
<div
|
|
457
432
|
ref={cardRef}
|
|
458
|
-
id={
|
|
433
|
+
id={previewId}
|
|
459
434
|
role="tooltip"
|
|
460
435
|
class={`weave-preview${card.ghost ? " weave-preview-ghost" : ""}`}
|
|
461
436
|
>
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { tabSelection, type WorkspaceLayout } from "../../shared/workspace";
|
|
2
|
+
|
|
3
|
+
export interface NoteDraft {
|
|
4
|
+
body: string;
|
|
5
|
+
baseline: string;
|
|
6
|
+
saving: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface TaskSource {
|
|
10
|
+
body: string;
|
|
11
|
+
version: number;
|
|
12
|
+
pending?: { body: string; version: number };
|
|
13
|
+
saving: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DraftStore {
|
|
17
|
+
get(slug: string): Readonly<NoteDraft> | null;
|
|
18
|
+
subscribe(listener: () => void): () => void;
|
|
19
|
+
open(slug: string, body: string): void;
|
|
20
|
+
edit(slug: string, body: string): void;
|
|
21
|
+
discard(slug: string): void;
|
|
22
|
+
isDirty(slug: string): boolean;
|
|
23
|
+
dirtySlugs(): string[];
|
|
24
|
+
save(slug: string, onSave: (slug: string, body: string) => Promise<boolean>): Promise<void>;
|
|
25
|
+
nextLoadVersion(): number;
|
|
26
|
+
getTaskBody(slug: string, fallback: string): string;
|
|
27
|
+
taskBody(slug: string, serverBody: string, loadVersion: number): string;
|
|
28
|
+
isTaskSaving(slug: string): boolean;
|
|
29
|
+
saveTask(slug: string, body: string, onSave: (slug: string, body: string) => Promise<boolean>): Promise<boolean>;
|
|
30
|
+
clearTaskSources(slugs: readonly string[]): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createDraftStore(): DraftStore {
|
|
34
|
+
const drafts = new Map<string, NoteDraft>();
|
|
35
|
+
const taskSources = new Map<string, TaskSource>();
|
|
36
|
+
const listeners = new Set<() => void>();
|
|
37
|
+
let loadVersion = 0;
|
|
38
|
+
const notify = (): void => listeners.forEach((listener) => listener());
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
get: (slug) => drafts.get(slug) ?? null,
|
|
42
|
+
subscribe(listener) {
|
|
43
|
+
listeners.add(listener);
|
|
44
|
+
return () => listeners.delete(listener);
|
|
45
|
+
},
|
|
46
|
+
open(slug, body) {
|
|
47
|
+
if (drafts.has(slug)) return;
|
|
48
|
+
drafts.set(slug, { body, baseline: body, saving: false });
|
|
49
|
+
notify();
|
|
50
|
+
},
|
|
51
|
+
edit(slug, body) {
|
|
52
|
+
const draft = drafts.get(slug);
|
|
53
|
+
if (draft === undefined || draft.body === body) return;
|
|
54
|
+
draft.body = body;
|
|
55
|
+
notify();
|
|
56
|
+
},
|
|
57
|
+
discard(slug) {
|
|
58
|
+
if (drafts.delete(slug)) notify();
|
|
59
|
+
},
|
|
60
|
+
isDirty(slug) {
|
|
61
|
+
const draft = drafts.get(slug);
|
|
62
|
+
return draft !== undefined && draft.body !== draft.baseline;
|
|
63
|
+
},
|
|
64
|
+
dirtySlugs() {
|
|
65
|
+
return [...drafts].filter(([, draft]) => draft.body !== draft.baseline).map(([slug]) => slug);
|
|
66
|
+
},
|
|
67
|
+
async save(slug, onSave) {
|
|
68
|
+
const draft = drafts.get(slug);
|
|
69
|
+
if (draft === undefined || draft.saving || taskSources.get(slug)?.saving) return;
|
|
70
|
+
const sent = draft.body;
|
|
71
|
+
draft.saving = true;
|
|
72
|
+
notify();
|
|
73
|
+
let ok = false;
|
|
74
|
+
try {
|
|
75
|
+
ok = await onSave(slug, sent);
|
|
76
|
+
} finally {
|
|
77
|
+
// Discard/reopen during an in-flight request gives the new draft a new identity.
|
|
78
|
+
if (drafts.get(slug) === draft) {
|
|
79
|
+
const source = taskSources.get(slug);
|
|
80
|
+
if (ok && source !== undefined) {
|
|
81
|
+
source.body = sent;
|
|
82
|
+
source.version = ++loadVersion;
|
|
83
|
+
delete source.pending;
|
|
84
|
+
}
|
|
85
|
+
if (ok && draft.body === sent) drafts.delete(slug);
|
|
86
|
+
else {
|
|
87
|
+
if (ok) draft.baseline = sent;
|
|
88
|
+
draft.saving = false;
|
|
89
|
+
}
|
|
90
|
+
notify();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
nextLoadVersion() {
|
|
95
|
+
return ++loadVersion;
|
|
96
|
+
},
|
|
97
|
+
getTaskBody(slug, fallback) {
|
|
98
|
+
return taskSources.get(slug)?.body ?? fallback;
|
|
99
|
+
},
|
|
100
|
+
taskBody(slug, serverBody, version) {
|
|
101
|
+
let source = taskSources.get(slug);
|
|
102
|
+
let changed = false;
|
|
103
|
+
if (source === undefined) {
|
|
104
|
+
source = { body: serverBody, version, saving: false };
|
|
105
|
+
taskSources.set(slug, source);
|
|
106
|
+
} else if (version > source.version) {
|
|
107
|
+
if (source.saving) {
|
|
108
|
+
if (source.pending === undefined || version > source.pending.version) source.pending = { body: serverBody, version };
|
|
109
|
+
} else {
|
|
110
|
+
changed = source.body !== serverBody;
|
|
111
|
+
source.body = serverBody;
|
|
112
|
+
source.version = version;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (changed) notify();
|
|
116
|
+
return source.body;
|
|
117
|
+
},
|
|
118
|
+
isTaskSaving: (slug) => taskSources.get(slug)?.saving ?? false,
|
|
119
|
+
async saveTask(slug, body, onSave) {
|
|
120
|
+
const source = taskSources.get(slug);
|
|
121
|
+
if (source === undefined || source.saving || drafts.get(slug)?.saving) return false;
|
|
122
|
+
source.saving = true;
|
|
123
|
+
notify();
|
|
124
|
+
let ok = false;
|
|
125
|
+
try {
|
|
126
|
+
ok = await onSave(slug, body);
|
|
127
|
+
return ok;
|
|
128
|
+
} finally {
|
|
129
|
+
if (taskSources.get(slug) === source) {
|
|
130
|
+
if (ok) {
|
|
131
|
+
source.body = body;
|
|
132
|
+
// Invalidate note loads that began before this save completed.
|
|
133
|
+
source.version = ++loadVersion;
|
|
134
|
+
} else if (source.pending !== undefined && source.pending.version > source.version) {
|
|
135
|
+
source.body = source.pending.body;
|
|
136
|
+
source.version = source.pending.version;
|
|
137
|
+
}
|
|
138
|
+
delete source.pending;
|
|
139
|
+
source.saving = false;
|
|
140
|
+
notify();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
clearTaskSources(slugs) {
|
|
145
|
+
let changed = false;
|
|
146
|
+
for (const slug of slugs) changed = taskSources.delete(slug) || changed;
|
|
147
|
+
if (changed) notify();
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Removing the last selected view of a dirty note requires deliberate discard. */
|
|
153
|
+
export function mayRemoveDrafts(drafts: DraftStore, current: WorkspaceLayout, next: WorkspaceLayout, confirm: () => boolean): boolean {
|
|
154
|
+
const before = new Set(current.panes.flatMap((pane) => pane.tabs.map(tabSelection)));
|
|
155
|
+
const after = new Set(next.panes.flatMap((pane) => pane.tabs.map(tabSelection)));
|
|
156
|
+
const removed = [...before].filter((id): id is string => id !== null && id.startsWith("note:") && !after.has(id))
|
|
157
|
+
.map((id) => id.slice(5));
|
|
158
|
+
if (removed.some((slug) => drafts.isDirty(slug)) && !confirm()) return false;
|
|
159
|
+
removed.forEach((slug) => drafts.discard(slug));
|
|
160
|
+
drafts.clearTaskSources(removed);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Confirm only dirty drafts affected by a tree mutation, then clear affected state. */
|
|
165
|
+
export function mayMutateDrafts(drafts: DraftStore, affectedSlugs: readonly string[], confirm: () => boolean): boolean {
|
|
166
|
+
const affected = new Set(affectedSlugs);
|
|
167
|
+
const dirty = drafts.dirtySlugs().filter((slug) => affected.has(slug));
|
|
168
|
+
if (dirty.length > 0 && !confirm()) return false;
|
|
169
|
+
affected.forEach((slug) => drafts.discard(slug));
|
|
170
|
+
drafts.clearTaskSources([...affected]);
|
|
171
|
+
return true;
|
|
172
|
+
}
|