pi-weave 0.2.1 → 0.2.3
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/package.json +1 -1
- package/src/web/client/dist/app.js +39 -39
- package/src/web/client/note/Note.tsx +58 -20
- package/src/web/client/note/note.model.ts +103 -25
- package/src/web/client/shell/Columns.tsx +2 -0
- package/src/web/client/shell/Shell.tsx +1 -0
- package/src/web/client/shell/theme.ts +2 -5
- package/src/web/client/state.ts +2 -1
- package/src/web/client/workspace.ts +4 -3
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* ## The editor
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* mode, so text and mode cannot disagree. No reducer — the 686-line one that
|
|
11
|
+
* The explicit Edit control swaps the body for a `<textarea>`; `draft` is
|
|
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
14
|
* issues. Being component state, the draft is out of the 2 s poll's reach;
|
|
15
15
|
* the shell guards it through the `editor` slot below.
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* ## Wikilink preview (P6.3)
|
|
24
24
|
*
|
|
25
25
|
* Hovering or focusing a wikilink opens a small card for its target. The
|
|
26
|
-
* delegated
|
|
26
|
+
* body's delegated handlers extend to the card for
|
|
27
27
|
* free — `mouseover`, `focusin` and `keydown` are read through the same
|
|
28
28
|
* ancestor walk (`previewAnchorOf`), the state machine is `reducePreview`,
|
|
29
29
|
* and this file's only contribution is forwarding the pointer's viewport
|
|
@@ -46,17 +46,18 @@ import {
|
|
|
46
46
|
artifactKeyOfNode,
|
|
47
47
|
DISCARD_PROMPT,
|
|
48
48
|
DONE_LABEL,
|
|
49
|
+
EDIT_LABEL,
|
|
49
50
|
EDITED_WORD,
|
|
50
51
|
EDITOR_ARIA_LABEL,
|
|
51
52
|
EMPTY_PREVIEW,
|
|
52
53
|
PREVIEW_ID,
|
|
53
54
|
SAVE_LABEL,
|
|
54
55
|
SAVING_LABEL,
|
|
56
|
+
TASK_CHECKBOX_ATTR,
|
|
55
57
|
WIKILINK_ATTR,
|
|
56
58
|
draftDirty,
|
|
57
59
|
draftMoved,
|
|
58
60
|
hasTextSelection,
|
|
59
|
-
noteClickAction,
|
|
60
61
|
saveLanded,
|
|
61
62
|
noteEmptyMessage,
|
|
62
63
|
noteHeader,
|
|
@@ -67,6 +68,9 @@ import {
|
|
|
67
68
|
renderNote,
|
|
68
69
|
selectedArtifactPath,
|
|
69
70
|
tagLabel,
|
|
71
|
+
taskCheckboxIndexOf,
|
|
72
|
+
taskCheckboxLabel,
|
|
73
|
+
toggleTaskCheckbox,
|
|
70
74
|
wikiIndex,
|
|
71
75
|
wikilinkTargetOf,
|
|
72
76
|
} from "./note.model";
|
|
@@ -83,6 +87,7 @@ const PREVIEW_Y = "weave-preview-y";
|
|
|
83
87
|
|
|
84
88
|
export interface NoteProps {
|
|
85
89
|
note: NotePayload | null;
|
|
90
|
+
loadFailed: boolean;
|
|
86
91
|
graph: GraphPayload | null;
|
|
87
92
|
selectedId: string | null;
|
|
88
93
|
onSelect: (id: string) => void;
|
|
@@ -116,30 +121,28 @@ function Header({
|
|
|
116
121
|
open,
|
|
117
122
|
editing,
|
|
118
123
|
saving,
|
|
119
|
-
|
|
124
|
+
onToggle,
|
|
120
125
|
onSave,
|
|
121
126
|
}: {
|
|
122
127
|
view: NoteHeaderView;
|
|
123
128
|
open: () => void;
|
|
124
129
|
editing: boolean;
|
|
125
130
|
saving: boolean;
|
|
126
|
-
|
|
131
|
+
onToggle: () => void;
|
|
127
132
|
onSave: () => void;
|
|
128
133
|
}) {
|
|
129
134
|
return (
|
|
130
135
|
<header class="weave-note-head">
|
|
131
136
|
<h3 class="weave-note-title">{view.title}</h3>
|
|
132
137
|
<p class="weave-note-meta">
|
|
133
|
-
{/* Nothing in read mode: the prose is the way in. The way out needs a
|
|
134
|
-
control, because "click the text" cannot also mean "stop". */}
|
|
135
138
|
{editing ? (
|
|
136
139
|
<>
|
|
137
140
|
<button type="button" class="weave-note-save" disabled={saving} onClick={onSave}>
|
|
138
141
|
{saving ? SAVING_LABEL : SAVE_LABEL}
|
|
139
142
|
</button>
|
|
140
|
-
<button type="button" class="weave-note-edit" onClick={
|
|
143
|
+
<button type="button" class="weave-note-edit" onClick={onToggle}>{DONE_LABEL}</button>
|
|
141
144
|
</>
|
|
142
|
-
) :
|
|
145
|
+
) : <button type="button" class="weave-note-edit" onClick={onToggle}>{EDIT_LABEL}</button>}
|
|
143
146
|
<button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
|
|
144
147
|
<span class="weave-note-open-mark" aria-hidden="true">↗</span>
|
|
145
148
|
</button>
|
|
@@ -167,7 +170,7 @@ function Header({
|
|
|
167
170
|
export function Note(props: NoteProps) {
|
|
168
171
|
const note = props.note?.note ?? null;
|
|
169
172
|
const artifactPath = selectedArtifactPath(props.graph, props.selectedId);
|
|
170
|
-
const empty = noteEmptyMessage(props.selectedId, note);
|
|
173
|
+
const empty = noteEmptyMessage(props.selectedId, note, props.loadFailed);
|
|
171
174
|
|
|
172
175
|
// Both hook calls sit before the empty-return so the hook order cannot
|
|
173
176
|
// depend on whether a note is loaded. The memoization is cheap insurance:
|
|
@@ -189,6 +192,12 @@ export function Note(props: NoteProps) {
|
|
|
189
192
|
const dispatch = (event: PreviewEvent): void => void sendPreview((state) => reducePreview(state, event));
|
|
190
193
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
191
194
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
|
195
|
+
const taskSaving = useRef(false);
|
|
196
|
+
const taskSource = useRef<{ slug: string; serverBody: string; body: string } | null>(null);
|
|
197
|
+
if (note === null) taskSource.current = null;
|
|
198
|
+
else if (taskSource.current === null || taskSource.current.slug !== note.slug || taskSource.current.serverBody !== note.body) {
|
|
199
|
+
taskSource.current = { slug: note.slug, serverBody: note.body, body: note.body };
|
|
200
|
+
}
|
|
192
201
|
|
|
193
202
|
const [draft, setDraft] = useState<string | null>(null);
|
|
194
203
|
const [saving, setSaving] = useState(false);
|
|
@@ -233,6 +242,33 @@ export function Note(props: NoteProps) {
|
|
|
233
242
|
else close();
|
|
234
243
|
};
|
|
235
244
|
|
|
245
|
+
const saveTask = async (checkbox: HTMLInputElement, taskIndex: number): Promise<void> => {
|
|
246
|
+
const source = taskSource.current;
|
|
247
|
+
const restore = (): void => {
|
|
248
|
+
checkbox.checked = !checkbox.checked;
|
|
249
|
+
checkbox.setAttribute("aria-label", taskCheckboxLabel(checkbox.checked));
|
|
250
|
+
};
|
|
251
|
+
if (source === null || taskSaving.current) {
|
|
252
|
+
restore();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const body = toggleTaskCheckbox(source.body, taskIndex);
|
|
256
|
+
if (body === null) {
|
|
257
|
+
restore();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const inputs = [...(bodyRef.current?.querySelectorAll<HTMLInputElement>(`input[${TASK_CHECKBOX_ATTR}]`) ?? [])];
|
|
262
|
+
taskSaving.current = true;
|
|
263
|
+
checkbox.setAttribute("aria-label", taskCheckboxLabel(checkbox.checked));
|
|
264
|
+
for (const input of inputs) input.disabled = true;
|
|
265
|
+
const ok = await props.onSave(source.slug, body);
|
|
266
|
+
if (ok) source.body = body;
|
|
267
|
+
else restore();
|
|
268
|
+
for (const input of inputs) input.disabled = false;
|
|
269
|
+
taskSaving.current = false;
|
|
270
|
+
};
|
|
271
|
+
|
|
236
272
|
const toggleEdit = (): void => {
|
|
237
273
|
if (note === null) return;
|
|
238
274
|
if (draft === null) open(note.body);
|
|
@@ -325,7 +361,7 @@ export function Note(props: NoteProps) {
|
|
|
325
361
|
open={() => props.onOpen(note.slug)}
|
|
326
362
|
editing={draft !== null}
|
|
327
363
|
saving={saving}
|
|
328
|
-
|
|
364
|
+
onToggle={toggleEdit}
|
|
329
365
|
onSave={() => void save()}
|
|
330
366
|
/>
|
|
331
367
|
{draft !== null ? (
|
|
@@ -380,16 +416,20 @@ export function Note(props: NoteProps) {
|
|
|
380
416
|
}}
|
|
381
417
|
onBlur={() => dispatch({ type: "hide" })}
|
|
382
418
|
onClick={(event) => {
|
|
383
|
-
|
|
419
|
+
const checkbox = event.target as HTMLInputElement;
|
|
420
|
+
const taskIndex = taskCheckboxIndexOf(checkbox);
|
|
421
|
+
if (taskIndex !== null) {
|
|
422
|
+
void saveTask(checkbox, taskIndex);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
384
425
|
const selection =
|
|
385
426
|
typeof window !== "undefined" && typeof window.getSelection === "function"
|
|
386
427
|
? window.getSelection()
|
|
387
428
|
: null;
|
|
429
|
+
if (hasTextSelection(selection)) return;
|
|
388
430
|
const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
|
|
389
|
-
const action = noteClickAction(hasTextSelection(selection), target);
|
|
390
431
|
// A wikilink carries no href, so route it onto the context bus.
|
|
391
|
-
if (
|
|
392
|
-
else if (action === "edit") open(note.body);
|
|
432
|
+
if (target !== null) props.onSelect(target);
|
|
393
433
|
}}
|
|
394
434
|
onKeyDown={(event) => {
|
|
395
435
|
// Escape is first so the card closes on the gesture a keyboard
|
|
@@ -404,11 +444,9 @@ export function Note(props: NoteProps) {
|
|
|
404
444
|
}
|
|
405
445
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
406
446
|
const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
|
|
447
|
+
if (target === null) return;
|
|
407
448
|
event.preventDefault();
|
|
408
|
-
|
|
409
|
-
// mouse-only now that the Edit button is gone.
|
|
410
|
-
if (target !== null) props.onSelect(target);
|
|
411
|
-
else open(note.body);
|
|
449
|
+
props.onSelect(target);
|
|
412
450
|
}}
|
|
413
451
|
// Sanitised by `renderNote`'s three layers — see note.model.ts.
|
|
414
452
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
* `tsconfig.json` project compile the tests.
|
|
68
68
|
*/
|
|
69
69
|
|
|
70
|
-
import { Marked } from "marked";
|
|
70
|
+
import { Lexer, Marked } from "marked";
|
|
71
71
|
import type { TokenizerAndRendererExtension, Tokens } from "marked";
|
|
72
72
|
import { relTime } from "../../shared/view";
|
|
73
73
|
import type { GraphPayload, ViewNote, WireGraphNode, WireNoteSource } from "../../shared/wire";
|
|
@@ -197,6 +197,14 @@ export function safeUrl(raw: string): string | null {
|
|
|
197
197
|
*/
|
|
198
198
|
export const WIKILINK_ATTR = "data-weave-target";
|
|
199
199
|
|
|
200
|
+
/** The rendered task checkbox's zero-based source-order index. */
|
|
201
|
+
export const TASK_CHECKBOX_ATTR = "data-weave-task";
|
|
202
|
+
|
|
203
|
+
/** The action a task checkbox offers from its current state. */
|
|
204
|
+
export function taskCheckboxLabel(checked: boolean): string {
|
|
205
|
+
return checked ? "Mark task incomplete" : "Mark task complete";
|
|
206
|
+
}
|
|
207
|
+
|
|
200
208
|
/** Prefix core's graph builder gives note nodes. Mirrors `noteSlug`. */
|
|
201
209
|
const NOTE_PREFIX = "note:";
|
|
202
210
|
|
|
@@ -465,11 +473,16 @@ export function renderWikilink(index: WikiIndex, target: string, alias: string):
|
|
|
465
473
|
* rather than `string | Promise<string>`; nothing here is asynchronous.
|
|
466
474
|
*/
|
|
467
475
|
export function markdownRenderer(index: WikiIndex): Marked {
|
|
476
|
+
let taskIndex = 0;
|
|
468
477
|
return new Marked({
|
|
469
478
|
gfm: true,
|
|
470
479
|
async: false,
|
|
471
480
|
extensions: [wikilinkExtension(index)],
|
|
472
481
|
renderer: {
|
|
482
|
+
checkbox({ checked }) {
|
|
483
|
+
const state = checked ? ' checked=""' : "";
|
|
484
|
+
return `<input type="checkbox"${state} ${TASK_CHECKBOX_ATTR}="${taskIndex++}" aria-label="${taskCheckboxLabel(checked)}"> `;
|
|
485
|
+
},
|
|
473
486
|
/**
|
|
474
487
|
* Layer 1. Raw HTML in a note body is rendered as **text**.
|
|
475
488
|
*
|
|
@@ -551,7 +564,6 @@ export interface SanitizeConfig {
|
|
|
551
564
|
readonly ALLOW_DATA_ATTR: boolean;
|
|
552
565
|
readonly ALLOW_ARIA_ATTR: boolean;
|
|
553
566
|
readonly ALLOW_UNKNOWN_PROTOCOLS: boolean;
|
|
554
|
-
readonly USE_PROFILES: { readonly html: true };
|
|
555
567
|
}
|
|
556
568
|
|
|
557
569
|
/**
|
|
@@ -575,9 +587,9 @@ export interface SanitizeConfig {
|
|
|
575
587
|
* and keeps the element. It also contains no `style`, so a CSS-based overlay
|
|
576
588
|
* attack has no attribute to live in.
|
|
577
589
|
*
|
|
578
|
-
* `ALLOW_DATA_ATTR: false` with
|
|
590
|
+
* `ALLOW_DATA_ATTR: false` with the two renderer-owned data attributes named explicitly is
|
|
579
591
|
* narrower than leaving data attributes on: the only `data-` attribute that
|
|
580
|
-
* may exist in rendered output is
|
|
592
|
+
* may exist in rendered output is one this module writes.
|
|
581
593
|
* `ADD_URI_SAFE_ATTR` then tells DOMPurify that the attribute's value is not a
|
|
582
594
|
* URL and must not be run through `IS_ALLOWED_URI` — without it a slug like
|
|
583
595
|
* `note-2` is fine but the check is being applied to a value that was never a
|
|
@@ -644,15 +656,15 @@ export const SANITIZE_CONFIG: SanitizeConfig = frozen({
|
|
|
644
656
|
"align",
|
|
645
657
|
"type",
|
|
646
658
|
"checked",
|
|
647
|
-
"
|
|
659
|
+
"aria-label",
|
|
648
660
|
"start",
|
|
649
661
|
WIKILINK_ATTR,
|
|
662
|
+
TASK_CHECKBOX_ATTR,
|
|
650
663
|
]),
|
|
651
|
-
ADD_URI_SAFE_ATTR: frozen([WIKILINK_ATTR]),
|
|
664
|
+
ADD_URI_SAFE_ATTR: frozen([WIKILINK_ATTR, TASK_CHECKBOX_ATTR]),
|
|
652
665
|
ALLOW_DATA_ATTR: false,
|
|
653
666
|
ALLOW_ARIA_ATTR: false,
|
|
654
667
|
ALLOW_UNKNOWN_PROTOCOLS: false,
|
|
655
|
-
USE_PROFILES: frozen({ html: true as const }),
|
|
656
668
|
});
|
|
657
669
|
|
|
658
670
|
/**
|
|
@@ -671,12 +683,92 @@ export function renderNote(purify: Purifier, body: string, index: WikiIndex): st
|
|
|
671
683
|
|
|
672
684
|
// --- reading a click back out of the DOM --------------------------------------------------
|
|
673
685
|
|
|
686
|
+
const TASK_LINE_RE = /^((?: {0,3}>[ \t]?)*[ \t]*(?:[*+-]|\d{1,9}[.)])[ \t]+\[)([ xX])(\] +\S)/;
|
|
687
|
+
|
|
688
|
+
/** Source offsets for the task markers marked will render, in render order. */
|
|
689
|
+
function taskMarkerOffsets(body: string): Array<number | null> {
|
|
690
|
+
const candidates: number[] = [];
|
|
691
|
+
const source = body.replace(/\r\n?/g, "\n");
|
|
692
|
+
let sourceOffset = 0;
|
|
693
|
+
|
|
694
|
+
for (const token of Lexer.lex(source, { gfm: true })) {
|
|
695
|
+
const tokenOffset = source.indexOf(token.raw, sourceOffset);
|
|
696
|
+
if (tokenOffset === -1) continue;
|
|
697
|
+
sourceOffset = tokenOffset + token.raw.length;
|
|
698
|
+
if (token.type !== "list" && token.type !== "blockquote") continue;
|
|
699
|
+
|
|
700
|
+
let lineOffset = 0;
|
|
701
|
+
while (lineOffset < token.raw.length) {
|
|
702
|
+
const newline = token.raw.indexOf("\n", lineOffset);
|
|
703
|
+
const lineEnd = newline === -1 ? token.raw.length : newline;
|
|
704
|
+
const line = token.raw.slice(lineOffset, lineEnd).replace(/\r$/, "");
|
|
705
|
+
const task = TASK_LINE_RE.exec(line);
|
|
706
|
+
if (task !== null) candidates.push(tokenOffset + lineOffset + (task[1] as string).length);
|
|
707
|
+
lineOffset = newline === -1 ? token.raw.length : newline + 1;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const walker = new Marked();
|
|
712
|
+
const states = (markdown: string): boolean[] => {
|
|
713
|
+
const checked: boolean[] = [];
|
|
714
|
+
walker.walkTokens(Lexer.lex(markdown, { gfm: true }), (token) => {
|
|
715
|
+
if (token.type === "checkbox") checked.push(token.checked);
|
|
716
|
+
});
|
|
717
|
+
return checked;
|
|
718
|
+
};
|
|
719
|
+
const baseline = states(source);
|
|
720
|
+
const offsets: Array<number | null> = baseline.map(() => null);
|
|
721
|
+
// ponytail: re-lex each candidate so Marked remains the sole judge of
|
|
722
|
+
// Markdown structure; use token source positions if Marked adds them.
|
|
723
|
+
for (const offset of candidates) {
|
|
724
|
+
const toggled = `${source.slice(0, offset)}${source[offset] === " " ? "x" : " "}${source.slice(offset + 1)}`;
|
|
725
|
+
const next = states(toggled);
|
|
726
|
+
if (next.length !== baseline.length) continue;
|
|
727
|
+
let changed = -1;
|
|
728
|
+
for (let index = 0; index < baseline.length; index += 1) {
|
|
729
|
+
if (next[index] === baseline[index]) continue;
|
|
730
|
+
if (changed !== -1) {
|
|
731
|
+
changed = -1;
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
changed = index;
|
|
735
|
+
}
|
|
736
|
+
if (changed !== -1) offsets[changed] = offset;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return offsets.map((offset) => {
|
|
740
|
+
if (offset === null) return null;
|
|
741
|
+
let original = 0;
|
|
742
|
+
let normalized = 0;
|
|
743
|
+
while (normalized < offset) {
|
|
744
|
+
original += body[original] === "\r" && body[original + 1] === "\n" ? 2 : 1;
|
|
745
|
+
normalized += 1;
|
|
746
|
+
}
|
|
747
|
+
return original;
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Flip one rendered GFM task marker without changing any other source byte. */
|
|
752
|
+
export function toggleTaskCheckbox(body: string, index: number): string | null {
|
|
753
|
+
if (!Number.isSafeInteger(index) || index < 0) return null;
|
|
754
|
+
const offset = taskMarkerOffsets(body)[index];
|
|
755
|
+
if (offset === undefined || offset === null) return null;
|
|
756
|
+
return `${body.slice(0, offset)}${body[offset] === " " ? "x" : " "}${body.slice(offset + 1)}`;
|
|
757
|
+
}
|
|
758
|
+
|
|
674
759
|
/** The slice of `Element` {@link wikilinkTargetOf} walks. `HTMLElement` satisfies it. */
|
|
675
760
|
export interface ClosestElement {
|
|
676
761
|
getAttribute(name: string): string | null;
|
|
677
762
|
readonly parentElement: ClosestElement | null;
|
|
678
763
|
}
|
|
679
764
|
|
|
765
|
+
/** The task index carried by a rendered checkbox, if the target is one. */
|
|
766
|
+
export function taskCheckboxIndexOf(element: ClosestElement | null): number | null {
|
|
767
|
+
const raw = element?.getAttribute(TASK_CHECKBOX_ATTR);
|
|
768
|
+
if (raw === null || raw === undefined || !/^(?:0|[1-9]\d*)$/.test(raw)) return null;
|
|
769
|
+
return Number(raw);
|
|
770
|
+
}
|
|
771
|
+
|
|
680
772
|
/**
|
|
681
773
|
* Depth limit for the ancestor walk.
|
|
682
774
|
*
|
|
@@ -747,11 +839,8 @@ export const CREATED_WORD = "created";
|
|
|
747
839
|
|
|
748
840
|
// --- the editor -------------------------------------------------------------------------
|
|
749
841
|
|
|
750
|
-
/**
|
|
751
|
-
|
|
752
|
-
* {@link noteClickAction}); "Done", because leaving a clean editor cancels
|
|
753
|
-
* nothing.
|
|
754
|
-
*/
|
|
842
|
+
/** The editor's controls and prompts. */
|
|
843
|
+
export const EDIT_LABEL = "Edit";
|
|
755
844
|
export const DONE_LABEL = "Done";
|
|
756
845
|
export const SAVE_LABEL = "Save";
|
|
757
846
|
export const SAVING_LABEL = "Saving…";
|
|
@@ -767,18 +856,6 @@ export function draftDirty(draft: string, body: string): boolean {
|
|
|
767
856
|
return draft !== body;
|
|
768
857
|
}
|
|
769
858
|
|
|
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
859
|
/**
|
|
783
860
|
* Whether a save's reply still belongs to the editor that issued it. A
|
|
784
861
|
* counter, not the slug: reopening the same note is a new session too.
|
|
@@ -1151,9 +1228,10 @@ export function reducePreview(state: PreviewState, event: PreviewEvent): Preview
|
|
|
1151
1228
|
* message for all of them is how a user concludes the app is broken when they
|
|
1152
1229
|
* click a module and the reading pane does not change.
|
|
1153
1230
|
*/
|
|
1154
|
-
export function noteEmptyMessage(selectedId: string | null, note: ViewNote | null): string | null {
|
|
1231
|
+
export function noteEmptyMessage(selectedId: string | null, note: ViewNote | null, failed = false): string | null {
|
|
1155
1232
|
if (note !== null) return null;
|
|
1156
1233
|
if (selectedId === null) return "Nothing open — search with ⌘K, or walk the tree with j and k.";
|
|
1234
|
+
if (failed) return "Couldn’t load this note — retrying…";
|
|
1157
1235
|
if (selectedId.startsWith(NOTE_PREFIX)) return "Loading…";
|
|
1158
1236
|
// A module, a file, a package, the git state: real nodes with no prose
|
|
1159
1237
|
// 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
|
/**
|
|
@@ -88,6 +89,7 @@ function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
|
|
|
88
89
|
{id === "note" ? (
|
|
89
90
|
<Note
|
|
90
91
|
note={props.note}
|
|
92
|
+
loadFailed={props.noteFailed}
|
|
91
93
|
graph={props.graph}
|
|
92
94
|
selectedId={props.selectedId}
|
|
93
95
|
onSelect={props.onSelect}
|
|
@@ -232,6 +232,7 @@ export function Shell(props: ShellProps) {
|
|
|
232
232
|
bootFailed={workspaceState.graphFailed}
|
|
233
233
|
graph={workspaceState.graph}
|
|
234
234
|
note={workspaceState.note}
|
|
235
|
+
noteFailed={workspaceState.noteFailed}
|
|
235
236
|
selectedId={workspaceState.selectedId}
|
|
236
237
|
recentIds={workspaceState.recentIds}
|
|
237
238
|
onSelect={select}
|
|
@@ -441,11 +441,6 @@ body{font-size:var(--weave-px-base)}
|
|
|
441
441
|
chrome's 1.6: the extra leading is what a page of running text needs that a
|
|
442
442
|
status bar does not. Code stays mono deliberately. */
|
|
443
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}}
|
|
449
444
|
.weave-note-body>*:first-child{margin-top:0}
|
|
450
445
|
.weave-note-body h1,.weave-note-body h2,.weave-note-body h3,
|
|
451
446
|
.weave-note-body h4,.weave-note-body h5,.weave-note-body h6{
|
|
@@ -462,6 +457,8 @@ body{font-size:var(--weave-px-base)}
|
|
|
462
457
|
.weave-note-body p,.weave-note-body ul,.weave-note-body ol,.weave-note-body blockquote{margin:0 0 12px}
|
|
463
458
|
.weave-note-body ul,.weave-note-body ol{padding-left:20px}
|
|
464
459
|
.weave-note-body li{margin:4px 0}
|
|
460
|
+
.weave-note-body input[type="checkbox"]{accent-color:var(--weave-accent);cursor:pointer}
|
|
461
|
+
.weave-note-body input[type="checkbox"]:disabled{cursor:wait}
|
|
465
462
|
.weave-note-body blockquote{
|
|
466
463
|
padding-left:10px;border-left:2px solid var(--weave-line-strong);color:var(--weave-dim);
|
|
467
464
|
}
|
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
|
}
|
|
@@ -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
|
}
|