kaizen-agent 1.32.0 → 1.33.0
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 +52 -0
- package/cli/board.test.ts +15 -21
- package/cli/browser.test.ts +3 -0
- package/cli/browser.ts +4 -2
- package/cli/build-web.ts +19 -9
- package/cli/notes.test.ts +4 -2
- package/cli/sources.test.ts +2 -2
- package/cli/state.ts +1 -1
- package/cli/web.ts +11 -3
- package/package.json +5 -2
- package/skills/kaizen/spec.md +5 -0
- package/web/board/agent-term.ts +51 -0
- package/web/board/api.ts +102 -0
- package/web/board/approvals.ts +100 -0
- package/web/board/cards.ts +600 -0
- package/web/board/chat.ts +108 -0
- package/web/board/dictation.ts +300 -0
- package/web/board/dom.ts +5 -0
- package/web/board/drafts.ts +21 -0
- package/web/board/fixes.ts +90 -0
- package/web/board/git.ts +76 -0
- package/web/board/icebox.ts +869 -0
- package/web/board/keys.ts +126 -0
- package/web/board/labels.ts +40 -0
- package/web/board/live.ts +69 -0
- package/web/board/main.ts +19 -0
- package/web/board/markdown.ts +197 -0
- package/web/board/panel.ts +331 -0
- package/web/board/projects.ts +220 -0
- package/web/board/settings.ts +191 -0
- package/web/board/store.ts +33 -0
- package/web/board/toast.ts +32 -0
- package/web/board/update.ts +75 -0
- package/web/board/views.ts +65 -0
- package/web/board.html +5 -985
- package/web/board.js +30 -0
- package/web/notebook.js +1 -1
- package/web/notebook.ts +726 -180
- package/web/notes-lib.ts +51 -15
- package/web/notes.html +1 -1
- package/web/term.js +1 -1
- package/web/term.ts +17 -5
package/web/notebook.ts
CHANGED
|
@@ -1,52 +1,104 @@
|
|
|
1
1
|
// The notebook page. Bundled into web/notebook.js by `bun run build:web`; the page
|
|
2
2
|
// never sees the filesystem, only /vault. See design.md, "Notebook", for the look.
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
EditorView,
|
|
5
|
+
Decoration,
|
|
6
|
+
ViewPlugin,
|
|
7
|
+
WidgetType,
|
|
8
|
+
keymap,
|
|
9
|
+
type DecorationSet,
|
|
10
|
+
type ViewUpdate,
|
|
11
|
+
} from "@codemirror/view";
|
|
4
12
|
import { EditorState, StateEffect, StateField, type Range } from "@codemirror/state";
|
|
5
13
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
|
6
14
|
import { syntaxTree, syntaxHighlighting, HighlightStyle } from "@codemirror/language";
|
|
7
15
|
import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown";
|
|
8
16
|
import { tags as t } from "@lezer/highlight";
|
|
9
17
|
import { autocompletion, startCompletion, type CompletionContext, type Completion } from "@codemirror/autocomplete";
|
|
10
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
wikilinks,
|
|
20
|
+
tags,
|
|
21
|
+
resolve,
|
|
22
|
+
safeUrl,
|
|
23
|
+
tree,
|
|
24
|
+
freshName,
|
|
25
|
+
parseTable,
|
|
26
|
+
boardLink,
|
|
27
|
+
resolveRun,
|
|
28
|
+
anchorWords,
|
|
29
|
+
TABS,
|
|
30
|
+
type Tree,
|
|
31
|
+
type BoardLink,
|
|
32
|
+
} from "./notes-lib.ts";
|
|
11
33
|
|
|
12
34
|
type Note = { name: string; mtime: number; text: string };
|
|
13
35
|
|
|
14
36
|
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
|
15
|
-
const esc = (s: string) =>
|
|
37
|
+
const esc = (s: string) =>
|
|
38
|
+
String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
|
16
39
|
const ico = (n: string) => `<svg><use href="#i-${n}"/></svg>`;
|
|
17
40
|
const last = (name: string) => name.split("/").pop()!;
|
|
18
|
-
const folderOf = (name: string) => name.includes("/") ? name.slice(0, name.lastIndexOf("/")) : "";
|
|
41
|
+
const folderOf = (name: string) => (name.includes("/") ? name.slice(0, name.lastIndexOf("/")) : "");
|
|
19
42
|
|
|
20
43
|
type Board = {
|
|
21
|
-
runs: {
|
|
44
|
+
runs: {
|
|
45
|
+
id: string;
|
|
46
|
+
short: string;
|
|
47
|
+
title: string;
|
|
48
|
+
status: string;
|
|
49
|
+
findings: { n: number; text: string; done: boolean }[];
|
|
50
|
+
backlog: { n: number; status: string; text: string }[];
|
|
51
|
+
}[];
|
|
22
52
|
ideas: { text: string; status: string }[];
|
|
23
53
|
};
|
|
24
|
-
let board: Board = { runs: [], ideas: [] };
|
|
25
|
-
let dir = "";
|
|
54
|
+
let board: Board = { runs: [], ideas: [] }; // what [[run:…]] and [[idea:…]] can name, from /links
|
|
55
|
+
let dir = ""; // the state dir whose notes/ is shown
|
|
26
56
|
let notes: Note[] = [];
|
|
27
|
-
let open: string | null = null;
|
|
28
|
-
let base = 0;
|
|
29
|
-
let dirty = false,
|
|
57
|
+
let open: string | null = null; // the note in the editor
|
|
58
|
+
let base = 0; // the mtime it was loaded or last saved at
|
|
59
|
+
let dirty = false,
|
|
60
|
+
saving: Promise<void> | null = null,
|
|
61
|
+
timer: ReturnType<typeof setTimeout> | undefined;
|
|
30
62
|
let conflict: { text: string; mtime: number } | null = null;
|
|
31
63
|
let confirmDelete = false;
|
|
32
|
-
const shut = new Set<string>();
|
|
64
|
+
const shut = new Set<string>(); // folders folded in the tree, for this page load
|
|
33
65
|
|
|
34
|
-
function toast(msg: string) {
|
|
66
|
+
function toast(msg: string) {
|
|
67
|
+
const el = $("toast");
|
|
68
|
+
el.textContent = msg;
|
|
69
|
+
el.classList.add("on");
|
|
70
|
+
setTimeout(() => el.classList.remove("on"), 2600);
|
|
71
|
+
}
|
|
35
72
|
async function post(body: Record<string, unknown>) {
|
|
36
73
|
try {
|
|
37
|
-
const r = await fetch("/vault", {
|
|
74
|
+
const r = await fetch("/vault", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "content-type": "application/json" },
|
|
77
|
+
body: JSON.stringify({ dir, ...body }),
|
|
78
|
+
});
|
|
38
79
|
return { status: r.status, ...(await r.json().catch(() => ({}))) };
|
|
39
|
-
} catch {
|
|
80
|
+
} catch {
|
|
81
|
+
return { status: 0, error: "The board is not running" };
|
|
82
|
+
}
|
|
40
83
|
}
|
|
41
84
|
|
|
42
85
|
// ---- live preview: markdown hidden and typeset on every line the cursor is not on
|
|
43
86
|
|
|
44
87
|
class Check extends WidgetType {
|
|
45
|
-
constructor(
|
|
46
|
-
|
|
88
|
+
constructor(
|
|
89
|
+
readonly on: boolean,
|
|
90
|
+
readonly at: number,
|
|
91
|
+
) {
|
|
92
|
+
super();
|
|
93
|
+
}
|
|
94
|
+
eq(o: Check) {
|
|
95
|
+
return o.on === this.on && o.at === this.at;
|
|
96
|
+
}
|
|
47
97
|
toDOM(view: EditorView) {
|
|
48
98
|
const b = document.createElement("input");
|
|
49
|
-
b.type = "checkbox";
|
|
99
|
+
b.type = "checkbox";
|
|
100
|
+
b.checked = this.on;
|
|
101
|
+
b.className = "cm-task";
|
|
50
102
|
b.setAttribute("aria-label", this.on ? "done" : "to do");
|
|
51
103
|
b.addEventListener("mousedown", (e) => {
|
|
52
104
|
e.preventDefault();
|
|
@@ -54,58 +106,114 @@ class Check extends WidgetType {
|
|
|
54
106
|
});
|
|
55
107
|
return b;
|
|
56
108
|
}
|
|
57
|
-
ignoreEvent() {
|
|
109
|
+
ignoreEvent() {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
58
112
|
}
|
|
59
113
|
class Bullet extends WidgetType {
|
|
60
|
-
eq() {
|
|
61
|
-
|
|
114
|
+
eq() {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
toDOM() {
|
|
118
|
+
const s = document.createElement("span");
|
|
119
|
+
s.className = "cm-bullet";
|
|
120
|
+
s.textContent = "•";
|
|
121
|
+
return s;
|
|
122
|
+
}
|
|
62
123
|
}
|
|
63
124
|
class Rule extends WidgetType {
|
|
64
|
-
eq() {
|
|
65
|
-
|
|
125
|
+
eq() {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
toDOM() {
|
|
129
|
+
const s = document.createElement("span");
|
|
130
|
+
s.className = "cm-rule";
|
|
131
|
+
return s;
|
|
132
|
+
}
|
|
66
133
|
}
|
|
67
134
|
class Img extends WidgetType {
|
|
68
|
-
constructor(
|
|
69
|
-
|
|
70
|
-
|
|
135
|
+
constructor(
|
|
136
|
+
readonly src: string,
|
|
137
|
+
readonly alt: string,
|
|
138
|
+
) {
|
|
139
|
+
super();
|
|
140
|
+
}
|
|
141
|
+
eq(o: Img) {
|
|
142
|
+
return o.src === this.src && o.alt === this.alt;
|
|
143
|
+
}
|
|
144
|
+
toDOM() {
|
|
145
|
+
const i = document.createElement("img");
|
|
146
|
+
i.src = this.src;
|
|
147
|
+
i.alt = this.alt;
|
|
148
|
+
i.className = "cm-img";
|
|
149
|
+
return i;
|
|
150
|
+
}
|
|
71
151
|
}
|
|
72
152
|
|
|
73
153
|
// A table the cursor is not in is drawn as one; clicking it puts the cursor in its
|
|
74
154
|
// source. Cells are text nodes: nothing in a note becomes markup.
|
|
75
155
|
class TableView extends WidgetType {
|
|
76
|
-
constructor(
|
|
77
|
-
|
|
156
|
+
constructor(
|
|
157
|
+
readonly src: string,
|
|
158
|
+
readonly at: number,
|
|
159
|
+
) {
|
|
160
|
+
super();
|
|
161
|
+
}
|
|
162
|
+
eq(o: TableView) {
|
|
163
|
+
return o.src === this.src && o.at === this.at;
|
|
164
|
+
}
|
|
78
165
|
toDOM(view: EditorView) {
|
|
79
|
-
const t = parseTable(this.src),
|
|
166
|
+
const t = parseTable(this.src),
|
|
167
|
+
wrap = document.createElement("div"),
|
|
168
|
+
table = document.createElement("table");
|
|
80
169
|
wrap.className = "cm-table";
|
|
81
170
|
const row = (cells: string[], tag: "th" | "td") => {
|
|
82
171
|
const tr = document.createElement("tr");
|
|
83
|
-
cells.forEach((c, i) => {
|
|
172
|
+
cells.forEach((c, i) => {
|
|
173
|
+
const el = document.createElement(tag);
|
|
174
|
+
el.textContent = c;
|
|
175
|
+
if (t.align[i]) el.style.textAlign = t.align[i]!;
|
|
176
|
+
tr.append(el);
|
|
177
|
+
});
|
|
84
178
|
return tr;
|
|
85
179
|
};
|
|
86
|
-
const head = document.createElement("thead"),
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
180
|
+
const head = document.createElement("thead"),
|
|
181
|
+
body = document.createElement("tbody");
|
|
182
|
+
head.append(row(t.head, "th"));
|
|
183
|
+
t.rows.forEach((r) => body.append(row(r, "td")));
|
|
184
|
+
table.append(head, body);
|
|
185
|
+
wrap.append(table);
|
|
186
|
+
wrap.addEventListener("mousedown", (e) => {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
view.dispatch({ selection: { anchor: this.at } });
|
|
189
|
+
view.focus();
|
|
190
|
+
});
|
|
90
191
|
return wrap;
|
|
91
192
|
}
|
|
92
|
-
ignoreEvent() {
|
|
193
|
+
ignoreEvent() {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
93
196
|
}
|
|
94
197
|
// Whether the editor has focus, as state: an unfocused editor shows every table drawn.
|
|
95
198
|
const focusSet = StateEffect.define<boolean>();
|
|
96
199
|
const focused = StateField.define<boolean>({
|
|
97
200
|
create: () => false,
|
|
98
|
-
update: (v, tr) => tr.effects.reduce((f, e) => e.is(focusSet) ? e.value : f, v),
|
|
201
|
+
update: (v, tr) => tr.effects.reduce((f, e) => (e.is(focusSet) ? e.value : f), v),
|
|
99
202
|
});
|
|
100
203
|
// Block widgets may span lines, which a view plugin may not do: tables live in a field.
|
|
101
204
|
function tables(state: EditorState) {
|
|
102
|
-
const out: Range<Decoration>[] = [],
|
|
205
|
+
const out: Range<Decoration>[] = [],
|
|
206
|
+
sel = state.selection.main,
|
|
207
|
+
live = state.field(focused, false);
|
|
103
208
|
syntaxTree(state).iterate({
|
|
104
209
|
enter(n) {
|
|
105
210
|
if (n.name !== "Table") return;
|
|
106
|
-
const from = state.doc.lineAt(n.from).from,
|
|
211
|
+
const from = state.doc.lineAt(n.from).from,
|
|
212
|
+
to = state.doc.lineAt(n.to).to;
|
|
107
213
|
if (live && sel.to >= from && sel.from <= to) return false;
|
|
108
|
-
out.push(
|
|
214
|
+
out.push(
|
|
215
|
+
Decoration.replace({ widget: new TableView(state.sliceDoc(from, to), from), block: true }).range(from, to),
|
|
216
|
+
);
|
|
109
217
|
return false;
|
|
110
218
|
},
|
|
111
219
|
});
|
|
@@ -113,36 +221,77 @@ function tables(state: EditorState) {
|
|
|
113
221
|
}
|
|
114
222
|
const tableField = StateField.define<DecorationSet>({
|
|
115
223
|
create: tables,
|
|
116
|
-
update: (v, tr) =>
|
|
224
|
+
update: (v, tr) =>
|
|
225
|
+
tr.docChanged ||
|
|
226
|
+
tr.selection ||
|
|
227
|
+
tr.effects.some((e) => e.is(focusSet)) ||
|
|
228
|
+
syntaxTree(tr.startState) !== syntaxTree(tr.state)
|
|
229
|
+
? tables(tr.state)
|
|
230
|
+
: v,
|
|
117
231
|
provide: (f) => EditorView.decorations.from(f),
|
|
118
232
|
});
|
|
119
233
|
|
|
120
234
|
// A link to the board: where it goes, what the chip says, and whether it is there.
|
|
121
|
-
const ICON: Record<string, string> = {
|
|
235
|
+
const ICON: Record<string, string> = {
|
|
236
|
+
idea: "idea",
|
|
237
|
+
starting: "idea",
|
|
238
|
+
running: "run",
|
|
239
|
+
stalled: "stall",
|
|
240
|
+
waiting: "wait",
|
|
241
|
+
done: "done",
|
|
242
|
+
abandoned: "aband",
|
|
243
|
+
};
|
|
122
244
|
function boardTarget(l: BoardLink) {
|
|
123
245
|
const q = (o: Record<string, string>) => "/?" + new URLSearchParams({ all: "1", dir, ...o }).toString();
|
|
124
246
|
if (l.kind === "idea") {
|
|
125
247
|
const t = l.text.toLowerCase();
|
|
126
|
-
const idea =
|
|
127
|
-
|
|
248
|
+
const idea =
|
|
249
|
+
board.ideas.find((i) => i.text.toLowerCase() === t) ??
|
|
250
|
+
board.ideas.find((i) => i.text.toLowerCase().startsWith(t));
|
|
251
|
+
return {
|
|
252
|
+
name: idea?.text ?? l.text,
|
|
253
|
+
sub: "idea",
|
|
254
|
+
status: idea?.status ?? "",
|
|
255
|
+
href: idea ? q({ idea: idea.text }) : null,
|
|
256
|
+
};
|
|
128
257
|
}
|
|
129
|
-
const id = resolveRun(
|
|
258
|
+
const id = resolveRun(
|
|
259
|
+
l.id,
|
|
260
|
+
board.runs.map((r) => r.id),
|
|
261
|
+
),
|
|
262
|
+
run = board.runs.find((r) => r.id === id);
|
|
130
263
|
const item = l.item && run && (l.item.type === "finding" ? run.findings : run.backlog).some((x) => x.n === l.item!.n);
|
|
131
264
|
const ok = !!run && (!l.item || item);
|
|
132
265
|
return {
|
|
133
|
-
name: run?.short ?? l.id,
|
|
134
|
-
|
|
266
|
+
name: run?.short ?? l.id,
|
|
267
|
+
sub: anchorWords(l),
|
|
268
|
+
status: run?.status ?? "",
|
|
269
|
+
href: ok
|
|
270
|
+
? q({ run: run!.id, ...(l.tab ? { tab: l.tab } : {}), ...(l.item ? { item: `${l.item.type}-${l.item.n}` } : {}) })
|
|
271
|
+
: null,
|
|
135
272
|
};
|
|
136
273
|
}
|
|
137
274
|
// Leave for the board only once the note is on disk.
|
|
138
275
|
async function go(href: string) {
|
|
139
276
|
await flush();
|
|
140
|
-
if (dirty) {
|
|
277
|
+
if (dirty) {
|
|
278
|
+
toast("This note is not saved yet");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
141
281
|
location.href = href;
|
|
142
282
|
}
|
|
143
283
|
class BoardChip extends WidgetType {
|
|
144
|
-
constructor(
|
|
145
|
-
|
|
284
|
+
constructor(
|
|
285
|
+
readonly name: string,
|
|
286
|
+
readonly sub: string,
|
|
287
|
+
readonly status: string,
|
|
288
|
+
readonly href: string | null,
|
|
289
|
+
) {
|
|
290
|
+
super();
|
|
291
|
+
}
|
|
292
|
+
eq(o: BoardChip) {
|
|
293
|
+
return o.name === this.name && o.sub === this.sub && o.status === this.status && o.href === this.href;
|
|
294
|
+
}
|
|
146
295
|
toDOM() {
|
|
147
296
|
const c = document.createElement("span");
|
|
148
297
|
c.className = "cm-board" + (this.href ? "" : " cm-board-missing");
|
|
@@ -154,125 +303,234 @@ class BoardChip extends WidgetType {
|
|
|
154
303
|
c.append(ic);
|
|
155
304
|
}
|
|
156
305
|
c.append(this.name);
|
|
157
|
-
if (this.sub) {
|
|
158
|
-
|
|
306
|
+
if (this.sub) {
|
|
307
|
+
const s = document.createElement("span");
|
|
308
|
+
s.className = "sub";
|
|
309
|
+
s.textContent = "› " + this.sub;
|
|
310
|
+
c.append(s);
|
|
311
|
+
}
|
|
312
|
+
c.addEventListener("mousedown", (e) => {
|
|
313
|
+
e.preventDefault();
|
|
314
|
+
if (this.href) void go(this.href);
|
|
315
|
+
else toast("That is not on the board");
|
|
316
|
+
});
|
|
159
317
|
return c;
|
|
160
318
|
}
|
|
161
|
-
ignoreEvent() {
|
|
319
|
+
ignoreEvent() {
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
162
322
|
}
|
|
163
323
|
|
|
164
324
|
const hide = Decoration.replace({});
|
|
165
|
-
const refresh = StateEffect.define<null>();
|
|
325
|
+
const refresh = StateEffect.define<null>(); // the list of notes changed: redraw missing links
|
|
166
326
|
|
|
167
327
|
function build(view: EditorView): DecorationSet {
|
|
168
|
-
const { state } = view,
|
|
328
|
+
const { state } = view,
|
|
329
|
+
doc = state.doc,
|
|
330
|
+
out: Range<Decoration>[] = [];
|
|
169
331
|
const live = new Set<number>();
|
|
170
332
|
// Only a focused editor has a cursor line: a note just opened reads fully typeset.
|
|
171
|
-
if (view.hasFocus)
|
|
333
|
+
if (view.hasFocus)
|
|
334
|
+
for (const r of state.selection.ranges)
|
|
335
|
+
for (let l = doc.lineAt(r.from).number; l <= doc.lineAt(r.to).number; l++) live.add(l);
|
|
172
336
|
const on = (pos: number) => live.has(doc.lineAt(pos).number);
|
|
173
337
|
// A plugin may not replace a line break: anything that would is left as written.
|
|
174
|
-
const put = (d: Decoration, from: number, to: number) => {
|
|
338
|
+
const put = (d: Decoration, from: number, to: number) => {
|
|
339
|
+
if (doc.lineAt(from).number === doc.lineAt(to).number) out.push(d.range(from, to));
|
|
340
|
+
};
|
|
175
341
|
const lines = (from: number, to: number, cls: string) => {
|
|
176
|
-
for (let l = doc.lineAt(from).number; l <= doc.lineAt(to).number; l++)
|
|
342
|
+
for (let l = doc.lineAt(from).number; l <= doc.lineAt(to).number; l++)
|
|
343
|
+
out.push(Decoration.line({ class: cls }).range(doc.line(l).from));
|
|
177
344
|
};
|
|
178
345
|
for (const { from, to } of view.visibleRanges) {
|
|
179
346
|
syntaxTree(state).iterate({
|
|
180
|
-
from,
|
|
347
|
+
from,
|
|
348
|
+
to,
|
|
181
349
|
enter(n) {
|
|
182
350
|
const name = n.name;
|
|
183
351
|
const h = /^ATXHeading(\d)$/.exec(name);
|
|
184
|
-
if (h) {
|
|
352
|
+
if (h) {
|
|
353
|
+
lines(n.from, n.from, `cm-h cm-h${Math.min(+h[1]!, 4)}`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
185
356
|
if (name === "HeaderMark" && n.node.parent?.name.startsWith("ATX")) {
|
|
186
|
-
if (!on(n.from))
|
|
357
|
+
if (!on(n.from))
|
|
358
|
+
put(
|
|
359
|
+
hide,
|
|
360
|
+
n.from,
|
|
361
|
+
Math.min(n.to + (doc.sliceString(n.to, n.to + 1) === " " ? 1 : 0), doc.lineAt(n.from).to),
|
|
362
|
+
);
|
|
187
363
|
return;
|
|
188
364
|
}
|
|
189
|
-
if (
|
|
365
|
+
if (
|
|
366
|
+
name === "EmphasisMark" ||
|
|
367
|
+
name === "StrikethroughMark" ||
|
|
368
|
+
(name === "CodeMark" && n.node.parent?.name === "InlineCode")
|
|
369
|
+
) {
|
|
190
370
|
if (!on(n.from)) put(hide, n.from, n.to);
|
|
191
371
|
return;
|
|
192
372
|
}
|
|
193
|
-
if (name === "Blockquote") {
|
|
373
|
+
if (name === "Blockquote") {
|
|
374
|
+
lines(n.from, n.to, "cm-quote");
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
194
377
|
if (name === "QuoteMark") {
|
|
195
|
-
if (!on(n.from))
|
|
378
|
+
if (!on(n.from))
|
|
379
|
+
put(
|
|
380
|
+
hide,
|
|
381
|
+
n.from,
|
|
382
|
+
Math.min(n.to + (doc.sliceString(n.to, n.to + 1) === " " ? 1 : 0), doc.lineAt(n.from).to),
|
|
383
|
+
);
|
|
196
384
|
return;
|
|
197
385
|
}
|
|
198
386
|
if (name === "FencedCode") {
|
|
199
387
|
lines(n.from, n.to, "cm-fence");
|
|
200
|
-
out.push(
|
|
388
|
+
out.push(
|
|
389
|
+
Decoration.line({ class: "cm-fence-a" }).range(doc.lineAt(n.from).from),
|
|
390
|
+
Decoration.line({ class: "cm-fence-z" }).range(doc.lineAt(n.to).from),
|
|
391
|
+
);
|
|
201
392
|
return false;
|
|
202
393
|
}
|
|
203
|
-
if (name === "HorizontalRule") {
|
|
394
|
+
if (name === "HorizontalRule") {
|
|
395
|
+
if (!on(n.from)) put(Decoration.replace({ widget: new Rule() }), n.from, n.to);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
204
398
|
if (name === "ListMark" && n.node.parent?.parent?.name === "BulletList") {
|
|
205
399
|
if (on(n.from)) return;
|
|
206
400
|
const task = n.node.nextSibling?.name === "Task";
|
|
207
|
-
put(
|
|
401
|
+
put(
|
|
402
|
+
task ? hide : Decoration.replace({ widget: new Bullet() }),
|
|
403
|
+
n.from,
|
|
404
|
+
task ? Math.min(n.to + 1, doc.lineAt(n.from).to) : n.to,
|
|
405
|
+
);
|
|
208
406
|
return;
|
|
209
407
|
}
|
|
210
408
|
if (name === "TaskMarker") {
|
|
211
|
-
if (!on(n.from))
|
|
409
|
+
if (!on(n.from))
|
|
410
|
+
put(
|
|
411
|
+
Decoration.replace({ widget: new Check(/x/i.test(doc.sliceString(n.from, n.to)), n.from) }),
|
|
412
|
+
n.from,
|
|
413
|
+
n.to,
|
|
414
|
+
);
|
|
212
415
|
return;
|
|
213
416
|
}
|
|
214
417
|
if (name === "Image") {
|
|
215
|
-
const url = n.node.getChild("URL"),
|
|
418
|
+
const url = n.node.getChild("URL"),
|
|
419
|
+
marks = n.node.getChildren("LinkMark");
|
|
216
420
|
if (on(n.from) || !url || marks.length < 2) return false;
|
|
217
421
|
const src = safeUrl(doc.sliceString(url.from, url.to));
|
|
218
|
-
if (src && /^https?:/i.test(src))
|
|
422
|
+
if (src && /^https?:/i.test(src))
|
|
423
|
+
put(
|
|
424
|
+
Decoration.replace({ widget: new Img(src, doc.sliceString(marks[0]!.to, marks[1]!.from)) }),
|
|
425
|
+
n.from,
|
|
426
|
+
n.to,
|
|
427
|
+
);
|
|
219
428
|
return false;
|
|
220
429
|
}
|
|
221
430
|
if (name === "Link") {
|
|
222
|
-
const url = n.node.getChild("URL"),
|
|
431
|
+
const url = n.node.getChild("URL"),
|
|
432
|
+
marks = n.node.getChildren("LinkMark");
|
|
223
433
|
// Only the inline form `[text](url)`; a reference link stays as written.
|
|
224
434
|
if (!url || marks.length < 4) return;
|
|
225
435
|
const href = safeUrl(doc.sliceString(url.from, url.to));
|
|
226
436
|
const text = { from: marks[0]!.to, to: marks[1]!.from };
|
|
227
437
|
// A link to somewhere safeUrl refuses stays as written, so what it points at is visible.
|
|
228
|
-
if (on(n.from) || !href) {
|
|
229
|
-
|
|
230
|
-
|
|
438
|
+
if (on(n.from) || !href) {
|
|
439
|
+
out.push(Decoration.mark({ class: "cm-link-src" }).range(n.from, n.to));
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (text.to > text.from)
|
|
443
|
+
out.push(
|
|
444
|
+
Decoration.mark({ class: "cm-link", attributes: { "data-href": href } }).range(text.from, text.to),
|
|
445
|
+
);
|
|
446
|
+
put(hide, marks[0]!.from, marks[0]!.to);
|
|
447
|
+
put(hide, marks[1]!.from, n.to);
|
|
231
448
|
return;
|
|
232
449
|
}
|
|
233
450
|
if (name === "URL" && n.node.parent?.name !== "Link" && n.node.parent?.name !== "Image") {
|
|
234
451
|
const href = safeUrl(doc.sliceString(n.from, n.to));
|
|
235
|
-
out.push(
|
|
452
|
+
out.push(
|
|
453
|
+
Decoration.mark({ class: "cm-link", attributes: href && !on(n.from) ? { "data-href": href } : {} }).range(
|
|
454
|
+
n.from,
|
|
455
|
+
n.to,
|
|
456
|
+
),
|
|
457
|
+
);
|
|
236
458
|
}
|
|
237
459
|
},
|
|
238
460
|
});
|
|
239
461
|
}
|
|
240
462
|
// Wikilinks and tags are not markdown: found by notes-lib over the text.
|
|
241
463
|
// ponytail: the whole note is scanned on each redraw; limit to visible ranges if notes reach megabytes.
|
|
242
|
-
const text = doc.toString(),
|
|
464
|
+
const text = doc.toString(),
|
|
465
|
+
names = notes.map((x) => x.name);
|
|
243
466
|
for (const w of wikilinks(text)) {
|
|
244
467
|
const bl = boardLink(w);
|
|
245
468
|
if (bl) {
|
|
246
|
-
if (on(w.from)) {
|
|
469
|
+
if (on(w.from)) {
|
|
470
|
+
out.push(Decoration.mark({ class: "cm-wiki" }).range(w.from, w.to));
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
247
473
|
const t = boardTarget(bl);
|
|
248
|
-
put(
|
|
474
|
+
put(
|
|
475
|
+
Decoration.replace({ widget: new BoardChip(w.label !== w.target ? w.label : t.name, t.sub, t.status, t.href) }),
|
|
476
|
+
w.from,
|
|
477
|
+
w.to,
|
|
478
|
+
);
|
|
249
479
|
continue;
|
|
250
480
|
}
|
|
251
481
|
const target = resolve(w.name, names);
|
|
252
482
|
const cls = "cm-wiki" + (target ? "" : " cm-wiki-missing");
|
|
253
|
-
if (on(w.from)) {
|
|
483
|
+
if (on(w.from)) {
|
|
484
|
+
out.push(Decoration.mark({ class: cls }).range(w.from, w.to));
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
254
487
|
const labelAt = text.lastIndexOf(w.label, w.to - 2);
|
|
255
|
-
const lf = labelAt > w.from ? labelAt : w.from + 2,
|
|
256
|
-
|
|
488
|
+
const lf = labelAt > w.from ? labelAt : w.from + 2,
|
|
489
|
+
lt = lf + w.label.length;
|
|
490
|
+
put(hide, w.from, lf);
|
|
491
|
+
out.push(Decoration.mark({ class: cls, attributes: { "data-note": target ?? w.name } }).range(lf, lt));
|
|
492
|
+
put(hide, lt, w.to);
|
|
257
493
|
}
|
|
258
|
-
for (const g of tags(text))
|
|
494
|
+
for (const g of tags(text))
|
|
495
|
+
out.push(
|
|
496
|
+
Decoration.mark({ class: "cm-tag", attributes: on(g.from) ? {} : { "data-tag": g.tag } }).range(g.from, g.to),
|
|
497
|
+
);
|
|
259
498
|
return Decoration.set(out, true);
|
|
260
499
|
}
|
|
261
500
|
|
|
262
|
-
const preview = ViewPlugin.fromClass(
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
501
|
+
const preview = ViewPlugin.fromClass(
|
|
502
|
+
class {
|
|
503
|
+
decorations: DecorationSet;
|
|
504
|
+
constructor(view: EditorView) {
|
|
505
|
+
this.decorations = build(view);
|
|
506
|
+
}
|
|
507
|
+
update(u: ViewUpdate) {
|
|
508
|
+
if (
|
|
509
|
+
u.docChanged ||
|
|
510
|
+
u.viewportChanged ||
|
|
511
|
+
u.selectionSet ||
|
|
512
|
+
u.focusChanged ||
|
|
513
|
+
syntaxTree(u.startState) !== syntaxTree(u.state) ||
|
|
514
|
+
u.transactions.some((tr) => tr.effects.some((e) => e.is(refresh)))
|
|
515
|
+
)
|
|
516
|
+
this.decorations = build(u.view);
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
{ decorations: (v) => v.decorations },
|
|
520
|
+
);
|
|
270
521
|
|
|
271
522
|
const look = HighlightStyle.define([
|
|
272
523
|
{ tag: t.emphasis, fontStyle: "italic" },
|
|
273
524
|
{ tag: t.strong, fontWeight: "600" },
|
|
274
525
|
{ tag: t.strikethrough, textDecoration: "line-through" },
|
|
275
|
-
{
|
|
526
|
+
{
|
|
527
|
+
tag: t.monospace,
|
|
528
|
+
fontFamily: "var(--mono)",
|
|
529
|
+
fontSize: "0.86em",
|
|
530
|
+
background: "var(--bg)",
|
|
531
|
+
borderRadius: "var(--r-s)",
|
|
532
|
+
padding: "1px 4px",
|
|
533
|
+
},
|
|
276
534
|
{ tag: [t.processingInstruction, t.meta, t.url, t.labelName, t.contentSeparator], color: "var(--dim)" },
|
|
277
535
|
{ tag: t.link, color: "var(--accent)" },
|
|
278
536
|
{ tag: t.quote, color: "var(--dim)" },
|
|
@@ -287,12 +545,23 @@ const clicks = EditorView.domEventHandlers({
|
|
|
287
545
|
const href = el.dataset.href;
|
|
288
546
|
// A relative link is a note (`[x](Other%20note.md)`), as in Obsidian; the rest open in a tab.
|
|
289
547
|
if (href && !/^[a-z][a-z0-9+.-]*:/i.test(href)) {
|
|
290
|
-
let name = href;
|
|
291
|
-
|
|
548
|
+
let name = href;
|
|
549
|
+
try {
|
|
550
|
+
name = decodeURIComponent(href);
|
|
551
|
+
} catch {
|
|
552
|
+
/* as written */
|
|
553
|
+
}
|
|
554
|
+
const hit = resolve(
|
|
555
|
+
name.split("#")[0]!,
|
|
556
|
+
notes.map((x) => x.name),
|
|
557
|
+
);
|
|
292
558
|
hit ? void openNote(hit) : toast(`No note named ${name}`);
|
|
293
559
|
} else if (href) window.open(href, "_blank", "noopener");
|
|
294
560
|
else if (el.dataset.note) openOrCreate(el.dataset.note);
|
|
295
|
-
else if (el.dataset.tag) {
|
|
561
|
+
else if (el.dataset.tag) {
|
|
562
|
+
$<HTMLInputElement>("q").value = "#" + el.dataset.tag;
|
|
563
|
+
drawSide();
|
|
564
|
+
}
|
|
296
565
|
return true;
|
|
297
566
|
},
|
|
298
567
|
});
|
|
@@ -325,14 +594,27 @@ function slash(cx: CompletionContext) {
|
|
|
325
594
|
validFor: /^\/[\w ]*$/,
|
|
326
595
|
// Boosted in list order, so an unfiltered menu reads as written above.
|
|
327
596
|
options: BLOCKS.map(([label, src, detail], i): Completion => ({
|
|
328
|
-
label: "/" + label.toLowerCase(),
|
|
597
|
+
label: "/" + label.toLowerCase(),
|
|
598
|
+
displayLabel: label,
|
|
599
|
+
detail,
|
|
600
|
+
boost: 50 - i,
|
|
329
601
|
apply: (view, _c, from, to) => {
|
|
330
602
|
// Straight under a line of text, `---` would underline it into a heading and a
|
|
331
603
|
// table would join its paragraph: both need a blank line above.
|
|
332
604
|
const line = view.state.doc.lineAt(from);
|
|
333
|
-
const gap =
|
|
334
|
-
|
|
335
|
-
|
|
605
|
+
const gap =
|
|
606
|
+
(label === "Divider" || label === "Table") &&
|
|
607
|
+
line.number > 1 &&
|
|
608
|
+
view.state.doc.line(line.number - 1).text.trim()
|
|
609
|
+
? "\n"
|
|
610
|
+
: "";
|
|
611
|
+
const text = gap + src.replace("|", ""),
|
|
612
|
+
caret = from + gap.length + src.indexOf("|");
|
|
613
|
+
view.dispatch({
|
|
614
|
+
changes: { from, to, insert: text },
|
|
615
|
+
selection: { anchor: caret },
|
|
616
|
+
userEvent: "input.complete",
|
|
617
|
+
});
|
|
336
618
|
// A link is only begun: the next list offers what it can point at.
|
|
337
619
|
if (label.startsWith("Link to")) setTimeout(() => startCompletion(view));
|
|
338
620
|
},
|
|
@@ -346,33 +628,62 @@ function slash(cx: CompletionContext) {
|
|
|
346
628
|
function links(cx: CompletionContext) {
|
|
347
629
|
const m = cx.matchBefore(/\[\[[^\]\n|]*$/);
|
|
348
630
|
if (!m) return null;
|
|
349
|
-
const q = m.text.slice(2),
|
|
631
|
+
const q = m.text.slice(2),
|
|
632
|
+
close = cx.state.sliceDoc(cx.pos, cx.pos + 2) === "]]";
|
|
350
633
|
const put = (text: string, stay: boolean) => (view: EditorView, _c: Completion, from: number, to: number) => {
|
|
351
|
-
view.dispatch({
|
|
634
|
+
view.dispatch({
|
|
635
|
+
changes: { from, to, insert: text + (close ? "" : "]]") },
|
|
636
|
+
selection: { anchor: from + text.length + (stay ? 0 : 2) },
|
|
637
|
+
userEvent: "input.complete",
|
|
638
|
+
});
|
|
352
639
|
};
|
|
353
640
|
const anchor = /^run:\s*([\w.-]+)#([\w-]*)$/i.exec(q);
|
|
354
641
|
if (anchor) {
|
|
355
|
-
const id = resolveRun(
|
|
642
|
+
const id = resolveRun(
|
|
643
|
+
anchor[1]!,
|
|
644
|
+
board.runs.map((r) => r.id),
|
|
645
|
+
),
|
|
646
|
+
run = board.runs.find((r) => r.id === id);
|
|
356
647
|
if (!run) return null;
|
|
357
|
-
const cut = (t: string) => t.length > 60 ? t.slice(0, 59) + "…" : t;
|
|
648
|
+
const cut = (t: string) => (t.length > 60 ? t.slice(0, 59) + "…" : t);
|
|
358
649
|
return {
|
|
359
|
-
from: cx.pos - anchor[2]!.length,
|
|
650
|
+
from: cx.pos - anchor[2]!.length,
|
|
651
|
+
validFor: /^[\w-]*$/,
|
|
360
652
|
options: [
|
|
361
653
|
...TABS.map((t, i): Completion => ({ label: t, detail: "tab", boost: 20 - i, apply: put(t, false) })),
|
|
362
|
-
...run.findings.map((f): Completion => ({
|
|
363
|
-
|
|
654
|
+
...run.findings.map((f): Completion => ({
|
|
655
|
+
label: `finding-${f.n}`,
|
|
656
|
+
detail: cut(f.text) + (f.done ? " (closed)" : ""),
|
|
657
|
+
apply: put(`finding-${f.n}`, false),
|
|
658
|
+
})),
|
|
659
|
+
...run.backlog.map((b): Completion => ({
|
|
660
|
+
label: `backlog-${b.n}`,
|
|
661
|
+
detail: `${b.status}: ${cut(b.text)}`,
|
|
662
|
+
apply: put(`backlog-${b.n}`, false),
|
|
663
|
+
})),
|
|
364
664
|
],
|
|
365
665
|
};
|
|
366
666
|
}
|
|
367
667
|
return {
|
|
368
|
-
from: m.from + 2,
|
|
668
|
+
from: m.from + 2,
|
|
669
|
+
validFor: /^[^\]\n|#]*$/,
|
|
369
670
|
options: [
|
|
370
671
|
...notes.map((n): Completion => ({ label: n.name, detail: "note", apply: put(n.name, false) })),
|
|
371
672
|
// Newest first, by the name the board shows; the full id is what gets written.
|
|
372
|
-
...[...board.runs]
|
|
373
|
-
|
|
673
|
+
...[...board.runs]
|
|
674
|
+
.sort((a, b) => b.id.localeCompare(a.id))
|
|
675
|
+
.map((r, i): Completion => ({
|
|
676
|
+
label: "run:" + r.id,
|
|
677
|
+
displayLabel: r.short,
|
|
678
|
+
detail: r.id.slice(0, 10) + " · " + r.title,
|
|
679
|
+
boost: Math.max(-99, 60 - i),
|
|
680
|
+
apply: put("run:" + r.id, true),
|
|
681
|
+
})),
|
|
682
|
+
...board.ideas.map((i): Completion => ({
|
|
683
|
+
label: "idea:" + i.text,
|
|
684
|
+
detail: "idea",
|
|
685
|
+
apply: put("idea:" + i.text, false),
|
|
374
686
|
})),
|
|
375
|
-
...board.ideas.map((i): Completion => ({ label: "idea:" + i.text, detail: "idea", apply: put("idea:" + i.text, false) })),
|
|
376
687
|
],
|
|
377
688
|
};
|
|
378
689
|
}
|
|
@@ -380,14 +691,37 @@ function links(cx: CompletionContext) {
|
|
|
380
691
|
const exts = (crlf: boolean) => [
|
|
381
692
|
autocompletion({ override: [slash, links], icons: false }),
|
|
382
693
|
history(),
|
|
383
|
-
keymap.of([
|
|
694
|
+
keymap.of([
|
|
695
|
+
...markdownKeymap,
|
|
696
|
+
...defaultKeymap,
|
|
697
|
+
...historyKeymap,
|
|
698
|
+
indentWithTab,
|
|
699
|
+
{
|
|
700
|
+
key: "Mod-s",
|
|
701
|
+
run: () => {
|
|
702
|
+
void flush();
|
|
703
|
+
return true;
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
]),
|
|
384
707
|
markdown({ base: markdownLanguage }),
|
|
385
708
|
syntaxHighlighting(look),
|
|
386
709
|
EditorView.lineWrapping,
|
|
387
710
|
EditorView.contentAttributes.of({ "aria-label": "Note", spellcheck: "true" }),
|
|
388
|
-
focused,
|
|
389
|
-
|
|
390
|
-
|
|
711
|
+
focused,
|
|
712
|
+
EditorView.focusChangeEffect.of((_s, on) => focusSet.of(on)),
|
|
713
|
+
tableField,
|
|
714
|
+
preview,
|
|
715
|
+
clicks,
|
|
716
|
+
EditorView.updateListener.of((u) => {
|
|
717
|
+
if (u.docChanged && !loading) {
|
|
718
|
+
dirty = true;
|
|
719
|
+
conflict = null;
|
|
720
|
+
drawMeta();
|
|
721
|
+
clearTimeout(timer);
|
|
722
|
+
timer = setTimeout(() => void flush(), 600);
|
|
723
|
+
}
|
|
724
|
+
}),
|
|
391
725
|
crlf ? EditorState.lineSeparator.of("\r\n") : [],
|
|
392
726
|
];
|
|
393
727
|
|
|
@@ -397,7 +731,11 @@ const view = new EditorView({ parent: $("editor"), state: EditorState.create({ d
|
|
|
397
731
|
function setText(text: string, fresh: boolean) {
|
|
398
732
|
loading = true;
|
|
399
733
|
if (fresh) view.setState(EditorState.create({ doc: text, extensions: exts(text.includes("\r\n")) }));
|
|
400
|
-
else
|
|
734
|
+
else
|
|
735
|
+
view.dispatch({
|
|
736
|
+
changes: { from: 0, to: view.state.doc.length, insert: text },
|
|
737
|
+
selection: { anchor: Math.min(view.state.selection.main.head, text.length) },
|
|
738
|
+
});
|
|
401
739
|
loading = false;
|
|
402
740
|
}
|
|
403
741
|
|
|
@@ -408,37 +746,68 @@ async function flush(): Promise<void> {
|
|
|
408
746
|
clearTimeout(timer);
|
|
409
747
|
if (saving) await saving;
|
|
410
748
|
if (!open || !dirty || conflict) return;
|
|
411
|
-
const name = open,
|
|
749
|
+
const name = open,
|
|
750
|
+
text = view.state.sliceDoc();
|
|
412
751
|
let saved = false;
|
|
413
752
|
saving = (async () => {
|
|
414
753
|
drawMeta("Saving…");
|
|
415
754
|
const r = await post({ op: "save", name, text, base });
|
|
416
755
|
if (open !== name) return;
|
|
417
|
-
if (r.ok) {
|
|
418
|
-
|
|
756
|
+
if (r.ok) {
|
|
757
|
+
saved = true;
|
|
758
|
+
base = r.mtime;
|
|
759
|
+
if (view.state.sliceDoc() === text) dirty = false;
|
|
760
|
+
upsert({ name, mtime: r.mtime, text });
|
|
761
|
+
} else if (r.status === 409) conflict = { text: r.text ?? "", mtime: r.mtime ?? 0 };
|
|
419
762
|
else toast(r.error || "Not saved");
|
|
420
763
|
})();
|
|
421
|
-
await saving;
|
|
764
|
+
await saving;
|
|
765
|
+
saving = null;
|
|
422
766
|
drawMeta();
|
|
423
767
|
// Typed on while that save was out: save again. A failed save waits for the next keystroke.
|
|
424
|
-
if (saved && dirty && !conflict && open === name) {
|
|
768
|
+
if (saved && dirty && !conflict && open === name) {
|
|
769
|
+
clearTimeout(timer);
|
|
770
|
+
timer = setTimeout(() => void flush(), 600);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
function upsert(n: Note) {
|
|
774
|
+
notes = [n, ...notes.filter((x) => x.name !== n.name)];
|
|
775
|
+
drawSide();
|
|
776
|
+
drawLinks();
|
|
425
777
|
}
|
|
426
|
-
function upsert(n: Note) { notes = [n, ...notes.filter((x) => x.name !== n.name)]; drawSide(); drawLinks(); }
|
|
427
778
|
|
|
428
779
|
// ---- the vault
|
|
429
780
|
|
|
430
781
|
async function load() {
|
|
431
782
|
let r: { notes?: Note[]; error?: string };
|
|
432
|
-
try {
|
|
433
|
-
|
|
783
|
+
try {
|
|
784
|
+
r = await (await fetch("/vault?dir=" + encodeURIComponent(dir))).json();
|
|
785
|
+
} catch {
|
|
786
|
+
toast("The board is not running");
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (!r.notes) {
|
|
790
|
+
toast(r.error || "Could not read notes");
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
434
793
|
notes = r.notes;
|
|
435
794
|
// The board's items, for chips and completion; a failure keeps the last ones.
|
|
436
|
-
try {
|
|
795
|
+
try {
|
|
796
|
+
const b = await (await fetch("/links?dir=" + encodeURIComponent(dir))).json();
|
|
797
|
+
if (Array.isArray(b.runs)) board = b;
|
|
798
|
+
} catch {
|
|
799
|
+
/* keep */
|
|
800
|
+
}
|
|
437
801
|
const mine = open && notes.find((n) => n.name === open);
|
|
438
802
|
// Someone else changed the open note: take it only when nothing typed here is unsaved.
|
|
439
|
-
if (mine && mine.mtime !== base && !dirty && !saving) {
|
|
803
|
+
if (mine && mine.mtime !== base && !dirty && !saving) {
|
|
804
|
+
base = mine.mtime;
|
|
805
|
+
setText(mine.text, false);
|
|
806
|
+
}
|
|
440
807
|
view.dispatch({ effects: refresh.of(null) });
|
|
441
|
-
drawSide();
|
|
808
|
+
drawSide();
|
|
809
|
+
drawLinks();
|
|
810
|
+
drawPane();
|
|
442
811
|
}
|
|
443
812
|
|
|
444
813
|
// Laying down a page: the note being left is copied onto a sheet under the paper
|
|
@@ -451,12 +820,18 @@ const DEAL = { duration: 260, easing: "cubic-bezier(.22,.61,.36,1)" };
|
|
|
451
820
|
function lift(): HTMLElement | null {
|
|
452
821
|
const main = document.querySelector<HTMLElement>(".main")!;
|
|
453
822
|
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return null;
|
|
454
|
-
const r = main.getBoundingClientRect(),
|
|
823
|
+
const r = main.getBoundingClientRect(),
|
|
824
|
+
top = main.scrollTop;
|
|
455
825
|
const sheet = main.cloneNode(true) as HTMLElement;
|
|
456
826
|
sheet.classList.add("sheet");
|
|
457
827
|
sheet.setAttribute("aria-hidden", "true");
|
|
458
828
|
sheet.inert = true;
|
|
459
|
-
Object.assign(sheet.style, {
|
|
829
|
+
Object.assign(sheet.style, {
|
|
830
|
+
left: r.left + "px",
|
|
831
|
+
top: r.top + "px",
|
|
832
|
+
width: r.width + "px",
|
|
833
|
+
height: r.height + "px",
|
|
834
|
+
});
|
|
460
835
|
sheet.append(Object.assign(document.createElement("i"), { className: "shade" }));
|
|
461
836
|
// After the real page in document order, so every id still finds the real one first.
|
|
462
837
|
document.body.append(sheet);
|
|
@@ -467,10 +842,28 @@ function turn(sheet: HTMLElement | null) {
|
|
|
467
842
|
if (!sheet) return;
|
|
468
843
|
const main = document.querySelector<HTMLElement>(".main")!;
|
|
469
844
|
main.classList.add("dealt");
|
|
470
|
-
const done = () => {
|
|
471
|
-
|
|
845
|
+
const done = () => {
|
|
846
|
+
main.classList.remove("dealt");
|
|
847
|
+
sheet.remove();
|
|
848
|
+
};
|
|
849
|
+
main
|
|
850
|
+
.animate(
|
|
851
|
+
[
|
|
852
|
+
{ transform: "translateX(56px)", opacity: 0 },
|
|
853
|
+
{ transform: "none", opacity: 1 },
|
|
854
|
+
],
|
|
855
|
+
DEAL,
|
|
856
|
+
)
|
|
857
|
+
.finished.then(done, done);
|
|
472
858
|
// The old page is gone before the new one is fully up, so the two never read as one.
|
|
473
|
-
sheet.animate(
|
|
859
|
+
sheet.animate(
|
|
860
|
+
[
|
|
861
|
+
{ transform: "none", opacity: 1 },
|
|
862
|
+
{ transform: "translateX(-24px)", opacity: 0, offset: 0.6 },
|
|
863
|
+
{ transform: "translateX(-24px)", opacity: 0 },
|
|
864
|
+
],
|
|
865
|
+
{ ...DEAL, fill: "forwards" },
|
|
866
|
+
);
|
|
474
867
|
sheet.querySelector<HTMLElement>(".shade")!.animate([{ opacity: 0 }, { opacity: 1 }], { ...DEAL, fill: "forwards" });
|
|
475
868
|
}
|
|
476
869
|
|
|
@@ -478,43 +871,87 @@ function turn(sheet: HTMLElement | null) {
|
|
|
478
871
|
async function openNote(name: string | null): Promise<boolean> {
|
|
479
872
|
if (name === open) return true;
|
|
480
873
|
await flush();
|
|
481
|
-
if (dirty) {
|
|
874
|
+
if (dirty) {
|
|
875
|
+
toast(conflict ? "Choose Keep mine or Load theirs first" : "This note is not saved yet");
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
482
878
|
const sheet = lift();
|
|
483
|
-
open = name;
|
|
879
|
+
open = name;
|
|
880
|
+
conflict = null;
|
|
881
|
+
confirmDelete = false;
|
|
882
|
+
dirty = false;
|
|
484
883
|
const n = name ? notes.find((x) => x.name === name) : null;
|
|
485
884
|
base = n?.mtime ?? 0;
|
|
486
885
|
setText(n?.text ?? "", true);
|
|
487
|
-
try {
|
|
886
|
+
try {
|
|
887
|
+
name ? sessionStorage.setItem("kz-note:" + dir, name) : sessionStorage.removeItem("kz-note:" + dir);
|
|
888
|
+
} catch {
|
|
889
|
+
/* private window */
|
|
890
|
+
}
|
|
488
891
|
document.body.classList.remove("side-open");
|
|
489
|
-
drawSide();
|
|
892
|
+
drawSide();
|
|
893
|
+
drawPane();
|
|
894
|
+
drawLinks();
|
|
490
895
|
turn(sheet);
|
|
491
896
|
return true;
|
|
492
897
|
}
|
|
493
898
|
|
|
494
899
|
async function create(name: string) {
|
|
495
900
|
const r = await post({ op: "save", name, text: "", base: 0 });
|
|
496
|
-
if (!r.ok) {
|
|
901
|
+
if (!r.ok) {
|
|
902
|
+
toast(r.status === 409 ? "A note with that name exists" : r.error || "Could not create the note");
|
|
903
|
+
return false;
|
|
904
|
+
}
|
|
497
905
|
upsert({ name: r.name, mtime: r.mtime, text: "" });
|
|
498
906
|
return openNote(r.name);
|
|
499
907
|
}
|
|
500
908
|
async function openOrCreate(name: string) {
|
|
501
|
-
const hit = resolve(
|
|
909
|
+
const hit = resolve(
|
|
910
|
+
name,
|
|
911
|
+
notes.map((n) => n.name),
|
|
912
|
+
);
|
|
502
913
|
if (hit) return void openNote(hit);
|
|
503
914
|
if (await create(name)) view.focus();
|
|
504
915
|
}
|
|
505
916
|
async function newNote() {
|
|
506
|
-
if (
|
|
917
|
+
if (
|
|
918
|
+
await create(
|
|
919
|
+
freshName(
|
|
920
|
+
notes.map((n) => n.name),
|
|
921
|
+
open ? folderOf(open) : "",
|
|
922
|
+
),
|
|
923
|
+
)
|
|
924
|
+
) {
|
|
925
|
+
const t = $<HTMLInputElement>("title");
|
|
926
|
+
t.focus();
|
|
927
|
+
t.select();
|
|
928
|
+
}
|
|
507
929
|
}
|
|
508
930
|
|
|
509
931
|
async function rename(to: string) {
|
|
510
|
-
if (!open || !to.trim() || to.trim() === open) {
|
|
932
|
+
if (!open || !to.trim() || to.trim() === open) {
|
|
933
|
+
drawPane();
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
511
936
|
const from = open;
|
|
512
937
|
await flush();
|
|
513
938
|
const r = await post({ op: "rename", name: from, to });
|
|
514
|
-
if (!r.ok) {
|
|
939
|
+
if (!r.ok) {
|
|
940
|
+
toast(r.error || "Could not rename");
|
|
941
|
+
drawPane();
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
515
944
|
// Another note was opened meanwhile: the rename stands, the editor stays where it is.
|
|
516
|
-
if (open !== from) {
|
|
517
|
-
|
|
945
|
+
if (open !== from) {
|
|
946
|
+
await load();
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
open = to
|
|
950
|
+
.trim()
|
|
951
|
+
.replace(/\.md$/i, "")
|
|
952
|
+
.split("/")
|
|
953
|
+
.map((p) => p.trim())
|
|
954
|
+
.join("/");
|
|
518
955
|
await load();
|
|
519
956
|
base = notes.find((n) => n.name === open)?.mtime ?? base;
|
|
520
957
|
toast(folderOf(from) === folderOf(open) ? "Renamed" : `Moved to ${folderOf(open) || "the top"}`);
|
|
@@ -524,9 +961,13 @@ async function remove() {
|
|
|
524
961
|
if (!open) return;
|
|
525
962
|
clearTimeout(timer);
|
|
526
963
|
const r = await post({ op: "delete", name: open });
|
|
527
|
-
if (!r.ok) {
|
|
964
|
+
if (!r.ok) {
|
|
965
|
+
toast(r.error || "Could not delete");
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
528
968
|
toast("Moved to notes/.trash");
|
|
529
|
-
dirty = false;
|
|
969
|
+
dirty = false;
|
|
970
|
+
notes = notes.filter((n) => n.name !== open);
|
|
530
971
|
await openNote(null);
|
|
531
972
|
}
|
|
532
973
|
|
|
@@ -538,24 +979,36 @@ function drawSide() {
|
|
|
538
979
|
const count = new Map<string, number>();
|
|
539
980
|
for (const n of notes) for (const g of new Set(tags(n.text).map((x) => x.tag))) count.set(g, (count.get(g) ?? 0) + 1);
|
|
540
981
|
const tg = q.startsWith("#") ? q.slice(1) : null;
|
|
541
|
-
$("tags").innerHTML = [...count]
|
|
542
|
-
.
|
|
982
|
+
$("tags").innerHTML = [...count]
|
|
983
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
984
|
+
.map(
|
|
985
|
+
([g, c]) =>
|
|
986
|
+
`<button class="chip${g === tg ? " on" : ""}" data-tag="${esc(g)}" aria-pressed="${g === tg}">#${esc(g)} <span>${c}</span></button>`,
|
|
987
|
+
)
|
|
988
|
+
.join("");
|
|
543
989
|
$("tagsh").hidden = !count.size;
|
|
544
990
|
const row = (n: string, withFolder = false) => {
|
|
545
991
|
const note = notes.find((x) => x.name === n);
|
|
546
992
|
return `<li><button class="nt${n === open ? " cur" : ""}" data-open="${esc(n)}"${n === open ? ' aria-current="true"' : ""}><span class="nm">${esc(last(n))}</span>${withFolder && folderOf(n) ? `<span class="fd">${esc(folderOf(n))}</span>` : ""}<span class="age">${note ? age(note.mtime) : ""}</span></button></li>`;
|
|
547
993
|
};
|
|
548
994
|
if (q) {
|
|
549
|
-
const hits = notes.filter((n) =>
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
995
|
+
const hits = notes.filter((n) =>
|
|
996
|
+
tg !== null
|
|
997
|
+
? tags(n.text).some((x) => x.tag === tg || x.tag.startsWith(tg + "/"))
|
|
998
|
+
: n.name.toLowerCase().includes(q) || n.text.toLowerCase().includes(q),
|
|
999
|
+
);
|
|
1000
|
+
$("tree").innerHTML = hits.length
|
|
1001
|
+
? `<ul>${hits.map((n) => row(n.name, true)).join("")}</ul>`
|
|
1002
|
+
: `<p class="none">No note matches.</p>`;
|
|
553
1003
|
return;
|
|
554
1004
|
}
|
|
555
|
-
const draw = (tr: Tree, path: string): string =>
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
1005
|
+
const draw = (tr: Tree, path: string): string =>
|
|
1006
|
+
`<ul>${[...tr.folders]
|
|
1007
|
+
.map(([f, sub]) => {
|
|
1008
|
+
const p = path + f;
|
|
1009
|
+
return `<li><details data-folder="${esc(p)}"${shut.has(p) ? "" : " open"}><summary><i class="chev">${ico("chev")}</i>${ico("folder")}<span>${esc(f)}</span></summary>${draw(sub, p + "/")}</details></li>`;
|
|
1010
|
+
})
|
|
1011
|
+
.join("")}${tr.notes.map((n) => row(n)).join("")}</ul>`;
|
|
559
1012
|
$("tree").innerHTML = notes.length ? draw(tree(notes.map((n) => n.name)), "") : "";
|
|
560
1013
|
}
|
|
561
1014
|
|
|
@@ -563,13 +1016,24 @@ function drawSide() {
|
|
|
563
1016
|
// on; it then holds from note to note until the icon or Esc turns it off. With no note
|
|
564
1017
|
// open the page is never full screen: picking one needs the sidebar.
|
|
565
1018
|
let full = false;
|
|
566
|
-
function setFull(on: boolean) {
|
|
1019
|
+
function setFull(on: boolean) {
|
|
1020
|
+
full = on;
|
|
1021
|
+
drawPane();
|
|
1022
|
+
}
|
|
567
1023
|
// Width: the reading column (boxed, 68ch) or the whole paper side. Kept per browser.
|
|
568
1024
|
let wide = false;
|
|
569
|
-
try {
|
|
1025
|
+
try {
|
|
1026
|
+
wide = localStorage.getItem("kz-note-wide") === "1";
|
|
1027
|
+
} catch {
|
|
1028
|
+
/* no storage */
|
|
1029
|
+
}
|
|
570
1030
|
function setWide(on: boolean) {
|
|
571
1031
|
wide = on;
|
|
572
|
-
try {
|
|
1032
|
+
try {
|
|
1033
|
+
localStorage.setItem("kz-note-wide", on ? "1" : "0");
|
|
1034
|
+
} catch {
|
|
1035
|
+
/* no storage */
|
|
1036
|
+
}
|
|
573
1037
|
drawPane();
|
|
574
1038
|
}
|
|
575
1039
|
|
|
@@ -591,26 +1055,49 @@ function drawMeta(state?: string) {
|
|
|
591
1055
|
if (!open) return;
|
|
592
1056
|
if (conflict) {
|
|
593
1057
|
m.innerHTML = `<span class="warnw">This note changed somewhere else since you opened it.</span><span class="acts"><button class="btn ghost" id="theirs">Load theirs</button><button class="btn pri" id="mine">Keep mine</button></span>`;
|
|
594
|
-
$("theirs").onclick = () => {
|
|
595
|
-
|
|
1058
|
+
$("theirs").onclick = () => {
|
|
1059
|
+
const c = conflict!;
|
|
1060
|
+
conflict = null;
|
|
1061
|
+
dirty = false;
|
|
1062
|
+
base = c.mtime;
|
|
1063
|
+
setText(c.text, false);
|
|
1064
|
+
drawMeta();
|
|
1065
|
+
};
|
|
1066
|
+
$("mine").onclick = () => {
|
|
1067
|
+
base = conflict!.mtime;
|
|
1068
|
+
conflict = null;
|
|
1069
|
+
void flush();
|
|
1070
|
+
};
|
|
596
1071
|
return;
|
|
597
1072
|
}
|
|
598
1073
|
if (confirmDelete) {
|
|
599
1074
|
m.innerHTML = `<span>Move this note to <code>notes/.trash</code>?</span><span class="acts"><button class="btn ghost" id="cancel">Cancel</button><button class="btn danger" id="del">Delete</button></span>`;
|
|
600
|
-
$("cancel").onclick = () => {
|
|
601
|
-
|
|
1075
|
+
$("cancel").onclick = () => {
|
|
1076
|
+
confirmDelete = false;
|
|
1077
|
+
drawMeta();
|
|
1078
|
+
};
|
|
1079
|
+
$("del").onclick = () => {
|
|
1080
|
+
confirmDelete = false;
|
|
1081
|
+
void remove();
|
|
1082
|
+
};
|
|
602
1083
|
return;
|
|
603
1084
|
}
|
|
604
1085
|
const where = folderOf(open);
|
|
605
1086
|
m.innerHTML = `<span>${where ? esc(where) : "Notes"}</span><span>·</span><span id="saved">${state ?? (dirty ? "Not saved yet" : "Saved")}</span><span class="sp"></span><button class="ib" id="wide" title="${wide ? "Boxed width" : "Full width"}" aria-label="${wide ? "Boxed width" : "Full width"}" aria-pressed="${wide}">${ico(wide ? "box" : "wide")}</button><button class="ib" id="full" title="${full ? "Exit full screen (Esc)" : "Full screen"}" aria-label="${full ? "Exit full screen" : "Full screen"}">${ico(full ? "min" : "max")}</button><button class="ib" id="trash" title="Delete note" aria-label="Delete note">${ico("trash")}</button>`;
|
|
606
|
-
$("trash").onclick = () => {
|
|
1087
|
+
$("trash").onclick = () => {
|
|
1088
|
+
confirmDelete = true;
|
|
1089
|
+
drawMeta();
|
|
1090
|
+
};
|
|
607
1091
|
$("full").onclick = () => setFull(!full);
|
|
608
1092
|
$("wide").onclick = () => setWide(!wide);
|
|
609
1093
|
}
|
|
610
1094
|
|
|
611
1095
|
function drawLinks() {
|
|
612
1096
|
const box = $("links");
|
|
613
|
-
if (!open) {
|
|
1097
|
+
if (!open) {
|
|
1098
|
+
box.hidden = true;
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
614
1101
|
const names = notes.map((n) => n.name);
|
|
615
1102
|
const from = notes.filter((n) => n.name !== open && wikilinks(n.text).some((l) => resolve(l.name, names) === open));
|
|
616
1103
|
box.hidden = !from.length;
|
|
@@ -619,7 +1106,13 @@ function drawLinks() {
|
|
|
619
1106
|
|
|
620
1107
|
function age(ms: number) {
|
|
621
1108
|
const s = (Date.now() - ms) / 1000;
|
|
622
|
-
return s < 60
|
|
1109
|
+
return s < 60
|
|
1110
|
+
? "now"
|
|
1111
|
+
: s < 3600
|
|
1112
|
+
? `${Math.floor(s / 60)}m`
|
|
1113
|
+
: s < 86400
|
|
1114
|
+
? `${Math.floor(s / 3600)}h`
|
|
1115
|
+
: `${Math.floor(s / 86400)}d`;
|
|
623
1116
|
}
|
|
624
1117
|
|
|
625
1118
|
// ---- wiring
|
|
@@ -627,24 +1120,39 @@ function age(ms: number) {
|
|
|
627
1120
|
document.addEventListener("click", (e) => {
|
|
628
1121
|
// The narrow layout's drawer closes on any click outside it.
|
|
629
1122
|
const tg = e.target as HTMLElement;
|
|
630
|
-
if (document.body.classList.contains("side-open") && !tg.closest?.(".side,#side"))
|
|
1123
|
+
if (document.body.classList.contains("side-open") && !tg.closest?.(".side,#side"))
|
|
1124
|
+
document.body.classList.remove("side-open");
|
|
631
1125
|
const el = (e.target as HTMLElement).closest?.("[data-open],[data-tag]") as HTMLElement | null;
|
|
632
1126
|
if (!el || el.closest(".cm-editor")) return;
|
|
633
1127
|
if (el.dataset.open) void openNote(el.dataset.open);
|
|
634
|
-
else if (el.dataset.tag) {
|
|
1128
|
+
else if (el.dataset.tag) {
|
|
1129
|
+
const q = $<HTMLInputElement>("q");
|
|
1130
|
+
q.value = q.value === "#" + el.dataset.tag ? "" : "#" + el.dataset.tag;
|
|
1131
|
+
drawSide();
|
|
1132
|
+
}
|
|
635
1133
|
});
|
|
636
|
-
$("tree").addEventListener(
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
1134
|
+
$("tree").addEventListener(
|
|
1135
|
+
"toggle",
|
|
1136
|
+
(e) => {
|
|
1137
|
+
const d = e.target as HTMLDetailsElement;
|
|
1138
|
+
if (d.dataset.folder) d.open ? shut.delete(d.dataset.folder) : shut.add(d.dataset.folder);
|
|
1139
|
+
},
|
|
1140
|
+
true,
|
|
1141
|
+
);
|
|
640
1142
|
$("q").addEventListener("input", drawSide);
|
|
641
1143
|
$("new").onclick = () => void newNote();
|
|
642
1144
|
$("new2").onclick = () => void newNote();
|
|
643
1145
|
$("side").onclick = () => document.body.classList.toggle("side-open");
|
|
644
1146
|
const title = $<HTMLInputElement>("title");
|
|
645
1147
|
title.addEventListener("keydown", (e) => {
|
|
646
|
-
if (e.key === "Enter") {
|
|
647
|
-
|
|
1148
|
+
if (e.key === "Enter") {
|
|
1149
|
+
e.preventDefault();
|
|
1150
|
+
title.blur();
|
|
1151
|
+
}
|
|
1152
|
+
if (e.key === "Escape") {
|
|
1153
|
+
title.value = open ?? "";
|
|
1154
|
+
title.blur();
|
|
1155
|
+
}
|
|
648
1156
|
});
|
|
649
1157
|
title.addEventListener("blur", () => void rename(title.value));
|
|
650
1158
|
document.addEventListener("keydown", (e) => {
|
|
@@ -654,34 +1162,67 @@ document.addEventListener("keydown", (e) => {
|
|
|
654
1162
|
if (document.body.classList.contains("full")) setFull(false);
|
|
655
1163
|
});
|
|
656
1164
|
// Closing with text not on disk: the browser asks first.
|
|
657
|
-
addEventListener("beforeunload", (e) => {
|
|
1165
|
+
addEventListener("beforeunload", (e) => {
|
|
1166
|
+
if (dirty) e.preventDefault();
|
|
1167
|
+
});
|
|
658
1168
|
// Leaving with unsaved text: a keepalive request outlives the page.
|
|
659
1169
|
addEventListener("pagehide", () => {
|
|
660
|
-
if (open && dirty && !conflict)
|
|
1170
|
+
if (open && dirty && !conflict)
|
|
1171
|
+
fetch("/vault", {
|
|
1172
|
+
method: "POST",
|
|
1173
|
+
keepalive: true,
|
|
1174
|
+
headers: { "content-type": "application/json" },
|
|
1175
|
+
body: JSON.stringify({ dir, op: "save", name: open, text: view.state.sliceDoc(), base }),
|
|
1176
|
+
});
|
|
661
1177
|
});
|
|
662
1178
|
|
|
663
1179
|
// The project: ?dir= in the URL, else the one the board picked last, else where it started.
|
|
664
1180
|
(async () => {
|
|
665
|
-
try {
|
|
1181
|
+
try {
|
|
1182
|
+
const th = localStorage.getItem("kz-theme");
|
|
1183
|
+
if (th) document.documentElement.dataset.theme = th;
|
|
1184
|
+
} catch {
|
|
1185
|
+
/* no storage */
|
|
1186
|
+
}
|
|
666
1187
|
let S: { dir: string | null; projects: { dir: string; label: string }[] };
|
|
667
|
-
try {
|
|
1188
|
+
try {
|
|
1189
|
+
S = await (await fetch("/state")).json();
|
|
1190
|
+
} catch {
|
|
1191
|
+
toast("The board is not running");
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
668
1194
|
let kept: string | null = null;
|
|
669
|
-
try {
|
|
1195
|
+
try {
|
|
1196
|
+
kept = localStorage.getItem("kz-proj");
|
|
1197
|
+
} catch {
|
|
1198
|
+
/* no storage */
|
|
1199
|
+
}
|
|
670
1200
|
const want = new URLSearchParams(location.search).get("dir");
|
|
671
1201
|
const known = (d: string | null) => !!d && S.projects.some((p) => p.dir === d);
|
|
672
1202
|
dir = [want, kept, S.dir].find(known) ?? S.projects[0]?.dir ?? "";
|
|
673
1203
|
const sel = $<HTMLSelectElement>("proj");
|
|
674
|
-
sel.innerHTML = S.projects
|
|
1204
|
+
sel.innerHTML = S.projects
|
|
1205
|
+
.map((p) => `<option value="${esc(p.dir)}"${p.dir === dir ? " selected" : ""}>${esc(p.label)}</option>`)
|
|
1206
|
+
.join("");
|
|
675
1207
|
sel.onchange = async () => {
|
|
676
|
-
if (!(await openNote(null))) {
|
|
1208
|
+
if (!(await openNote(null))) {
|
|
1209
|
+
sel.value = dir;
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
677
1212
|
dir = sel.value;
|
|
678
1213
|
// window.: `history` here is CodeMirror's undo extension.
|
|
679
|
-
const u = new URL(location.href);
|
|
1214
|
+
const u = new URL(location.href);
|
|
1215
|
+
u.searchParams.set("dir", dir);
|
|
1216
|
+
window.history.replaceState(null, "", u);
|
|
680
1217
|
await load();
|
|
681
1218
|
};
|
|
682
1219
|
await load();
|
|
683
1220
|
let again: string | null = null;
|
|
684
|
-
try {
|
|
1221
|
+
try {
|
|
1222
|
+
again = sessionStorage.getItem("kz-note:" + dir);
|
|
1223
|
+
} catch {
|
|
1224
|
+
/* no storage */
|
|
1225
|
+
}
|
|
685
1226
|
// ?note= is the board's "Linked from notes"; otherwise the note open last in this tab.
|
|
686
1227
|
const asked = new URLSearchParams(location.search).get("note");
|
|
687
1228
|
if (asked && notes.some((n) => n.name === asked)) again = asked;
|
|
@@ -689,8 +1230,13 @@ addEventListener("pagehide", () => {
|
|
|
689
1230
|
// Every write under a state dir arrives as `changed`, this page's own saves included.
|
|
690
1231
|
const listen = () => {
|
|
691
1232
|
const es = new EventSource("/events");
|
|
692
|
-
es.onmessage = (ev) => {
|
|
693
|
-
|
|
1233
|
+
es.onmessage = (ev) => {
|
|
1234
|
+
if (ev.data === "changed") void load();
|
|
1235
|
+
};
|
|
1236
|
+
es.onerror = () => {
|
|
1237
|
+
es.close();
|
|
1238
|
+
setTimeout(listen, 3000);
|
|
1239
|
+
};
|
|
694
1240
|
};
|
|
695
1241
|
listen();
|
|
696
1242
|
})();
|