pi-weave 0.2.0 → 0.2.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 +7 -3
- package/package.json +1 -1
- package/src/core/index.ts +1 -0
- package/src/core/links/repair.ts +2 -2
- package/src/core/vault.ts +46 -1
- package/src/web/client/api.ts +6 -0
- package/src/web/client/dist/app.js +107 -83
- package/src/web/client/note/Note.tsx +141 -15
- package/src/web/client/note/note.model.ts +53 -1
- package/src/web/client/shell/Columns.tsx +14 -1
- package/src/web/client/shell/Shell.tsx +57 -5
- package/src/web/client/shell/theme.ts +24 -0
- package/src/web/client/state.ts +2 -1
- package/src/web/client/tree/Tree.tsx +11 -1
- package/src/web/client/workspace.ts +4 -3
- package/src/web/server/routes.ts +18 -6
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The note column — reading, and editing (weave-workspace §1.2, P2.4, P5, P6.3).
|
|
3
3
|
*
|
|
4
4
|
* Props in, JSX out. The markdown pipeline, sanitiser config, wikilink
|
|
5
5
|
* resolution and every string are in `note.model.ts`. This file wires the
|
|
6
|
-
* rendered body, selection-safe wikilink navigation,
|
|
7
|
-
*
|
|
6
|
+
* rendered body, selection-safe wikilink navigation, the Open in `$EDITOR`
|
|
7
|
+
* action, and the body editor.
|
|
8
|
+
*
|
|
9
|
+
* ## The editor
|
|
10
|
+
*
|
|
11
|
+
* Clicking the prose swaps it for a `<textarea>`; `draft` is `null` in read
|
|
12
|
+
* mode, so text and mode cannot disagree. No reducer — the 686-line one that
|
|
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.
|
|
8
16
|
*
|
|
9
17
|
* `dangerouslySetInnerHTML` is used deliberately for sanitized note HTML. The
|
|
10
18
|
* alternative is parsing marked's output into a Preact tree, which means a
|
|
@@ -31,16 +39,25 @@
|
|
|
31
39
|
*/
|
|
32
40
|
|
|
33
41
|
import DOMPurify from "dompurify";
|
|
34
|
-
import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
42
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
35
43
|
import type { GraphPayload, NotePayload } from "../../shared/wire";
|
|
36
44
|
import {
|
|
37
45
|
CREATED_WORD,
|
|
38
46
|
artifactKeyOfNode,
|
|
47
|
+
DISCARD_PROMPT,
|
|
48
|
+
DONE_LABEL,
|
|
39
49
|
EDITED_WORD,
|
|
50
|
+
EDITOR_ARIA_LABEL,
|
|
40
51
|
EMPTY_PREVIEW,
|
|
41
52
|
PREVIEW_ID,
|
|
53
|
+
SAVE_LABEL,
|
|
54
|
+
SAVING_LABEL,
|
|
42
55
|
WIKILINK_ATTR,
|
|
56
|
+
draftDirty,
|
|
57
|
+
draftMoved,
|
|
43
58
|
hasTextSelection,
|
|
59
|
+
noteClickAction,
|
|
60
|
+
saveLanded,
|
|
44
61
|
noteEmptyMessage,
|
|
45
62
|
noteHeader,
|
|
46
63
|
previewAnchorOf,
|
|
@@ -66,10 +83,23 @@ const PREVIEW_Y = "weave-preview-y";
|
|
|
66
83
|
|
|
67
84
|
export interface NoteProps {
|
|
68
85
|
note: NotePayload | null;
|
|
86
|
+
loadFailed: boolean;
|
|
69
87
|
graph: GraphPayload | null;
|
|
70
88
|
selectedId: string | null;
|
|
71
89
|
onSelect: (id: string) => void;
|
|
72
90
|
onOpen: (slug: string) => void;
|
|
91
|
+
/**
|
|
92
|
+
* Save the note's body. Resolves `true` when the write landed; a `false`
|
|
93
|
+
* has already been reported to the user by the shell, which owns the one
|
|
94
|
+
* error-reporting gesture the client has.
|
|
95
|
+
*/
|
|
96
|
+
onSave: (slug: string, body: string) => Promise<boolean>;
|
|
97
|
+
/**
|
|
98
|
+
* The shell's handle on the open editor — the `fit` pattern again. Guards
|
|
99
|
+
* both *ask* (navigate, unload) and *close* (a tree rename of this note).
|
|
100
|
+
* Functions, not values, or a mount-time guard answers "clean" forever.
|
|
101
|
+
*/
|
|
102
|
+
editor: { current: { dirty(): boolean; discard(): void } | null };
|
|
73
103
|
/** Epoch ms for relative times. Injected so the render is deterministic. */
|
|
74
104
|
now: number;
|
|
75
105
|
}
|
|
@@ -85,14 +115,32 @@ export interface NoteProps {
|
|
|
85
115
|
function Header({
|
|
86
116
|
view,
|
|
87
117
|
open,
|
|
118
|
+
editing,
|
|
119
|
+
saving,
|
|
120
|
+
onDone,
|
|
121
|
+
onSave,
|
|
88
122
|
}: {
|
|
89
123
|
view: NoteHeaderView;
|
|
90
124
|
open: () => void;
|
|
125
|
+
editing: boolean;
|
|
126
|
+
saving: boolean;
|
|
127
|
+
onDone: () => void;
|
|
128
|
+
onSave: () => void;
|
|
91
129
|
}) {
|
|
92
130
|
return (
|
|
93
131
|
<header class="weave-note-head">
|
|
94
132
|
<h3 class="weave-note-title">{view.title}</h3>
|
|
95
133
|
<p class="weave-note-meta">
|
|
134
|
+
{/* Nothing in read mode: the prose is the way in. The way out needs a
|
|
135
|
+
control, because "click the text" cannot also mean "stop". */}
|
|
136
|
+
{editing ? (
|
|
137
|
+
<>
|
|
138
|
+
<button type="button" class="weave-note-save" disabled={saving} onClick={onSave}>
|
|
139
|
+
{saving ? SAVING_LABEL : SAVE_LABEL}
|
|
140
|
+
</button>
|
|
141
|
+
<button type="button" class="weave-note-edit" onClick={onDone}>{DONE_LABEL}</button>
|
|
142
|
+
</>
|
|
143
|
+
) : null}
|
|
96
144
|
<button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
|
|
97
145
|
<span class="weave-note-open-mark" aria-hidden="true">↗</span>
|
|
98
146
|
</button>
|
|
@@ -120,7 +168,7 @@ function Header({
|
|
|
120
168
|
export function Note(props: NoteProps) {
|
|
121
169
|
const note = props.note?.note ?? null;
|
|
122
170
|
const artifactPath = selectedArtifactPath(props.graph, props.selectedId);
|
|
123
|
-
const empty = noteEmptyMessage(props.selectedId, note);
|
|
171
|
+
const empty = noteEmptyMessage(props.selectedId, note, props.loadFailed);
|
|
124
172
|
|
|
125
173
|
// Both hook calls sit before the empty-return so the hook order cannot
|
|
126
174
|
// depend on whether a note is loaded. The memoization is cheap insurance:
|
|
@@ -142,6 +190,55 @@ export function Note(props: NoteProps) {
|
|
|
142
190
|
const dispatch = (event: PreviewEvent): void => void sendPreview((state) => reducePreview(state, event));
|
|
143
191
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
144
192
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
|
193
|
+
|
|
194
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
195
|
+
const [saving, setSaving] = useState(false);
|
|
196
|
+
const dirty = note !== null && draft !== null && draftDirty(draft, note.body);
|
|
197
|
+
// Bumped on open and close, so a late reply knows it is late (`saveLanded`).
|
|
198
|
+
const session = useRef(0);
|
|
199
|
+
// The draft *now*: `save` outlives the render that captured its own copy.
|
|
200
|
+
const draftRef = useRef(draft);
|
|
201
|
+
draftRef.current = draft;
|
|
202
|
+
|
|
203
|
+
// During render, not in an effect: an effect runs after paint, leaving a
|
|
204
|
+
// window where the slot is still clean and a click slips the guard.
|
|
205
|
+
props.editor.current = { dirty: () => dirty, discard: () => close() };
|
|
206
|
+
useEffect(() => () => {
|
|
207
|
+
props.editor.current = null;
|
|
208
|
+
}, [props.editor]);
|
|
209
|
+
|
|
210
|
+
const close = (): void => {
|
|
211
|
+
session.current += 1;
|
|
212
|
+
setDraft(null);
|
|
213
|
+
setSaving(false);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const open = (body: string): void => {
|
|
217
|
+
session.current += 1;
|
|
218
|
+
setDraft(body);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const save = async (): Promise<void> => {
|
|
222
|
+
if (note === null || draft === null || saving) return;
|
|
223
|
+
const issued = session.current;
|
|
224
|
+
// The textarea stays enabled through the request, so the draft can move
|
|
225
|
+
// underneath it; the reply is only ever about these bytes.
|
|
226
|
+
const sent = draft;
|
|
227
|
+
setSaving(true);
|
|
228
|
+
const ok = await props.onSave(note.slug, sent);
|
|
229
|
+
// A reply for an editor that is gone must not close the one now open.
|
|
230
|
+
if (!saveLanded(issued, session.current)) return;
|
|
231
|
+
// Stay open on failure, and on success the user typed through: both hold
|
|
232
|
+
// text the server does not have, and closing would discard it.
|
|
233
|
+
if (!ok || draftMoved(sent, draftRef.current)) setSaving(false);
|
|
234
|
+
else close();
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const toggleEdit = (): void => {
|
|
238
|
+
if (note === null) return;
|
|
239
|
+
if (draft === null) open(note.body);
|
|
240
|
+
else if (!dirty || window.confirm(DISCARD_PROMPT)) close();
|
|
241
|
+
};
|
|
145
242
|
const card = preview.anchor === null ? null : previewCard(props.graph, preview.anchor);
|
|
146
243
|
|
|
147
244
|
// A card whose target note left the screen is a claim about a document that
|
|
@@ -152,6 +249,10 @@ export function Note(props: NoteProps) {
|
|
|
152
249
|
// slug changes as rarely as navigation happens.
|
|
153
250
|
useLayoutEffect(() => {
|
|
154
251
|
dispatch({ type: "hide" });
|
|
252
|
+
// A different note is a different document: an open editor holding the
|
|
253
|
+
// previous one's text would save it over this one. The shell's guard is
|
|
254
|
+
// what asks before a dirty draft gets here.
|
|
255
|
+
close();
|
|
155
256
|
}, [note === null ? null : note.slug]);
|
|
156
257
|
|
|
157
258
|
// Wired after the card mounts, because both jobs depend on the live DOM:
|
|
@@ -223,7 +324,32 @@ export function Note(props: NoteProps) {
|
|
|
223
324
|
<Header
|
|
224
325
|
view={header}
|
|
225
326
|
open={() => props.onOpen(note.slug)}
|
|
327
|
+
editing={draft !== null}
|
|
328
|
+
saving={saving}
|
|
329
|
+
onDone={toggleEdit}
|
|
330
|
+
onSave={() => void save()}
|
|
226
331
|
/>
|
|
332
|
+
{draft !== null ? (
|
|
333
|
+
<textarea
|
|
334
|
+
class="weave-note-editor"
|
|
335
|
+
aria-label={EDITOR_ARIA_LABEL}
|
|
336
|
+
spellcheck
|
|
337
|
+
value={draft}
|
|
338
|
+
onInput={(event) => setDraft((event.target as HTMLTextAreaElement).value)}
|
|
339
|
+
onKeyDown={(event) => {
|
|
340
|
+
// Bound here, not in `keys.model.ts`: a global binding is a
|
|
341
|
+
// workspace-wide claim, and Escape must not reach the shell.
|
|
342
|
+
if (event.key === "Escape") {
|
|
343
|
+
event.stopPropagation();
|
|
344
|
+
toggleEdit();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (event.key.toLowerCase() !== "s" || !(event.metaKey || event.ctrlKey)) return;
|
|
348
|
+
event.preventDefault();
|
|
349
|
+
void save();
|
|
350
|
+
}}
|
|
351
|
+
/>
|
|
352
|
+
) : (
|
|
227
353
|
<div
|
|
228
354
|
ref={bodyRef}
|
|
229
355
|
class="weave-note-body"
|
|
@@ -255,19 +381,16 @@ export function Note(props: NoteProps) {
|
|
|
255
381
|
}}
|
|
256
382
|
onBlur={() => dispatch({ type: "hide" })}
|
|
257
383
|
onClick={(event) => {
|
|
258
|
-
//
|
|
384
|
+
// Three gestures, one element; `noteClickAction` owns the order.
|
|
259
385
|
const selection =
|
|
260
386
|
typeof window !== "undefined" && typeof window.getSelection === "function"
|
|
261
387
|
? window.getSelection()
|
|
262
388
|
: null;
|
|
263
|
-
if (hasTextSelection(selection)) return;
|
|
264
|
-
|
|
265
|
-
// A wikilink carries no href, so route it onto the context bus.
|
|
266
389
|
const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
390
|
+
const action = noteClickAction(hasTextSelection(selection), target);
|
|
391
|
+
// A wikilink carries no href, so route it onto the context bus.
|
|
392
|
+
if (action === "navigate" && target !== null) props.onSelect(target);
|
|
393
|
+
else if (action === "edit") open(note.body);
|
|
271
394
|
}}
|
|
272
395
|
onKeyDown={(event) => {
|
|
273
396
|
// Escape is first so the card closes on the gesture a keyboard
|
|
@@ -282,13 +405,16 @@ export function Note(props: NoteProps) {
|
|
|
282
405
|
}
|
|
283
406
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
284
407
|
const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
|
|
285
|
-
if (target === null) return;
|
|
286
408
|
event.preventDefault();
|
|
287
|
-
|
|
409
|
+
// The keyboard's half of the click: without it, editing would be
|
|
410
|
+
// mouse-only now that the Edit button is gone.
|
|
411
|
+
if (target !== null) props.onSelect(target);
|
|
412
|
+
else open(note.body);
|
|
288
413
|
}}
|
|
289
414
|
// Sanitised by `renderNote`'s three layers — see note.model.ts.
|
|
290
415
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
291
416
|
/>
|
|
417
|
+
)}
|
|
292
418
|
{card === null ? null : (
|
|
293
419
|
<div
|
|
294
420
|
ref={cardRef}
|
|
@@ -745,6 +745,57 @@ export function hasTextSelection(selection: SelectionLike | null | undefined): b
|
|
|
745
745
|
export const EDITED_WORD = "edited";
|
|
746
746
|
export const CREATED_WORD = "created";
|
|
747
747
|
|
|
748
|
+
// --- the editor -------------------------------------------------------------------------
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* The editor's strings. No "Edit" label — clicking the prose opens it (see
|
|
752
|
+
* {@link noteClickAction}); "Done", because leaving a clean editor cancels
|
|
753
|
+
* nothing.
|
|
754
|
+
*/
|
|
755
|
+
export const DONE_LABEL = "Done";
|
|
756
|
+
export const SAVE_LABEL = "Save";
|
|
757
|
+
export const SAVING_LABEL = "Saving…";
|
|
758
|
+
export const EDITOR_ARIA_LABEL = "Note body";
|
|
759
|
+
export const DISCARD_PROMPT = "Discard unsaved changes to this note?";
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Whether the draft differs from the note as last loaded. Trailing whitespace
|
|
763
|
+
* counts: the save trims, so calling such a draft clean would skip the prompt
|
|
764
|
+
* on the one edit that gets silently reverted.
|
|
765
|
+
*/
|
|
766
|
+
export function draftDirty(draft: string, body: string): boolean {
|
|
767
|
+
return draft !== body;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** What a click on the rendered body means. */
|
|
771
|
+
export type NoteClickAction = "ignore" | "navigate" | "edit";
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Resolve a click on the prose. Selection wins (a drag to copy ends in a
|
|
775
|
+
* click, and 865da37 is that bug), then wikilinks, then editing.
|
|
776
|
+
*/
|
|
777
|
+
export function noteClickAction(hasSelection: boolean, wikilinkTarget: string | null): NoteClickAction {
|
|
778
|
+
if (hasSelection) return "ignore";
|
|
779
|
+
return wikilinkTarget === null ? "edit" : "navigate";
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Whether a save's reply still belongs to the editor that issued it. A
|
|
784
|
+
* counter, not the slug: reopening the same note is a new session too.
|
|
785
|
+
*/
|
|
786
|
+
export function saveLanded(issued: number, current: number): boolean {
|
|
787
|
+
return issued === current;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Whether the draft moved since the bytes a save sent — keystrokes made
|
|
792
|
+
* during the request were never in it, so a success must not close over them.
|
|
793
|
+
* Unlike {@link saveLanded}, this is about the *text*, not the session.
|
|
794
|
+
*/
|
|
795
|
+
export function draftMoved(sent: string, current: string | null): boolean {
|
|
796
|
+
return current !== null && current !== sent;
|
|
797
|
+
}
|
|
798
|
+
|
|
748
799
|
/** The note header: title, provenance badge, relative time, tags. */
|
|
749
800
|
export interface NoteHeaderView {
|
|
750
801
|
readonly title: string;
|
|
@@ -1100,9 +1151,10 @@ export function reducePreview(state: PreviewState, event: PreviewEvent): Preview
|
|
|
1100
1151
|
* message for all of them is how a user concludes the app is broken when they
|
|
1101
1152
|
* click a module and the reading pane does not change.
|
|
1102
1153
|
*/
|
|
1103
|
-
export function noteEmptyMessage(selectedId: string | null, note: ViewNote | null): string | null {
|
|
1154
|
+
export function noteEmptyMessage(selectedId: string | null, note: ViewNote | null, failed = false): string | null {
|
|
1104
1155
|
if (note !== null) return null;
|
|
1105
1156
|
if (selectedId === null) return "Nothing open — search with ⌘K, or walk the tree with j and k.";
|
|
1157
|
+
if (failed) return "Couldn’t load this note — retrying…";
|
|
1106
1158
|
if (selectedId.startsWith(NOTE_PREFIX)) return "Loading…";
|
|
1107
1159
|
// A module, a file, a package, the git state: real nodes with no prose
|
|
1108
1160
|
// behind them. The context rail beside this column is where their
|
|
@@ -26,6 +26,7 @@ export interface ColumnsProps {
|
|
|
26
26
|
/** The §1.3 context bus, as the columns see it. */
|
|
27
27
|
graph: GraphPayload | null;
|
|
28
28
|
note: NotePayload | null;
|
|
29
|
+
noteFailed: boolean;
|
|
29
30
|
selectedId: string | null;
|
|
30
31
|
recentIds: ReadonlySet<string>;
|
|
31
32
|
/**
|
|
@@ -40,6 +41,15 @@ export interface ColumnsProps {
|
|
|
40
41
|
onSelect: (id: string | null) => void;
|
|
41
42
|
onRefresh: () => void;
|
|
42
43
|
onOpen: (slug: string) => void;
|
|
44
|
+
/** Save a note's body. Resolves `true` when the write landed. */
|
|
45
|
+
onSave: (slug: string, body: string) => Promise<boolean>;
|
|
46
|
+
/**
|
|
47
|
+
* Ask before a mutation that would invalidate an open draft, closing it if
|
|
48
|
+
* the user agrees. `false` means keep editing: do not proceed.
|
|
49
|
+
*/
|
|
50
|
+
onMutate: () => boolean;
|
|
51
|
+
/** Slot the note column fills with its editor handle, for the shell's guards. */
|
|
52
|
+
editor: { current: { dirty(): boolean; discard(): void } | null };
|
|
43
53
|
/** Epoch ms for relative times, read once per render by the shell. */
|
|
44
54
|
now: number;
|
|
45
55
|
/**
|
|
@@ -75,15 +85,18 @@ function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
|
|
|
75
85
|
return (
|
|
76
86
|
<section class={`weave-col weave-col-${id}`} aria-label={copy.title}>
|
|
77
87
|
<h2 class="weave-col-title">{copy.title}</h2>
|
|
78
|
-
{id === "tree" ? <Tree graph={props.graph} selectedId={props.selectedId} recentIds={props.recentIds} onSelect={props.onSelect} onRefresh={props.onRefresh} now={props.now} /> : null}
|
|
88
|
+
{id === "tree" ? <Tree graph={props.graph} selectedId={props.selectedId} recentIds={props.recentIds} onSelect={props.onSelect} onMutate={props.onMutate} onRefresh={props.onRefresh} now={props.now} /> : null}
|
|
79
89
|
{id === "note" ? (
|
|
80
90
|
<Note
|
|
81
91
|
note={props.note}
|
|
92
|
+
loadFailed={props.noteFailed}
|
|
82
93
|
graph={props.graph}
|
|
83
94
|
selectedId={props.selectedId}
|
|
84
95
|
onSelect={props.onSelect}
|
|
85
96
|
now={props.now}
|
|
86
97
|
onOpen={props.onOpen}
|
|
98
|
+
onSave={props.onSave}
|
|
99
|
+
editor={props.editor}
|
|
87
100
|
/>
|
|
88
101
|
) : null}
|
|
89
102
|
{id === "graph" ? (
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
|
|
20
20
|
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
21
21
|
import { fetchJson } from "../api.dom";
|
|
22
|
-
import { openNote } from "../api";
|
|
22
|
+
import { openNote, saveNote } from "../api";
|
|
23
|
+
import { DISCARD_PROMPT } from "../note/note.model";
|
|
23
24
|
import type { ColorScheme } from "../graph/graph.model";
|
|
24
25
|
import { schemeOf, watchScheme } from "../graph/scheme";
|
|
25
26
|
import { createSigmaRenderer } from "../graph/renderer.dom";
|
|
@@ -83,6 +84,26 @@ export function Shell(props: ShellProps) {
|
|
|
83
84
|
// Filled by the graph column at mount, cleared on unmount. The global `g`
|
|
84
85
|
// key's only route to the renderer — see `Graph.tsx`'s `fit` prop.
|
|
85
86
|
const fit = useRef<(() => void) | null>(null);
|
|
87
|
+
// Filled by the note column while an editor is open: the exits that destroy
|
|
88
|
+
// a draft are the shell's events, not the column's.
|
|
89
|
+
const editor = useRef<{ dirty(): boolean; discard(): void } | null>(null);
|
|
90
|
+
/**
|
|
91
|
+
* Confirm abandoning an open draft, closing it if the user agrees. `false`
|
|
92
|
+
* means keep editing. Closing here is what stops a tree mutation asking
|
|
93
|
+
* twice — the draft is gone before the selection that follows it.
|
|
94
|
+
*/
|
|
95
|
+
const mayDiscard = (): boolean => {
|
|
96
|
+
const open = editor.current;
|
|
97
|
+
if (open === null || !open.dirty()) return true;
|
|
98
|
+
if (!window.confirm(DISCARD_PROMPT)) return false;
|
|
99
|
+
open.discard();
|
|
100
|
+
return true;
|
|
101
|
+
};
|
|
102
|
+
/** Every selection change routes through here, so no exit skips the guard. */
|
|
103
|
+
const select = (id: string | null): void => {
|
|
104
|
+
if (!mayDiscard()) return;
|
|
105
|
+
void workspace.current?.select(id);
|
|
106
|
+
};
|
|
86
107
|
// The global key listener reads through this so a handler registered at
|
|
87
108
|
// mount still sees the current overlay.
|
|
88
109
|
const live = useRef({ overlay, selectedId: workspaceState.selectedId, layout, width });
|
|
@@ -155,6 +176,18 @@ export function Shell(props: ShellProps) {
|
|
|
155
176
|
// choice) keep up while the choice is "system".
|
|
156
177
|
useEffect(() => watchScheme(window, setSystemScheme), []);
|
|
157
178
|
|
|
179
|
+
// The other exit. No custom message (browsers ignore it), but `returnValue`
|
|
180
|
+
// as well as `preventDefault` — the only spelling all of them honour.
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
const guard = (event: BeforeUnloadEvent): void => {
|
|
183
|
+
if (editor.current?.dirty() !== true) return;
|
|
184
|
+
event.preventDefault();
|
|
185
|
+
event.returnValue = "";
|
|
186
|
+
};
|
|
187
|
+
window.addEventListener("beforeunload", guard);
|
|
188
|
+
return () => window.removeEventListener("beforeunload", guard);
|
|
189
|
+
}, []);
|
|
190
|
+
|
|
158
191
|
useEffect(
|
|
159
192
|
() =>
|
|
160
193
|
watchKeys(document, {
|
|
@@ -164,7 +197,7 @@ export function Shell(props: ShellProps) {
|
|
|
164
197
|
setOverlay,
|
|
165
198
|
focusSelector: (selector) => focusSelector(document, selector),
|
|
166
199
|
fitGraph: () => fit.current?.(),
|
|
167
|
-
clearSelection: () =>
|
|
200
|
+
clearSelection: () => select(null),
|
|
168
201
|
cycleTheme: () => setTheme((current) => cycleTheme(current)),
|
|
169
202
|
}),
|
|
170
203
|
}),
|
|
@@ -199,11 +232,30 @@ export function Shell(props: ShellProps) {
|
|
|
199
232
|
bootFailed={workspaceState.graphFailed}
|
|
200
233
|
graph={workspaceState.graph}
|
|
201
234
|
note={workspaceState.note}
|
|
235
|
+
noteFailed={workspaceState.noteFailed}
|
|
202
236
|
selectedId={workspaceState.selectedId}
|
|
203
237
|
recentIds={workspaceState.recentIds}
|
|
204
|
-
onSelect={
|
|
238
|
+
onSelect={select}
|
|
239
|
+
onMutate={mayDiscard}
|
|
205
240
|
onRefresh={() => workspace.current?.refresh()}
|
|
206
|
-
onOpen={(slug) => void
|
|
241
|
+
onOpen={(slug) => void (async () => {
|
|
242
|
+
// Reported, not discarded: a 403 and an editor that opened nothing
|
|
243
|
+
// used to look identical. `alert` is what the tree already uses.
|
|
244
|
+
const result = await openNote(fetchJson, slug);
|
|
245
|
+
if (!result.ok) window.alert(result.message);
|
|
246
|
+
else if (!result.data.opened) window.alert("could not open the note in an editor");
|
|
247
|
+
})()}
|
|
248
|
+
onSave={async (slug, body) => {
|
|
249
|
+
const result = await saveNote(fetchJson, slug, body);
|
|
250
|
+
if (!result.ok) {
|
|
251
|
+
window.alert(result.message);
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
// The poll would get there in two seconds; this agrees immediately.
|
|
255
|
+
workspace.current?.refresh();
|
|
256
|
+
return true;
|
|
257
|
+
}}
|
|
258
|
+
editor={editor}
|
|
207
259
|
now={now}
|
|
208
260
|
// The graph column's three ports (§7.5, §10). Supplied here, at the
|
|
209
261
|
// one place that is already allowed to name browser globals, so
|
|
@@ -227,7 +279,7 @@ export function Shell(props: ShellProps) {
|
|
|
227
279
|
{overlay === "search" ? (
|
|
228
280
|
<SearchPalette
|
|
229
281
|
graph={workspaceState.graph}
|
|
230
|
-
onSelect={
|
|
282
|
+
onSelect={select}
|
|
231
283
|
onClose={() => setOverlay(null)}
|
|
232
284
|
ports={{ fetch: fetchJson }}
|
|
233
285
|
/>
|
|
@@ -411,6 +411,25 @@ body{font-size:var(--weave-px-base)}
|
|
|
411
411
|
}
|
|
412
412
|
.weave-note-open:hover{color:var(--weave-fg);background:var(--weave-line)}
|
|
413
413
|
.weave-note-open-mark{display:inline-flex;line-height:0}
|
|
414
|
+
/* Save / Done, chip-shaped: the same quiet row, and a control in a reading
|
|
415
|
+
column should not outweigh the title. */
|
|
416
|
+
.weave-note-edit,.weave-note-save{
|
|
417
|
+
font:inherit;font-size:var(--weave-px-caption);line-height:1;white-space:nowrap;cursor:pointer;
|
|
418
|
+
color:var(--weave-dim);background:var(--weave-panel);padding:4px 7px;
|
|
419
|
+
border:1px solid var(--weave-line-strong);border-radius:var(--weave-radius);
|
|
420
|
+
}
|
|
421
|
+
.weave-note-edit:hover,.weave-note-save:hover{color:var(--weave-fg);border-color:var(--weave-accent)}
|
|
422
|
+
/* Save is the row's only affirmative action, so the only one with the accent. */
|
|
423
|
+
.weave-note-save{color:var(--weave-accent);border-color:var(--weave-accent)}
|
|
424
|
+
.weave-note-save:disabled{color:var(--weave-faint);border-color:var(--weave-line-strong);cursor:default}
|
|
425
|
+
/* Mono and a tighter line than the prose: this is Markdown source, where
|
|
426
|
+
alignment carries meaning proportional text throws away. */
|
|
427
|
+
.weave-note-editor{
|
|
428
|
+
display:block;width:100%;flex:1;min-height:0;resize:none;
|
|
429
|
+
padding:12px var(--weave-note-gutter) 28px;box-sizing:border-box;
|
|
430
|
+
font-family:var(--weave-mono);font-size:var(--weave-px-row);line-height:1.6;
|
|
431
|
+
color:var(--weave-fg);background:var(--weave-bg);border:0;outline:0;
|
|
432
|
+
}
|
|
414
433
|
.weave-note-tags{margin:6px 0 0;display:flex;gap:5px;flex-wrap:wrap}
|
|
415
434
|
.weave-tag{
|
|
416
435
|
font-size:var(--weave-px-caption);color:var(--weave-accent);background:var(--weave-panel);
|
|
@@ -422,6 +441,11 @@ body{font-size:var(--weave-px-base)}
|
|
|
422
441
|
chrome's 1.6: the extra leading is what a page of running text needs that a
|
|
423
442
|
status bar does not. Code stays mono deliberately. */
|
|
424
443
|
.weave-note-body{padding:12px var(--weave-note-gutter) 28px;font-size:var(--weave-px-body);line-height:1.7;color:var(--weave-fg)}
|
|
444
|
+
/* The prose is the edit affordance: a caret and a faint wash, not a border.
|
|
445
|
+
This column is read far more often than edited. */
|
|
446
|
+
.weave-note-body{cursor:text}
|
|
447
|
+
.weave-note-body:hover{background:var(--weave-panel)}
|
|
448
|
+
@media (prefers-reduced-motion: no-preference){.weave-note-body{transition:background 120ms ease}}
|
|
425
449
|
.weave-note-body>*:first-child{margin-top:0}
|
|
426
450
|
.weave-note-body h1,.weave-note-body h2,.weave-note-body h3,
|
|
427
451
|
.weave-note-body h4,.weave-note-body h5,.weave-note-body h6{
|
package/src/web/client/state.ts
CHANGED
|
@@ -11,10 +11,11 @@ export interface WorkspaceState {
|
|
|
11
11
|
graph: GraphPayload | null;
|
|
12
12
|
graphFailed: boolean;
|
|
13
13
|
note: NotePayload | null;
|
|
14
|
+
noteFailed: boolean;
|
|
14
15
|
recentIds: ReadonlySet<string>;
|
|
15
16
|
selectedId: string | null;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export function initialWorkspaceState(): WorkspaceState {
|
|
19
|
-
return { graph: null, graphFailed: false, note: null, recentIds: new Set(), selectedId: null };
|
|
20
|
+
return { graph: null, graphFailed: false, note: null, noteFailed: false, recentIds: new Set(), selectedId: null };
|
|
20
21
|
}
|
|
@@ -45,6 +45,11 @@ export interface TreeProps {
|
|
|
45
45
|
selectedId: string | null;
|
|
46
46
|
recentIds: ReadonlySet<string>;
|
|
47
47
|
onSelect: (id: string) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Ask before a mutation that would invalidate an open draft. `false` means
|
|
50
|
+
* keep editing: the mutation must not run.
|
|
51
|
+
*/
|
|
52
|
+
onMutate: () => boolean;
|
|
48
53
|
onRefresh: () => void;
|
|
49
54
|
now: number;
|
|
50
55
|
}
|
|
@@ -191,6 +196,9 @@ export function Tree(props: TreeProps) {
|
|
|
191
196
|
} else if (action === "rename") {
|
|
192
197
|
setEditing({ id: menu.id, label: menu.label, value: menu.label });
|
|
193
198
|
} else if (target.type !== "vault") {
|
|
199
|
+
// Before the request: asking after a delete is asking about a file that
|
|
200
|
+
// is already gone, and "keep editing" would strand the draft on a 404.
|
|
201
|
+
if (deletesSelection(props.selectedId, target) && !props.onMutate()) return;
|
|
194
202
|
await run(target.type === "note" ? deleteNote(fetchJson, target.path) : deleteFolder(fetchJson, target.path), deletesSelection(props.selectedId, target) ? "vault" : undefined);
|
|
195
203
|
}
|
|
196
204
|
};
|
|
@@ -237,7 +245,9 @@ export function Tree(props: TreeProps) {
|
|
|
237
245
|
// unactionable — it named no link and offered no way to see them — and it
|
|
238
246
|
// fired on the *commit* of an edit the user had already typed out, which
|
|
239
247
|
// is the least useful moment to ask. Renaming back is one more rename.
|
|
248
|
+
// An open draft is a different question; `onMutate` asks that one.
|
|
240
249
|
if (target !== null && name && name !== current.label) {
|
|
250
|
+
if (deletesSelection(props.selectedId, target) && !props.onMutate()) return;
|
|
241
251
|
void run(target.type === "note" ? renameNote(fetchJson, target.path, name) : renameFolder(fetchJson, target.path, name));
|
|
242
252
|
}
|
|
243
253
|
};
|
|
@@ -268,7 +278,7 @@ export function Tree(props: TreeProps) {
|
|
|
268
278
|
setMenu({ id: "vault", label: "Vault", x: event.clientX, y: event.clientY });
|
|
269
279
|
}}
|
|
270
280
|
>
|
|
271
|
-
{rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={() => { props.onSelect(view.id); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); }} />)}
|
|
281
|
+
{rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={() => { props.onSelect(view.id); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) { if (dragged === props.selectedId && !props.onMutate()) return; void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); } }} />)}
|
|
272
282
|
</ul>
|
|
273
283
|
) : <p class="weave-tree-empty">{empty}</p>}
|
|
274
284
|
<p class="weave-tree-count">{rowCountLabel(rows)}</p>
|
|
@@ -54,11 +54,12 @@ export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
|
|
|
54
54
|
const loadNote = async (): Promise<void> => {
|
|
55
55
|
const slug = noteSlug(state.selectedId);
|
|
56
56
|
if (slug === null) {
|
|
57
|
-
update({ note: null });
|
|
57
|
+
update({ note: null, noteFailed: false });
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
60
|
const result = await fetchNote(opts.fetch, slug);
|
|
61
|
-
if (result.ok) update({ note: result.data });
|
|
61
|
+
if (result.ok) update({ note: result.data, noteFailed: false });
|
|
62
|
+
else update({ note: null, noteFailed: true });
|
|
62
63
|
};
|
|
63
64
|
const loadGraph = async (): Promise<ApiResult<unknown>> => {
|
|
64
65
|
const previous = state.graph;
|
|
@@ -86,7 +87,7 @@ export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
|
|
|
86
87
|
polling = true;
|
|
87
88
|
try {
|
|
88
89
|
const result = await loadGraph();
|
|
89
|
-
if (
|
|
90
|
+
if (noteSlug(state.selectedId) !== null && (state.note === null || (result.ok && !result.cached))) await loadNote();
|
|
90
91
|
} finally {
|
|
91
92
|
polling = false;
|
|
92
93
|
}
|
package/src/web/server/routes.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* | GET | `/app.js` | the committed bundle, `Cache-Control: no-store` |
|
|
8
8
|
* | GET | `/api/graph` | {@link GraphPayload}, ETag'd on `stamp` |
|
|
9
9
|
* | GET | `/api/note/:slug` | {@link NotePayload} |
|
|
10
|
+
* | POST | `/api/note/:slug` | save a note's Markdown body |
|
|
10
11
|
* | POST | `/api/note/:slug/rename` | rename a vault note |
|
|
11
12
|
* | POST | `/api/note/:slug/move` | move a vault note |
|
|
12
13
|
* | DELETE | `/api/note/:slug` | delete a vault note |
|
|
@@ -52,7 +53,7 @@ import type { GraphModel as CoreGraphModel } from "../../core/graph/model";
|
|
|
52
53
|
import { openNoteInEditor } from "../../core/openInEditor";
|
|
53
54
|
import type { Note } from "../../core/types";
|
|
54
55
|
import type { VaultMutationResult } from "../../core/vault";
|
|
55
|
-
import { createFolder, deleteFolder, deleteNote, getNote, moveNote, renameFolder, renameNote, resolveHtmlPath, searchNotes } from "../../core/vault";
|
|
56
|
+
import { createFolder, deleteFolder, deleteNote, getNote, moveNote, renameFolder, renameNote, resolveHtmlPath, searchNotes, setNoteBody } from "../../core/vault";
|
|
56
57
|
import { deriveTagIndex, type TaggedNote } from "../../core/view/links";
|
|
57
58
|
import type {
|
|
58
59
|
GraphPayload,
|
|
@@ -555,18 +556,29 @@ async function routeNote(
|
|
|
555
556
|
}
|
|
556
557
|
const action = target.endsWith("/rename") ? "rename" : target.endsWith("/move") ? "move" : null;
|
|
557
558
|
const slug = action === null ? target : target.slice(0, -(action.length + 1));
|
|
558
|
-
if (method === "POST"
|
|
559
|
+
if (method === "POST") {
|
|
559
560
|
const body = await readJsonBody(req);
|
|
560
561
|
if (typeof body !== "object" || body === null) {
|
|
561
562
|
sendJson(res, 400, { error: "expected JSON object" });
|
|
562
563
|
return true;
|
|
563
564
|
}
|
|
565
|
+
const fields = body as { body?: unknown; name?: unknown; folder?: unknown };
|
|
566
|
+
// The payload decides, not the suffix: slugs nest, so `archive/rename` is
|
|
567
|
+
// a real note and only `{name}`/`{folder}` mean the action.
|
|
568
|
+
if (typeof fields.body === "string") {
|
|
569
|
+
sendMutation(res, await setNoteBody(deps.vaultRoot, target, fields.body));
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
if (action === null) {
|
|
573
|
+
sendJson(res, 400, { error: "expected { body: string }" });
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
564
576
|
const result = action === "rename"
|
|
565
|
-
? typeof
|
|
566
|
-
? await renameNote(deps.vaultRoot, slug,
|
|
577
|
+
? typeof fields.name === "string"
|
|
578
|
+
? await renameNote(deps.vaultRoot, slug, fields.name)
|
|
567
579
|
: null
|
|
568
|
-
:
|
|
569
|
-
? await moveNote(deps.vaultRoot, slug,
|
|
580
|
+
: fields.folder === null || typeof fields.folder === "string"
|
|
581
|
+
? await moveNote(deps.vaultRoot, slug, fields.folder)
|
|
570
582
|
: null;
|
|
571
583
|
if (result === null) sendJson(res, 400, { error: action === "rename" ? "expected { name: string }" : "expected { folder: string | null }" });
|
|
572
584
|
else sendMutation(res, result);
|