pi-weave 0.3.8 → 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 +21 -17
- package/package.json +4 -3
- package/skills/weave-explore/SKILL.md +3 -5
- package/skills/weave-notepad/SKILL.md +22 -2
- package/skills/weave-notepad/references/second-thoughts.md +57 -0
- package/src/core/graph/build.ts +1 -1
- package/src/core/graph/current.ts +10 -8
- package/src/core/history.ts +417 -0
- package/src/core/index.ts +19 -0
- package/src/core/links/repair.ts +5 -68
- package/src/core/noteAction.ts +72 -2
- package/src/core/openInEditor.ts +3 -8
- package/src/core/repoAction.ts +20 -61
- package/src/core/repoIndex.ts +38 -0
- package/src/core/trail.ts +124 -0
- package/src/core/types.ts +1 -3
- package/src/core/vault.ts +116 -29
- package/src/core/view/focus.ts +2 -2
- package/src/core/view/index.ts +2 -3
- package/src/core/view/rawTail.ts +153 -0
- package/src/core/workspace.ts +20 -26
- package/src/core/workspaceCommands.ts +14 -14
- package/src/opencode/tools.ts +3 -3
- package/src/opencode/tui.ts +1 -7
- package/src/pi/index.ts +68 -80
- package/src/pi/tools/noteTool.ts +1 -1
- package/src/pi/tools/repoTool.ts +3 -6
- package/src/pi/viewer/web/run.ts +6 -8
- package/src/web/client/api.ts +103 -5
- package/src/web/client/context/context.model.ts +5 -0
- package/src/web/client/dist/app.js +499 -129
- package/src/web/client/graph/Graph.tsx +166 -14
- package/src/web/client/graph/column.model.ts +59 -2
- package/src/web/client/graph/graph.model.ts +183 -7
- package/src/web/client/graph/renderer.ts +39 -9
- package/src/web/client/graph/spotlight.model.ts +472 -0
- package/src/web/client/note/Note.tsx +370 -66
- package/src/web/client/note/Replay.tsx +139 -0
- package/src/web/client/note/drafts.ts +115 -11
- package/src/web/client/note/editbar.model.ts +153 -0
- package/src/web/client/note/note.model.ts +82 -1
- package/src/web/client/note/replay.model.ts +248 -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/ContextRail.tsx +7 -0
- package/src/web/client/shell/Settings.tsx +21 -1
- package/src/web/client/shell/Shell.tsx +93 -27
- 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 +31 -8
- 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 +354 -32
- package/src/web/client/shell/workspace.css.ts +40 -9
- package/src/web/client/trail/TrailStrip.tsx +55 -0
- package/src/web/client/trail/trail.model.ts +195 -0
- package/src/web/client/trail/trail.ts +45 -0
- package/src/web/client/tree/Tree.tsx +7 -3
- package/src/web/client/tree/tree.model.ts +2 -5
- package/src/web/server/page.ts +1 -1
- package/src/web/server/routes.ts +83 -3
- package/src/web/shared/logo.ts +1 -1
- package/src/web/shared/themes.ts +1 -1
- package/src/web/shared/view.ts +10 -4
- package/src/web/shared/wire.ts +74 -1
- package/src/web/shared/workspace.ts +10 -2
- package/src/core/view/health.ts +0 -160
- package/src/pi/viewer/tui/bodyStore.ts +0 -95
- package/src/pi/viewer/tui/branding.ts +0 -14
- package/src/pi/viewer/tui/model.ts +0 -390
- package/src/pi/viewer/tui/openNote.ts +0 -11
- package/src/pi/viewer/tui/run.ts +0 -71
- package/src/pi/viewer/tui/surface/base.ts +0 -203
- package/src/pi/viewer/tui/surface/detail.ts +0 -315
- package/src/pi/viewer/tui/surface/explore.ts +0 -241
- package/src/pi/viewer/tui/surface/focus.ts +0 -139
- package/src/pi/viewer/tui/surface/health.ts +0 -102
- package/src/pi/viewer/tui/theme.ts +0 -90
- package/src/pi/viewer/tui/workspace.ts +0 -53
- package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
import DOMPurify from "dompurify";
|
|
42
42
|
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
43
|
-
import type { GraphPayload, NotePayload } from "../../shared/wire";
|
|
43
|
+
import type { GraphPayload, HistoryPayload, NotePayload } from "../../shared/wire";
|
|
44
44
|
import {
|
|
45
45
|
CREATED_WORD,
|
|
46
46
|
artifactKeyOfNode,
|
|
@@ -49,9 +49,8 @@ import {
|
|
|
49
49
|
EDIT_LABEL,
|
|
50
50
|
EDITED_WORD,
|
|
51
51
|
EDITOR_ARIA_LABEL,
|
|
52
|
+
EDITOR_PLACEHOLDER,
|
|
52
53
|
EMPTY_PREVIEW,
|
|
53
|
-
SAVE_LABEL,
|
|
54
|
-
SAVING_LABEL,
|
|
55
54
|
TASK_CHECKBOX_ATTR,
|
|
56
55
|
WIKILINK_ATTR,
|
|
57
56
|
hasTextSelection,
|
|
@@ -60,6 +59,10 @@ import {
|
|
|
60
59
|
previewAnchorOf,
|
|
61
60
|
previewCard,
|
|
62
61
|
previewPlacement,
|
|
62
|
+
RAW_EDITOR_ARIA_LABEL,
|
|
63
|
+
RAW_TAIL_ARIA_LABEL,
|
|
64
|
+
RAW_TITLE,
|
|
65
|
+
rawSummary,
|
|
63
66
|
reducePreview,
|
|
64
67
|
renderNote,
|
|
65
68
|
selectedArtifactPath,
|
|
@@ -72,7 +75,24 @@ import {
|
|
|
72
75
|
} from "./note.model";
|
|
73
76
|
import type { NoteHeaderView, PreviewElement, PreviewEvent } from "./note.model";
|
|
74
77
|
import { createDraftStore } from "./drafts";
|
|
75
|
-
import type { DraftStore } from "./drafts";
|
|
78
|
+
import type { DraftStore, SaveNote } from "./drafts";
|
|
79
|
+
import { ReplayBar, wrapMarks } from "./Replay";
|
|
80
|
+
import type { ReplayState } from "./Replay";
|
|
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";
|
|
76
96
|
|
|
77
97
|
/**
|
|
78
98
|
* The custom properties the preview card is placed with, written by the
|
|
@@ -97,7 +117,12 @@ export interface NoteProps {
|
|
|
97
117
|
* has already been reported to the user by the shell, which owns the one
|
|
98
118
|
* error-reporting gesture the client has.
|
|
99
119
|
*/
|
|
100
|
-
onSave:
|
|
120
|
+
onSave: SaveNote;
|
|
121
|
+
/**
|
|
122
|
+
* Load the note's saved revisions for the history slider. The History
|
|
123
|
+
* control is shown only when this is provided.
|
|
124
|
+
*/
|
|
125
|
+
onHistory?: (slug: string) => Promise<{ ok: true; data: HistoryPayload } | { ok: false; message: string }>;
|
|
101
126
|
/** Shared across Note instances so duplicate tabs edit one in-memory body. */
|
|
102
127
|
drafts?: DraftStore;
|
|
103
128
|
/** Stable per view; distinct when the same note is mounted more than once. */
|
|
@@ -109,66 +134,134 @@ export interface NoteProps {
|
|
|
109
134
|
}
|
|
110
135
|
|
|
111
136
|
/**
|
|
112
|
-
*
|
|
137
|
+
* Folder crumb, title, one quiet meta line, tags.
|
|
113
138
|
*
|
|
114
139
|
* The hierarchy is the point: the title is the page's largest voice and the
|
|
115
|
-
* meta line is its footnote
|
|
116
|
-
* The Open in `$EDITOR`
|
|
117
|
-
* 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.
|
|
118
144
|
*/
|
|
119
145
|
function Header({
|
|
120
146
|
view,
|
|
121
147
|
open,
|
|
122
148
|
editing,
|
|
123
|
-
saving,
|
|
124
149
|
taskSaving,
|
|
125
|
-
|
|
126
|
-
|
|
150
|
+
onEdit,
|
|
151
|
+
replaying,
|
|
152
|
+
onHistory,
|
|
127
153
|
}: {
|
|
128
154
|
view: NoteHeaderView;
|
|
129
155
|
open: () => void;
|
|
130
156
|
editing: boolean;
|
|
131
|
-
saving: boolean;
|
|
132
157
|
taskSaving: boolean;
|
|
133
|
-
|
|
134
|
-
|
|
158
|
+
onEdit: () => void;
|
|
159
|
+
replaying: boolean;
|
|
160
|
+
onHistory: (() => void) | null;
|
|
135
161
|
}) {
|
|
136
162
|
return (
|
|
137
163
|
<header class="weave-note-head">
|
|
164
|
+
<p class="weave-note-crumb">
|
|
165
|
+
<Icon name="folder" class="weave-icon" />
|
|
166
|
+
{view.folder}
|
|
167
|
+
</p>
|
|
138
168
|
<h3 class="weave-note-title">{view.title}</h3>
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
<
|
|
143
|
-
|
|
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>
|
|
144
193
|
</button>
|
|
145
|
-
<button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{DONE_LABEL}</button>
|
|
146
|
-
</>
|
|
147
|
-
) : <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{EDIT_LABEL}</button>}
|
|
148
|
-
<button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
|
|
149
|
-
<span class="weave-note-open-mark" aria-hidden="true">↗</span>
|
|
150
|
-
</button>
|
|
151
|
-
<span class={`weave-prov weave-prov-${view.provenance}`} title={view.provenanceTitle}>
|
|
152
|
-
{view.provenanceGlyph} {view.provenance}
|
|
153
|
-
</span>
|
|
154
|
-
<span class="weave-note-time" title={view.updatedIso}>
|
|
155
|
-
{EDITED_WORD} {view.updated}
|
|
156
|
-
</span>
|
|
157
|
-
<span class="weave-note-time" title={view.createdIso}>
|
|
158
|
-
{CREATED_WORD} {view.created}
|
|
159
|
-
</span>
|
|
160
|
-
</p>
|
|
161
|
-
<p class="weave-note-tags">
|
|
162
|
-
{view.tags.map((tag) => (
|
|
163
|
-
<span key={tag} class="weave-tag">
|
|
164
|
-
{tagLabel(tag)}
|
|
165
194
|
</span>
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
)}
|
|
168
206
|
</header>
|
|
169
207
|
);
|
|
170
208
|
}
|
|
171
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
|
+
|
|
172
265
|
export function Note(props: NoteProps) {
|
|
173
266
|
const localDrafts = useRef(createDraftStore());
|
|
174
267
|
const drafts = props.drafts ?? localDrafts.current;
|
|
@@ -201,10 +294,20 @@ export function Note(props: NoteProps) {
|
|
|
201
294
|
const dispatch = (event: PreviewEvent): void => void sendPreview((state) => reducePreview(state, event));
|
|
202
295
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
203
296
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
|
297
|
+
const editorRef = useRef<HTMLTextAreaElement | null>(null);
|
|
298
|
+
// Set by the gesture that opened the editor *in this column*, consumed by
|
|
299
|
+
// the layout effect below once the textarea exists. A flag rather than
|
|
300
|
+
// "focus whenever a draft appears": the same note open in a second pane
|
|
301
|
+
// shares the draft, and must not take the cursor away from this one.
|
|
302
|
+
const focusEditor = useRef(false);
|
|
204
303
|
const draft = note === null ? null : drafts.get(note.slug);
|
|
304
|
+
// The `## Raw` box under the editor starts folded: a long dictation would
|
|
305
|
+
// otherwise outgrow the note it belongs to.
|
|
306
|
+
const [rawOpen, setRawOpen] = useState(false);
|
|
205
307
|
const taskSaving = note !== null && drafts.isTaskSaving(note.slug);
|
|
206
|
-
|
|
207
|
-
|
|
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 });
|
|
208
311
|
};
|
|
209
312
|
|
|
210
313
|
const saveTask = async (checkbox: HTMLInputElement, taskIndex: number): Promise<void> => {
|
|
@@ -239,19 +342,163 @@ export function Note(props: NoteProps) {
|
|
|
239
342
|
useEffect(() => {
|
|
240
343
|
if (note !== null && defaultOpened.current !== note.slug) {
|
|
241
344
|
defaultOpened.current = note.slug;
|
|
242
|
-
if (props.defaultEdit && drafts.get(note.slug) === null)
|
|
345
|
+
if (props.defaultEdit && drafts.get(note.slug) === null) {
|
|
346
|
+
focusEditor.current = true;
|
|
347
|
+
drafts.open(note.slug, body);
|
|
348
|
+
}
|
|
243
349
|
}
|
|
244
350
|
}, [note?.slug, props.defaultEdit]);
|
|
245
351
|
|
|
246
|
-
const
|
|
247
|
-
if (note === null || drafts.isTaskSaving(note.slug)) return;
|
|
248
|
-
|
|
249
|
-
|
|
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);
|
|
382
|
+
} else if (!drafts.isDirty(note.slug) || window.confirm(DISCARD_PROMPT)) drafts.discard(note.slug);
|
|
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
|
+
};
|
|
406
|
+
// Both editor boxes: bound here, not in `keys.model.ts`, because a global
|
|
407
|
+
// binding is a workspace-wide claim and Escape must not reach the shell.
|
|
408
|
+
const editorKeys = (event: KeyboardEvent): void => {
|
|
409
|
+
if (event.key === "Escape") {
|
|
410
|
+
event.stopPropagation();
|
|
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();
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const kind = formatForKey(event.key);
|
|
421
|
+
if (kind === null || event.shiftKey) return;
|
|
422
|
+
event.preventDefault();
|
|
423
|
+
format(kind);
|
|
250
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]);
|
|
251
435
|
const card = preview.anchor === null ? null : previewCard(props.graph, preview.anchor);
|
|
252
436
|
|
|
437
|
+
// Cursor in the textarea, after the last word, the moment it appears.
|
|
438
|
+
useLayoutEffect(() => {
|
|
439
|
+
const editor = editorRef.current;
|
|
440
|
+
if (editor === null || !focusEditor.current) return;
|
|
441
|
+
focusEditor.current = false;
|
|
442
|
+
editor.focus();
|
|
443
|
+
editor.setSelectionRange(editor.value.length, editor.value.length);
|
|
444
|
+
}, [draft !== null]);
|
|
445
|
+
|
|
446
|
+
// The history slider. Held per column, like the preview card: it is a view
|
|
447
|
+
// of the note on screen, and closes when the note changes.
|
|
448
|
+
const [replay, setReplay] = useState<ReplayState | null>(null);
|
|
449
|
+
const replaySlug = useRef<string | null>(null);
|
|
450
|
+
const loadHistory = props.onHistory;
|
|
451
|
+
const toggleHistory = (): void => {
|
|
452
|
+
if (note === null || loadHistory === undefined) return;
|
|
453
|
+
if (replay !== null) {
|
|
454
|
+
replaySlug.current = null;
|
|
455
|
+
setReplay(null);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const slug = note.slug;
|
|
459
|
+
replaySlug.current = slug;
|
|
460
|
+
setReplay({ revisions: null, enabled: true, trimmed: false, index: 0, playing: false, error: null });
|
|
461
|
+
void loadHistory(slug).then((result) => {
|
|
462
|
+
if (replaySlug.current !== slug) return;
|
|
463
|
+
setReplay(result.ok
|
|
464
|
+
? { revisions: result.data.revisions, enabled: result.data.enabled, trimmed: result.data.trimmed, index: Math.max(0, result.data.revisions.length - 1), playing: false, error: null }
|
|
465
|
+
: { revisions: null, enabled: true, trimmed: false, index: 0, playing: false, error: `Could not load history: ${result.message}` });
|
|
466
|
+
});
|
|
467
|
+
};
|
|
468
|
+
const step = replay?.revisions == null ? null : replayStep(replay.revisions, replay.index);
|
|
469
|
+
const replayHtml = useMemo(
|
|
470
|
+
() => (step === null || index === null ? null : renderNote(DOMPurify, step.marked, index)),
|
|
471
|
+
[step?.marked, index],
|
|
472
|
+
);
|
|
473
|
+
useLayoutEffect(() => {
|
|
474
|
+
replaySlug.current = null;
|
|
475
|
+
setReplay(null);
|
|
476
|
+
setRawOpen(false);
|
|
477
|
+
}, [note === null ? null : note.slug]);
|
|
478
|
+
useLayoutEffect(() => {
|
|
479
|
+
if (replayHtml === null || bodyRef.current === null) return;
|
|
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" });
|
|
483
|
+
}, [replayHtml]);
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
if (replay === null || !replay.playing || replay.revisions === null) return;
|
|
486
|
+
const count = replay.revisions.length;
|
|
487
|
+
const timer = window.setTimeout(() => {
|
|
488
|
+
setReplay((state) => {
|
|
489
|
+
if (state === null) return state;
|
|
490
|
+
const next = nextStep(state.index, count);
|
|
491
|
+
return next === null ? { ...state, playing: false } : { ...state, index: next };
|
|
492
|
+
});
|
|
493
|
+
}, PLAY_INTERVAL_MS);
|
|
494
|
+
return () => window.clearTimeout(timer);
|
|
495
|
+
}, [replay]);
|
|
496
|
+
|
|
253
497
|
useLayoutEffect(() => {
|
|
254
|
-
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);
|
|
255
502
|
}, [drafts, note === null ? null : note.slug, note?.body, props.loadVersion]);
|
|
256
503
|
|
|
257
504
|
// A card whose target note left the screen is a claim about a document that
|
|
@@ -340,35 +587,87 @@ export function Note(props: NoteProps) {
|
|
|
340
587
|
const header = noteHeader(note, props.now);
|
|
341
588
|
return (
|
|
342
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
|
+
)}
|
|
343
600
|
<Header
|
|
344
601
|
view={header}
|
|
345
602
|
open={() => props.onOpen(note.slug)}
|
|
346
603
|
editing={draft !== null}
|
|
347
|
-
saving={draft?.saving ?? false}
|
|
348
604
|
taskSaving={taskSaving}
|
|
349
|
-
|
|
350
|
-
|
|
605
|
+
onEdit={startEdit}
|
|
606
|
+
replaying={replay !== null}
|
|
607
|
+
onHistory={loadHistory === undefined ? null : toggleHistory}
|
|
351
608
|
/>
|
|
609
|
+
{replay === null ? null : (
|
|
610
|
+
<ReplayBar
|
|
611
|
+
state={replay}
|
|
612
|
+
now={props.now}
|
|
613
|
+
onIndex={(value) => setReplay((state) => state === null ? state : { ...state, index: value, playing: false })}
|
|
614
|
+
onPlay={() => setReplay((state) => {
|
|
615
|
+
if (state === null || state.revisions === null) return state;
|
|
616
|
+
if (state.playing) return { ...state, playing: false };
|
|
617
|
+
// Playing from the end starts over: that is what "watch it being written" means.
|
|
618
|
+
const restart = state.index >= state.revisions.length - 1;
|
|
619
|
+
return { ...state, index: restart ? 0 : state.index, playing: true };
|
|
620
|
+
})}
|
|
621
|
+
/>
|
|
622
|
+
)}
|
|
352
623
|
{draft !== null ? (
|
|
624
|
+
<>
|
|
353
625
|
<textarea
|
|
626
|
+
ref={editorRef}
|
|
354
627
|
class="weave-note-editor"
|
|
355
628
|
aria-label={EDITOR_ARIA_LABEL}
|
|
629
|
+
placeholder={EDITOR_PLACEHOLDER}
|
|
356
630
|
spellcheck={props.spellcheck ?? true}
|
|
357
631
|
value={draft.body}
|
|
358
632
|
onInput={(event) => drafts.edit(note.slug, (event.target as HTMLTextAreaElement).value)}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
// workspace-wide claim, and Escape must not reach the shell.
|
|
362
|
-
if (event.key === "Escape") {
|
|
363
|
-
event.stopPropagation();
|
|
364
|
-
toggleEdit();
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
if (event.key.toLowerCase() !== "s" || !(event.metaKey || event.ctrlKey)) return;
|
|
368
|
-
event.preventDefault();
|
|
369
|
-
void save();
|
|
370
|
-
}}
|
|
633
|
+
onFocus={() => { lastBox.current = "body"; }}
|
|
634
|
+
onKeyDown={editorKeys}
|
|
371
635
|
/>
|
|
636
|
+
{/* A box of its own, never the body's textarea: text typed below
|
|
637
|
+
the heading there used to be dropped on save without a word. */}
|
|
638
|
+
{draft.tail === "" ? null : (
|
|
639
|
+
<section class="weave-raw" aria-label={RAW_TAIL_ARIA_LABEL} data-open={rawOpen ? "" : undefined}>
|
|
640
|
+
<div class="weave-raw-head">
|
|
641
|
+
<button
|
|
642
|
+
type="button"
|
|
643
|
+
class="weave-raw-toggle"
|
|
644
|
+
aria-expanded={rawOpen}
|
|
645
|
+
aria-controls={`${props.idPrefix ?? generatedId}-weave-raw`}
|
|
646
|
+
onClick={() => setRawOpen((open) => !open)}
|
|
647
|
+
>
|
|
648
|
+
<svg class="weave-raw-chevron" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
|
649
|
+
<path d="M4.5 2.5 8 6l-3.5 3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
650
|
+
</svg>
|
|
651
|
+
{RAW_TITLE}
|
|
652
|
+
</button>
|
|
653
|
+
<span class="weave-raw-hint">{rawSummary(draft.raw, rawOpen)}</span>
|
|
654
|
+
</div>
|
|
655
|
+
{rawOpen ? (
|
|
656
|
+
<textarea
|
|
657
|
+
id={`${props.idPrefix ?? generatedId}-weave-raw`}
|
|
658
|
+
class="weave-raw-editor"
|
|
659
|
+
aria-label={RAW_EDITOR_ARIA_LABEL}
|
|
660
|
+
spellcheck={props.spellcheck ?? true}
|
|
661
|
+
value={draft.raw}
|
|
662
|
+
ref={rawEditorRef}
|
|
663
|
+
onInput={(event) => drafts.editRaw(note.slug, (event.target as HTMLTextAreaElement).value)}
|
|
664
|
+
onFocus={() => { lastBox.current = "raw"; }}
|
|
665
|
+
onKeyDown={editorKeys}
|
|
666
|
+
/>
|
|
667
|
+
) : null}
|
|
668
|
+
</section>
|
|
669
|
+
)}
|
|
670
|
+
</>
|
|
372
671
|
) : (
|
|
373
672
|
<div
|
|
374
673
|
ref={bodyRef}
|
|
@@ -403,6 +702,11 @@ export function Note(props: NoteProps) {
|
|
|
403
702
|
onClick={(event) => {
|
|
404
703
|
const checkbox = event.target as HTMLInputElement;
|
|
405
704
|
const taskIndex = taskCheckboxIndexOf(checkbox);
|
|
705
|
+
// A past version is read-only: its checkboxes must not write to the note.
|
|
706
|
+
if (taskIndex !== null && replayHtml !== null) {
|
|
707
|
+
event.preventDefault();
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
406
710
|
if (taskIndex !== null) {
|
|
407
711
|
void saveTask(checkbox, taskIndex);
|
|
408
712
|
return;
|
|
@@ -434,7 +738,7 @@ export function Note(props: NoteProps) {
|
|
|
434
738
|
props.onSelect(target, event.metaKey || event.ctrlKey || event.shiftKey);
|
|
435
739
|
}}
|
|
436
740
|
// Sanitised by `renderNote`'s three layers — see note.model.ts.
|
|
437
|
-
dangerouslySetInnerHTML={{ __html: html }}
|
|
741
|
+
dangerouslySetInnerHTML={{ __html: replayHtml ?? html }}
|
|
438
742
|
/>
|
|
439
743
|
)}
|
|
440
744
|
{card === null ? null : (
|