pi-weave 0.3.9 → 0.3.10
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 +5 -2
- package/package.json +1 -1
- package/src/web/client/context/context.model.ts +5 -0
- package/src/web/client/dist/app.js +392 -153
- package/src/web/client/graph/Graph.tsx +143 -12
- package/src/web/client/graph/column.model.ts +58 -1
- package/src/web/client/graph/graph.model.ts +114 -0
- package/src/web/client/graph/renderer.ts +21 -7
- package/src/web/client/graph/spotlight.model.ts +472 -0
- package/src/web/client/note/Note.tsx +212 -59
- package/src/web/client/note/Replay.tsx +18 -7
- package/src/web/client/note/drafts.ts +69 -6
- package/src/web/client/note/editbar.model.ts +153 -0
- package/src/web/client/note/note.model.ts +65 -1
- package/src/web/client/note/replay.model.ts +75 -0
- package/src/web/client/search/SearchPalette.tsx +103 -34
- package/src/web/client/search/search.model.ts +533 -26
- package/src/web/client/shell/Shell.tsx +61 -19
- package/src/web/client/shell/StatusBar.tsx +1 -0
- package/src/web/client/shell/icons.model.ts +68 -1
- package/src/web/client/shell/keys.model.ts +11 -1
- package/src/web/client/shell/shell.model.ts +48 -1
- package/src/web/client/shell/theme.model.ts +3 -0
- package/src/web/client/shell/theme.ts +252 -47
- package/src/web/client/shell/workspace.css.ts +38 -11
- package/src/web/shared/themes.ts +1 -1
- package/src/web/shared/workspace.ts +10 -2
|
@@ -51,8 +51,6 @@ import {
|
|
|
51
51
|
EDITOR_ARIA_LABEL,
|
|
52
52
|
EDITOR_PLACEHOLDER,
|
|
53
53
|
EMPTY_PREVIEW,
|
|
54
|
-
SAVE_LABEL,
|
|
55
|
-
SAVING_LABEL,
|
|
56
54
|
TASK_CHECKBOX_ATTR,
|
|
57
55
|
WIKILINK_ATTR,
|
|
58
56
|
hasTextSelection,
|
|
@@ -81,6 +79,20 @@ import type { DraftStore, SaveNote } from "./drafts";
|
|
|
81
79
|
import { ReplayBar, wrapMarks } from "./Replay";
|
|
82
80
|
import type { ReplayState } from "./Replay";
|
|
83
81
|
import { HISTORY_CLOSE_LABEL, HISTORY_LABEL, PLAY_INTERVAL_MS, nextStep, replayStep } from "./replay.model";
|
|
82
|
+
import {
|
|
83
|
+
AUTOSAVE_MS,
|
|
84
|
+
DISCARD_EDITS_LABEL,
|
|
85
|
+
EDITING_LABEL,
|
|
86
|
+
FORMAT_TOOLS,
|
|
87
|
+
REVERT_PROMPT,
|
|
88
|
+
RETRY_LABEL,
|
|
89
|
+
applyFormat,
|
|
90
|
+
editStatus,
|
|
91
|
+
formatForKey,
|
|
92
|
+
replacedSpan,
|
|
93
|
+
} from "./editbar.model";
|
|
94
|
+
import type { EditStatus, FormatKind } from "./editbar.model";
|
|
95
|
+
import { Icon } from "../tree/Tree";
|
|
84
96
|
|
|
85
97
|
/**
|
|
86
98
|
* The custom properties the preview card is placed with, written by the
|
|
@@ -122,75 +134,134 @@ export interface NoteProps {
|
|
|
122
134
|
}
|
|
123
135
|
|
|
124
136
|
/**
|
|
125
|
-
*
|
|
137
|
+
* Folder crumb, title, one quiet meta line, tags.
|
|
126
138
|
*
|
|
127
139
|
* The hierarchy is the point: the title is the page's largest voice and the
|
|
128
|
-
* meta line is its footnote
|
|
129
|
-
* The Open in `$EDITOR`
|
|
130
|
-
* a workflow control belongs in a column whose job
|
|
140
|
+
* meta line is its footnote, in plain words: who wrote it and when it last
|
|
141
|
+
* changed. The note's own actions (History, Edit, Open in `$EDITOR`) sit at
|
|
142
|
+
* the end of that line, where a workflow control belongs in a column whose job
|
|
143
|
+
* is reading. While editing, the line gives way to the edit bar above.
|
|
131
144
|
*/
|
|
132
145
|
function Header({
|
|
133
146
|
view,
|
|
134
147
|
open,
|
|
135
148
|
editing,
|
|
136
|
-
saving,
|
|
137
149
|
taskSaving,
|
|
138
|
-
|
|
139
|
-
onSave,
|
|
150
|
+
onEdit,
|
|
140
151
|
replaying,
|
|
141
152
|
onHistory,
|
|
142
153
|
}: {
|
|
143
154
|
view: NoteHeaderView;
|
|
144
155
|
open: () => void;
|
|
145
156
|
editing: boolean;
|
|
146
|
-
saving: boolean;
|
|
147
157
|
taskSaving: boolean;
|
|
148
|
-
|
|
149
|
-
onSave: () => void;
|
|
158
|
+
onEdit: () => void;
|
|
150
159
|
replaying: boolean;
|
|
151
160
|
onHistory: (() => void) | null;
|
|
152
161
|
}) {
|
|
153
162
|
return (
|
|
154
163
|
<header class="weave-note-head">
|
|
164
|
+
<p class="weave-note-crumb">
|
|
165
|
+
<Icon name="folder" class="weave-icon" />
|
|
166
|
+
{view.folder}
|
|
167
|
+
</p>
|
|
155
168
|
<h3 class="weave-note-title">{view.title}</h3>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
<
|
|
160
|
-
|
|
169
|
+
{editing ? null : (
|
|
170
|
+
<p class="weave-note-meta">
|
|
171
|
+
<span class={`weave-prov weave-prov-${view.provenance}`} title={view.provenanceTitle}>
|
|
172
|
+
<span class="weave-prov-dot" aria-hidden="true" />
|
|
173
|
+
{view.byline}
|
|
174
|
+
</span>
|
|
175
|
+
<span class="weave-note-time" title={`${CREATED_WORD} ${view.created} · ${view.createdIso}`}>
|
|
176
|
+
{EDITED_WORD} {view.updated}
|
|
177
|
+
</span>
|
|
178
|
+
<span class="weave-note-actions">
|
|
179
|
+
{onHistory === null ? null : (
|
|
180
|
+
<button type="button" class="weave-note-action weave-note-history" aria-pressed={replaying} onClick={onHistory}>
|
|
181
|
+
<Icon name="history" class="weave-icon" />
|
|
182
|
+
{replaying ? HISTORY_CLOSE_LABEL : HISTORY_LABEL}
|
|
183
|
+
</button>
|
|
184
|
+
)}
|
|
185
|
+
{replaying ? null : (
|
|
186
|
+
<button type="button" class="weave-note-action weave-note-edit" disabled={taskSaving} onClick={onEdit}>
|
|
187
|
+
<Icon name="edit" class="weave-icon" />
|
|
188
|
+
{EDIT_LABEL}
|
|
189
|
+
</button>
|
|
190
|
+
)}
|
|
191
|
+
<button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
|
|
192
|
+
<span class="weave-note-open-mark" aria-hidden="true">↗</span>
|
|
161
193
|
</button>
|
|
162
|
-
<button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{DONE_LABEL}</button>
|
|
163
|
-
</>
|
|
164
|
-
) : replaying ? null : <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{EDIT_LABEL}</button>}
|
|
165
|
-
{editing || onHistory === null ? null : (
|
|
166
|
-
<button type="button" class="weave-note-edit weave-note-history" aria-pressed={replaying} onClick={onHistory}>
|
|
167
|
-
{replaying ? HISTORY_CLOSE_LABEL : HISTORY_LABEL}
|
|
168
|
-
</button>
|
|
169
|
-
)}
|
|
170
|
-
<button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
|
|
171
|
-
<span class="weave-note-open-mark" aria-hidden="true">↗</span>
|
|
172
|
-
</button>
|
|
173
|
-
<span class={`weave-prov weave-prov-${view.provenance}`} title={view.provenanceTitle}>
|
|
174
|
-
{view.provenanceGlyph} {view.provenance}
|
|
175
|
-
</span>
|
|
176
|
-
<span class="weave-note-time" title={view.updatedIso}>
|
|
177
|
-
{EDITED_WORD} {view.updated}
|
|
178
|
-
</span>
|
|
179
|
-
<span class="weave-note-time" title={view.createdIso}>
|
|
180
|
-
{CREATED_WORD} {view.created}
|
|
181
|
-
</span>
|
|
182
|
-
</p>
|
|
183
|
-
<p class="weave-note-tags">
|
|
184
|
-
{view.tags.map((tag) => (
|
|
185
|
-
<span key={tag} class="weave-tag">
|
|
186
|
-
{tagLabel(tag)}
|
|
187
194
|
</span>
|
|
188
|
-
|
|
189
|
-
|
|
195
|
+
</p>
|
|
196
|
+
)}
|
|
197
|
+
{view.tags.length === 0 ? null : (
|
|
198
|
+
<p class="weave-note-tags">
|
|
199
|
+
{view.tags.map((tag) => (
|
|
200
|
+
<span key={tag} class="weave-tag">
|
|
201
|
+
{tagLabel(tag)}
|
|
202
|
+
</span>
|
|
203
|
+
))}
|
|
204
|
+
</p>
|
|
205
|
+
)}
|
|
190
206
|
</header>
|
|
191
207
|
);
|
|
192
208
|
}
|
|
193
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The edit bar (E1): says the note is being edited, carries the formatting
|
|
212
|
+
* buttons, shows whether the words are on disk yet, and ends the session.
|
|
213
|
+
* Saving is automatic, so there is no Save button: Done catches up and closes.
|
|
214
|
+
*/
|
|
215
|
+
function EditBar({
|
|
216
|
+
status,
|
|
217
|
+
busy,
|
|
218
|
+
onFormat,
|
|
219
|
+
onRetry,
|
|
220
|
+
onDiscard,
|
|
221
|
+
onDone,
|
|
222
|
+
}: {
|
|
223
|
+
status: EditStatus;
|
|
224
|
+
busy: boolean;
|
|
225
|
+
onFormat: (kind: FormatKind) => void;
|
|
226
|
+
onRetry: () => void;
|
|
227
|
+
onDiscard: () => void;
|
|
228
|
+
onDone: () => void;
|
|
229
|
+
}) {
|
|
230
|
+
return (
|
|
231
|
+
<div class="weave-editbar" role="toolbar" aria-label={EDITING_LABEL}>
|
|
232
|
+
<span class="weave-editbar-badge">
|
|
233
|
+
<Icon name="edit" class="weave-icon" />
|
|
234
|
+
{EDITING_LABEL}
|
|
235
|
+
</span>
|
|
236
|
+
<span class="weave-editbar-tools">
|
|
237
|
+
{FORMAT_TOOLS.map((tool) => (
|
|
238
|
+
<button
|
|
239
|
+
key={tool.kind}
|
|
240
|
+
type="button"
|
|
241
|
+
class="weave-editbar-tool"
|
|
242
|
+
title={tool.key === null ? tool.label : `${tool.label} (⌘${tool.key.toUpperCase()})`}
|
|
243
|
+
aria-label={tool.label}
|
|
244
|
+
// Keep the cursor and selection in the text box the button acts on.
|
|
245
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
246
|
+
onClick={() => onFormat(tool.kind)}
|
|
247
|
+
>
|
|
248
|
+
<Icon name={tool.icon} class="weave-icon" />
|
|
249
|
+
</button>
|
|
250
|
+
))}
|
|
251
|
+
</span>
|
|
252
|
+
<span class={`weave-editbar-status weave-editbar-${status.kind}`} role="status">
|
|
253
|
+
{status.kind === "saved" ? <Icon name="check" class="weave-icon" /> : null}
|
|
254
|
+
{status.label}
|
|
255
|
+
{status.kind === "failed" ? (
|
|
256
|
+
<button type="button" class="weave-editbar-retry" onClick={onRetry}>{RETRY_LABEL}</button>
|
|
257
|
+
) : null}
|
|
258
|
+
</span>
|
|
259
|
+
<button type="button" class="weave-editbar-discard" disabled={busy || status.kind === "saving"} onClick={onDiscard}>{DISCARD_EDITS_LABEL}</button>
|
|
260
|
+
<button type="button" class="weave-editbar-done" disabled={busy} onClick={onDone}>{DONE_LABEL}</button>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
194
265
|
export function Note(props: NoteProps) {
|
|
195
266
|
const localDrafts = useRef(createDraftStore());
|
|
196
267
|
const drafts = props.drafts ?? localDrafts.current;
|
|
@@ -234,8 +305,9 @@ export function Note(props: NoteProps) {
|
|
|
234
305
|
// otherwise outgrow the note it belongs to.
|
|
235
306
|
const [rawOpen, setRawOpen] = useState(false);
|
|
236
307
|
const taskSaving = note !== null && drafts.isTaskSaving(note.slug);
|
|
237
|
-
|
|
238
|
-
|
|
308
|
+
/** Write the draft and keep editing: autosave, ⌘S and Retry. */
|
|
309
|
+
const saveNow = (): void => {
|
|
310
|
+
if (note !== null && !drafts.isTaskSaving(note.slug)) void drafts.save(note.slug, props.onSave, { keepOpen: true });
|
|
239
311
|
};
|
|
240
312
|
|
|
241
313
|
const saveTask = async (checkbox: HTMLInputElement, taskIndex: number): Promise<void> => {
|
|
@@ -277,25 +349,89 @@ export function Note(props: NoteProps) {
|
|
|
277
349
|
}
|
|
278
350
|
}, [note?.slug, props.defaultEdit]);
|
|
279
351
|
|
|
280
|
-
const
|
|
281
|
-
if (note === null || drafts.isTaskSaving(note.slug)) return;
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
352
|
+
const startEdit = (): void => {
|
|
353
|
+
if (note === null || drafts.isTaskSaving(note.slug) || drafts.get(note.slug) !== null) return;
|
|
354
|
+
focusEditor.current = true;
|
|
355
|
+
drafts.open(note.slug, body);
|
|
356
|
+
};
|
|
357
|
+
// Done while an autosave is in flight waits for it, then closes.
|
|
358
|
+
const closeWhenSaved = useRef(false);
|
|
359
|
+
/** Catch up and close. A failed write leaves the editor open, saying so. */
|
|
360
|
+
const done = (): void => {
|
|
361
|
+
if (note === null || draft === null || drafts.isTaskSaving(note.slug)) return;
|
|
362
|
+
if (draft.saving) {
|
|
363
|
+
closeWhenSaved.current = true;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
closeWhenSaved.current = false;
|
|
367
|
+
if (drafts.isDirty(note.slug)) void drafts.save(note.slug, props.onSave);
|
|
368
|
+
else drafts.discard(note.slug);
|
|
369
|
+
};
|
|
370
|
+
/**
|
|
371
|
+
* Back to the note as it was when editing began. Before any autosave that
|
|
372
|
+
* is dropping the draft; after one, it writes the opening version back.
|
|
373
|
+
*/
|
|
374
|
+
const discardEdits = (): void => {
|
|
375
|
+
if (note === null || draft === null || draft.saving) return;
|
|
376
|
+
closeWhenSaved.current = false;
|
|
377
|
+
const autosaved = draft.baseline !== draft.original || draft.rawBaseline !== draft.originalRaw;
|
|
378
|
+
if (autosaved) {
|
|
379
|
+
if (!window.confirm(REVERT_PROMPT)) return;
|
|
380
|
+
if (drafts.revert(note.slug)) void drafts.save(note.slug, props.onSave);
|
|
381
|
+
else drafts.discard(note.slug);
|
|
285
382
|
} else if (!drafts.isDirty(note.slug) || window.confirm(DISCARD_PROMPT)) drafts.discard(note.slug);
|
|
286
383
|
};
|
|
384
|
+
// The box the last formatting button or key acted on: the body, or Raw.
|
|
385
|
+
const rawEditorRef = useRef<HTMLTextAreaElement | null>(null);
|
|
386
|
+
const lastBox = useRef<"body" | "raw">("body");
|
|
387
|
+
const format = (kind: FormatKind): void => {
|
|
388
|
+
if (note === null) return;
|
|
389
|
+
const raw = lastBox.current === "raw" && rawEditorRef.current !== null;
|
|
390
|
+
const box = raw ? rawEditorRef.current : editorRef.current;
|
|
391
|
+
if (box === null) return;
|
|
392
|
+
const next = applyFormat({ value: box.value, start: box.selectionStart, end: box.selectionEnd }, kind);
|
|
393
|
+
// Insert only the changed span, through the browser, so ⌘Z undoes it.
|
|
394
|
+
// Where that is not supported, set the value (and lose undo) instead.
|
|
395
|
+
const span = replacedSpan(box.value, next.value);
|
|
396
|
+
box.focus();
|
|
397
|
+
box.setSelectionRange(span.start, span.end);
|
|
398
|
+
const inserted = typeof document.execCommand === "function"
|
|
399
|
+
&& document.execCommand(span.text === "" ? "delete" : "insertText", false, span.text);
|
|
400
|
+
if (!inserted || box.value !== next.value) box.value = next.value;
|
|
401
|
+
if (raw) drafts.editRaw(note.slug, next.value);
|
|
402
|
+
else drafts.edit(note.slug, next.value);
|
|
403
|
+
// The selection is set now so typing replaces the placeholder the button inserted.
|
|
404
|
+
box.setSelectionRange(next.start, next.end);
|
|
405
|
+
};
|
|
287
406
|
// Both editor boxes: bound here, not in `keys.model.ts`, because a global
|
|
288
407
|
// binding is a workspace-wide claim and Escape must not reach the shell.
|
|
289
408
|
const editorKeys = (event: KeyboardEvent): void => {
|
|
290
409
|
if (event.key === "Escape") {
|
|
291
410
|
event.stopPropagation();
|
|
292
|
-
|
|
411
|
+
done();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
|
|
415
|
+
if (event.key.toLowerCase() === "s") {
|
|
416
|
+
event.preventDefault();
|
|
417
|
+
if (note !== null && drafts.isDirty(note.slug)) saveNow();
|
|
293
418
|
return;
|
|
294
419
|
}
|
|
295
|
-
|
|
420
|
+
const kind = formatForKey(event.key);
|
|
421
|
+
if (kind === null || event.shiftKey) return;
|
|
296
422
|
event.preventDefault();
|
|
297
|
-
|
|
423
|
+
format(kind);
|
|
298
424
|
};
|
|
425
|
+
// Autosave: a pause in typing writes the draft. A failed write waits for
|
|
426
|
+
// new words or Retry rather than hammering a server that just said no.
|
|
427
|
+
useEffect(() => {
|
|
428
|
+
if (note === null || draft === null || draft.saving || draft.failed || !drafts.isDirty(note.slug)) return;
|
|
429
|
+
const timer = window.setTimeout(saveNow, AUTOSAVE_MS);
|
|
430
|
+
return () => window.clearTimeout(timer);
|
|
431
|
+
}, [note?.slug, draft, draft?.body, draft?.raw, draft?.saving, draft?.failed]);
|
|
432
|
+
useEffect(() => {
|
|
433
|
+
if (closeWhenSaved.current && draft !== null && !draft.saving) done();
|
|
434
|
+
}, [draft?.saving]);
|
|
299
435
|
const card = preview.anchor === null ? null : previewCard(props.graph, preview.anchor);
|
|
300
436
|
|
|
301
437
|
// Cursor in the textarea, after the last word, the moment it appears.
|
|
@@ -341,7 +477,9 @@ export function Note(props: NoteProps) {
|
|
|
341
477
|
}, [note === null ? null : note.slug]);
|
|
342
478
|
useLayoutEffect(() => {
|
|
343
479
|
if (replayHtml === null || bodyRef.current === null) return;
|
|
344
|
-
wrapMarks(bodyRef.current)
|
|
480
|
+
const first = wrapMarks(bodyRef.current);
|
|
481
|
+
// A Raw append is marked but not followed: see `ReplayStep.follow`.
|
|
482
|
+
if (step?.follow) first?.scrollIntoView?.({ block: "nearest" });
|
|
345
483
|
}, [replayHtml]);
|
|
346
484
|
useEffect(() => {
|
|
347
485
|
if (replay === null || !replay.playing || replay.revisions === null) return;
|
|
@@ -357,7 +495,10 @@ export function Note(props: NoteProps) {
|
|
|
357
495
|
}, [replay]);
|
|
358
496
|
|
|
359
497
|
useLayoutEffect(() => {
|
|
360
|
-
if (note
|
|
498
|
+
if (note === null) return;
|
|
499
|
+
drafts.taskBody(note.slug, note.body, props.loadVersion);
|
|
500
|
+
// Each load is the note as it is on disk now, after an autosave included.
|
|
501
|
+
drafts.syncTail(note.slug, note.body);
|
|
361
502
|
}, [drafts, note === null ? null : note.slug, note?.body, props.loadVersion]);
|
|
362
503
|
|
|
363
504
|
// A card whose target note left the screen is a claim about a document that
|
|
@@ -446,20 +587,29 @@ export function Note(props: NoteProps) {
|
|
|
446
587
|
const header = noteHeader(note, props.now);
|
|
447
588
|
return (
|
|
448
589
|
<article key={note.slug} class={`weave-note weave-note-${header.provenance}`}>
|
|
590
|
+
{draft === null ? null : (
|
|
591
|
+
<EditBar
|
|
592
|
+
status={editStatus({ saving: draft.saving, failed: draft.failed, dirty: drafts.isDirty(note.slug), savedAt: draft.savedAt })}
|
|
593
|
+
busy={taskSaving}
|
|
594
|
+
onFormat={format}
|
|
595
|
+
onRetry={saveNow}
|
|
596
|
+
onDiscard={discardEdits}
|
|
597
|
+
onDone={done}
|
|
598
|
+
/>
|
|
599
|
+
)}
|
|
449
600
|
<Header
|
|
450
601
|
view={header}
|
|
451
602
|
open={() => props.onOpen(note.slug)}
|
|
452
603
|
editing={draft !== null}
|
|
453
|
-
saving={draft?.saving ?? false}
|
|
454
604
|
taskSaving={taskSaving}
|
|
455
|
-
|
|
456
|
-
onSave={() => void save()}
|
|
605
|
+
onEdit={startEdit}
|
|
457
606
|
replaying={replay !== null}
|
|
458
607
|
onHistory={loadHistory === undefined ? null : toggleHistory}
|
|
459
608
|
/>
|
|
460
609
|
{replay === null ? null : (
|
|
461
610
|
<ReplayBar
|
|
462
611
|
state={replay}
|
|
612
|
+
now={props.now}
|
|
463
613
|
onIndex={(value) => setReplay((state) => state === null ? state : { ...state, index: value, playing: false })}
|
|
464
614
|
onPlay={() => setReplay((state) => {
|
|
465
615
|
if (state === null || state.revisions === null) return state;
|
|
@@ -480,6 +630,7 @@ export function Note(props: NoteProps) {
|
|
|
480
630
|
spellcheck={props.spellcheck ?? true}
|
|
481
631
|
value={draft.body}
|
|
482
632
|
onInput={(event) => drafts.edit(note.slug, (event.target as HTMLTextAreaElement).value)}
|
|
633
|
+
onFocus={() => { lastBox.current = "body"; }}
|
|
483
634
|
onKeyDown={editorKeys}
|
|
484
635
|
/>
|
|
485
636
|
{/* A box of its own, never the body's textarea: text typed below
|
|
@@ -508,7 +659,9 @@ export function Note(props: NoteProps) {
|
|
|
508
659
|
aria-label={RAW_EDITOR_ARIA_LABEL}
|
|
509
660
|
spellcheck={props.spellcheck ?? true}
|
|
510
661
|
value={draft.raw}
|
|
662
|
+
ref={rawEditorRef}
|
|
511
663
|
onInput={(event) => drafts.editRaw(note.slug, (event.target as HTMLTextAreaElement).value)}
|
|
664
|
+
onFocus={() => { lastBox.current = "raw"; }}
|
|
512
665
|
onKeyDown={editorKeys}
|
|
513
666
|
/>
|
|
514
667
|
) : null}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { useLayoutEffect, useRef } from "preact/hooks";
|
|
8
8
|
import type { HistoryRevision } from "../../shared/wire";
|
|
9
|
-
import { HISTORY_EMPTY, HISTORY_OFF_HINT, HISTORY_TRIMMED, MARK_CLOSE, MARK_OPEN, replayStep, splitMarked } from "./replay.model";
|
|
9
|
+
import { HISTORY_EMPTY, HISTORY_OFF_HINT, HISTORY_TRIMMED, MARK_CLOSE, MARK_OPEN, changeBars, dayMarks, replayStep, splitMarked } from "./replay.model";
|
|
10
10
|
|
|
11
11
|
/** What the column holds while replaying. `null` revisions means still loading. */
|
|
12
12
|
export interface ReplayState {
|
|
@@ -52,8 +52,8 @@ export function wrapMarks(root: HTMLElement): HTMLElement | null {
|
|
|
52
52
|
return first;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/** Past this many versions the
|
|
56
|
-
const
|
|
55
|
+
/** Past this many versions the band shows no per-version bars: they would be hairlines. */
|
|
56
|
+
const MAX_BARS = 160;
|
|
57
57
|
|
|
58
58
|
const Icon = (props: { d: string; fill?: boolean }) => (
|
|
59
59
|
<svg width="14" height="14" viewBox="0 0 18 18" aria-hidden="true"
|
|
@@ -65,6 +65,8 @@ const Icon = (props: { d: string; fill?: boolean }) => (
|
|
|
65
65
|
|
|
66
66
|
export function ReplayBar(props: {
|
|
67
67
|
state: ReplayState;
|
|
68
|
+
/** Epoch ms, for the "Today" / "Yesterday" day labels. */
|
|
69
|
+
now: number;
|
|
68
70
|
onIndex: (index: number) => void;
|
|
69
71
|
onPlay: () => void;
|
|
70
72
|
}) {
|
|
@@ -101,10 +103,19 @@ export function ReplayBar(props: {
|
|
|
101
103
|
<Icon d="m7 4 5 5-5 5" />
|
|
102
104
|
</button>
|
|
103
105
|
<div class="weave-replay-track" ref={track}>
|
|
104
|
-
{revisions.length >
|
|
105
|
-
<span class="weave-replay-
|
|
106
|
-
{revisions.map((
|
|
107
|
-
|
|
106
|
+
{revisions.length > MAX_BARS || last === 0 ? null : (
|
|
107
|
+
<span class="weave-replay-bars" aria-hidden="true" style={{ "--weave-bars": `${revisions.length}` }}>
|
|
108
|
+
{changeBars(revisions).map((bar, at) => (
|
|
109
|
+
// Placed where the slider's thumb sits for this version, so a bar and its stop line up.
|
|
110
|
+
<span key={at} style={{ "--weave-bar": `${Math.round(bar.height * 100)}%`, "--weave-bar-at": `${(at / last) * 100}%` }}
|
|
111
|
+
class={`weave-replay-bar weave-replay-${bar.by}${at > index ? " weave-replay-ahead" : ""}${at === index ? " weave-replay-current" : ""}`} />
|
|
112
|
+
))}
|
|
113
|
+
</span>
|
|
114
|
+
)}
|
|
115
|
+
{last === 0 ? null : (
|
|
116
|
+
<span class="weave-replay-days" aria-hidden="true">
|
|
117
|
+
{dayMarks(revisions, props.now).map((mark) => (
|
|
118
|
+
<span key={mark.at} class="weave-replay-day" style={{ "--weave-day-at": `${mark.at * 100}%` }}>{mark.label}</span>
|
|
108
119
|
))}
|
|
109
120
|
</span>
|
|
110
121
|
)}
|
|
@@ -15,11 +15,29 @@ export interface NoteDraft {
|
|
|
15
15
|
raw: string;
|
|
16
16
|
rawBaseline: string;
|
|
17
17
|
saving: boolean;
|
|
18
|
+
/** The body and Raw entries as they were when the editor opened: what Discard returns to. */
|
|
19
|
+
original: string;
|
|
20
|
+
originalRaw: string;
|
|
21
|
+
/** When an autosave last landed (epoch ms); null until one has. */
|
|
22
|
+
savedAt: number | null;
|
|
23
|
+
/** The last save attempt failed; cleared by the next one that lands. */
|
|
24
|
+
failed: boolean;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
/** How a save should treat the `## Raw` tail; `raw` means the person edited it. */
|
|
21
28
|
export interface SaveOptions {
|
|
22
29
|
raw?: boolean;
|
|
30
|
+
/** An autosave: the shell reports a failure in the edit bar, not with an alert. */
|
|
31
|
+
quiet?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How a draft save behaves. */
|
|
35
|
+
export interface DraftSaveOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Keep the editor open after the write lands (autosave). Without it a save
|
|
38
|
+
* that catches up with the editor closes it, which is what Done does.
|
|
39
|
+
*/
|
|
40
|
+
keepOpen?: boolean;
|
|
23
41
|
}
|
|
24
42
|
|
|
25
43
|
export type SaveNote = (slug: string, body: string, options?: SaveOptions) => Promise<boolean>;
|
|
@@ -39,10 +57,23 @@ export interface DraftStore {
|
|
|
39
57
|
edit(slug: string, body: string): void;
|
|
40
58
|
/** Edit the `## Raw` entries. */
|
|
41
59
|
editRaw(slug: string, raw: string): void;
|
|
60
|
+
/**
|
|
61
|
+
* Catch an open editor's Raw box up with the note on disk. Entries an
|
|
62
|
+
* agent appended while the editor was open (and survived an autosave,
|
|
63
|
+
* which keeps the server's tail) would otherwise be missing from it, and
|
|
64
|
+
* the next save of an edited Raw would write them away.
|
|
65
|
+
*/
|
|
66
|
+
syncTail(slug: string, serverBody: string): void;
|
|
42
67
|
discard(slug: string): void;
|
|
43
68
|
isDirty(slug: string): boolean;
|
|
44
69
|
dirtySlugs(): string[];
|
|
45
|
-
save(slug: string, onSave: SaveNote): Promise<void>;
|
|
70
|
+
save(slug: string, onSave: SaveNote, options?: DraftSaveOptions): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Put the body and Raw entries back to how they were when the editor
|
|
73
|
+
* opened. Returns whether that differs from what is on disk now (an
|
|
74
|
+
* autosave already wrote the edits), in which case the caller saves.
|
|
75
|
+
*/
|
|
76
|
+
revert(slug: string): boolean;
|
|
46
77
|
nextLoadVersion(): number;
|
|
47
78
|
getTaskBody(slug: string, fallback: string): string;
|
|
48
79
|
taskBody(slug: string, serverBody: string, loadVersion: number): string;
|
|
@@ -51,7 +82,8 @@ export interface DraftStore {
|
|
|
51
82
|
clearTaskSources(slugs: readonly string[]): void;
|
|
52
83
|
}
|
|
53
84
|
|
|
54
|
-
|
|
85
|
+
/** `now` is the clock behind "Saved 12:04"; tests pass a fixed one. */
|
|
86
|
+
export function createDraftStore(now: () => number = Date.now): DraftStore {
|
|
55
87
|
const drafts = new Map<string, NoteDraft>();
|
|
56
88
|
const taskSources = new Map<string, TaskSource>();
|
|
57
89
|
const listeners = new Set<() => void>();
|
|
@@ -69,19 +101,39 @@ export function createDraftStore(): DraftStore {
|
|
|
69
101
|
if (drafts.has(slug)) return;
|
|
70
102
|
const { head, tail } = splitRawTail(body);
|
|
71
103
|
const { entries } = splitRawOpening(tail);
|
|
72
|
-
drafts.set(slug, { body: head, baseline: head, tail, raw: entries, rawBaseline: entries, saving: false });
|
|
104
|
+
drafts.set(slug, { body: head, baseline: head, tail, raw: entries, rawBaseline: entries, saving: false, original: head, originalRaw: entries, savedAt: null, failed: false });
|
|
73
105
|
notify();
|
|
74
106
|
},
|
|
75
107
|
edit(slug, body) {
|
|
76
108
|
const draft = drafts.get(slug);
|
|
77
109
|
if (draft === undefined || draft.body === body) return;
|
|
78
110
|
draft.body = body;
|
|
111
|
+
// New words get a fresh autosave attempt; a failure says so until then.
|
|
112
|
+
draft.failed = false;
|
|
79
113
|
notify();
|
|
80
114
|
},
|
|
81
115
|
editRaw(slug, raw) {
|
|
82
116
|
const draft = drafts.get(slug);
|
|
83
117
|
if (draft === undefined || draft.raw === raw) return;
|
|
84
118
|
draft.raw = raw;
|
|
119
|
+
draft.failed = false;
|
|
120
|
+
notify();
|
|
121
|
+
},
|
|
122
|
+
syncTail(slug, serverBody) {
|
|
123
|
+
const draft = drafts.get(slug);
|
|
124
|
+
// A write in flight answers for itself, and Raw edits not yet saved are
|
|
125
|
+
// the person's to keep: only an untouched box follows the disk.
|
|
126
|
+
if (draft === undefined || draft.saving || draft.raw !== draft.rawBaseline) return;
|
|
127
|
+
const { tail } = splitRawTail(serverBody);
|
|
128
|
+
if (tail === draft.tail) return;
|
|
129
|
+
const { entries } = splitRawOpening(tail);
|
|
130
|
+
// Discard returns to how the note was when the editor opened, plus what
|
|
131
|
+
// was appended since: an agent's entry is not the person's edit to undo.
|
|
132
|
+
if (draft.originalRaw === draft.rawBaseline) draft.originalRaw = entries;
|
|
133
|
+
else if (entries.startsWith(draft.rawBaseline)) draft.originalRaw += entries.slice(draft.rawBaseline.length);
|
|
134
|
+
draft.tail = tail;
|
|
135
|
+
draft.raw = entries;
|
|
136
|
+
draft.rawBaseline = entries;
|
|
85
137
|
notify();
|
|
86
138
|
},
|
|
87
139
|
discard(slug) {
|
|
@@ -94,7 +146,15 @@ export function createDraftStore(): DraftStore {
|
|
|
94
146
|
dirtySlugs() {
|
|
95
147
|
return [...drafts].filter(([, draft]) => dirty(draft)).map(([slug]) => slug);
|
|
96
148
|
},
|
|
97
|
-
|
|
149
|
+
revert(slug) {
|
|
150
|
+
const draft = drafts.get(slug);
|
|
151
|
+
if (draft === undefined) return false;
|
|
152
|
+
draft.body = draft.original;
|
|
153
|
+
draft.raw = draft.originalRaw;
|
|
154
|
+
notify();
|
|
155
|
+
return dirty(draft);
|
|
156
|
+
},
|
|
157
|
+
async save(slug, onSave, options) {
|
|
98
158
|
const draft = drafts.get(slug);
|
|
99
159
|
if (draft === undefined || draft.saving || taskSources.get(slug)?.saving) return;
|
|
100
160
|
const sent = draft.body;
|
|
@@ -109,7 +169,8 @@ export function createDraftStore(): DraftStore {
|
|
|
109
169
|
notify();
|
|
110
170
|
let ok = false;
|
|
111
171
|
try {
|
|
112
|
-
|
|
172
|
+
const saveOptions: SaveOptions = { ...(rawEdited ? { raw: true } : {}), ...(options?.keepOpen ? { quiet: true } : {}) };
|
|
173
|
+
ok = await onSave(slug, full, Object.keys(saveOptions).length === 0 ? undefined : saveOptions);
|
|
113
174
|
} finally {
|
|
114
175
|
// Discard/reopen during an in-flight request gives the new draft a new identity.
|
|
115
176
|
if (drafts.get(slug) === draft) {
|
|
@@ -119,13 +180,15 @@ export function createDraftStore(): DraftStore {
|
|
|
119
180
|
source.version = ++loadVersion;
|
|
120
181
|
delete source.pending;
|
|
121
182
|
}
|
|
122
|
-
if (ok && draft.body === sent && draft.raw === sentRaw) drafts.delete(slug);
|
|
183
|
+
if (ok && !options?.keepOpen && draft.body === sent && draft.raw === sentRaw) drafts.delete(slug);
|
|
123
184
|
else {
|
|
124
185
|
if (ok) {
|
|
125
186
|
draft.baseline = sent;
|
|
126
187
|
draft.rawBaseline = sentRaw;
|
|
127
188
|
draft.tail = tail;
|
|
189
|
+
draft.savedAt = now();
|
|
128
190
|
}
|
|
191
|
+
draft.failed = !ok;
|
|
129
192
|
draft.saving = false;
|
|
130
193
|
}
|
|
131
194
|
notify();
|