@memorysmithapp/markdown-renderer 0.3.2 → 0.5.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 +50 -4
- package/dist/Markdown.d.ts +6 -2
- package/dist/Markdown.js +126 -17
- package/dist/NoteActions.d.ts +9 -0
- package/dist/NoteActions.js +68 -0
- package/dist/NoteBody.d.ts +32 -2
- package/dist/NoteBody.js +74 -7
- package/dist/edit/AttachDialog.d.ts +23 -0
- package/dist/edit/AttachDialog.js +126 -0
- package/dist/edit/ContextMenu.d.ts +29 -0
- package/dist/edit/ContextMenu.js +95 -0
- package/dist/edit/LanguageControl.d.ts +19 -0
- package/dist/edit/LanguageControl.js +124 -0
- package/dist/edit/NoteEditor.d.ts +44 -0
- package/dist/edit/NoteEditor.js +520 -0
- package/dist/edit/SaveNoticeDialog.d.ts +15 -0
- package/dist/edit/SaveNoticeDialog.js +41 -0
- package/dist/edit/analyze.d.ts +150 -0
- package/dist/edit/analyze.js +268 -0
- package/dist/edit/blocks.d.ts +97 -0
- package/dist/edit/blocks.js +481 -0
- package/dist/edit/commands.d.ts +78 -0
- package/dist/edit/commands.js +257 -0
- package/dist/edit/frontmatter.d.ts +45 -0
- package/dist/edit/frontmatter.js +142 -0
- package/dist/edit/index.d.ts +5 -0
- package/dist/edit/index.js +7 -0
- package/dist/edit/live.d.ts +28 -0
- package/dist/edit/live.js +675 -0
- package/dist/edit/saves.d.ts +48 -0
- package/dist/edit/saves.js +73 -0
- package/dist/edit/table.d.ts +58 -0
- package/dist/edit/table.js +185 -0
- package/dist/file-types.d.ts +30 -0
- package/dist/file-types.js +70 -0
- package/dist/highlight.d.ts +21 -7
- package/dist/highlight.js +56 -7
- package/dist/host.d.ts +82 -3
- package/dist/host.js +16 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +1 -0
- package/dist/mode-icons.d.ts +6 -0
- package/dist/mode-icons.js +11 -0
- package/dist/prism-grammars.d.ts +18 -0
- package/dist/prism-grammars.js +305 -0
- package/dist/remark-callouts.d.ts +6 -0
- package/dist/remark-callouts.js +2 -2
- package/dist/remark-ring.d.ts +5 -0
- package/dist/remark-ring.js +25 -15
- package/dist/slots.js +17 -1
- package/dist/strings.d.ts +129 -1
- package/dist/strings.js +127 -1
- package/dist/task-saves.d.ts +35 -0
- package/dist/task-saves.js +86 -0
- package/dist/theme.d.ts +47 -0
- package/dist/theme.js +2 -2
- package/package.json +14 -2
- package/structure.css +355 -0
- package/theme-default.css +1099 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A note edited on the page that draws it (#48, ADR-006).
|
|
4
|
+
*
|
|
5
|
+
* The editor holds the note's bytes and nothing else: CodeMirror keeps the text, the live page
|
|
6
|
+
* (`live.ts`) draws it as the reading surface would, and every change the author makes — typed,
|
|
7
|
+
* from the toolbar, in a table's cell or in the properties form — is a change to those bytes,
|
|
8
|
+
* handed to the host with `onChange`. What the author did not touch is never rewritten, so the
|
|
9
|
+
* host receives a note that differs from the one it gave only where it was edited (#50).
|
|
10
|
+
*/
|
|
11
|
+
import { autocompletion } from '@codemirror/autocomplete';
|
|
12
|
+
import { defaultKeymap, history, historyKeymap, indentWithTab, redo, undo } from '@codemirror/commands';
|
|
13
|
+
import { markdown, markdownKeymap } from '@codemirror/lang-markdown';
|
|
14
|
+
import { Annotation, Compartment, EditorState } from '@codemirror/state';
|
|
15
|
+
import { EditorView, keymap } from '@codemirror/view';
|
|
16
|
+
import { useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react';
|
|
17
|
+
import { HOST_KEYS, HostProvider, isAttachRefusal, resolveHost } from '../host.js';
|
|
18
|
+
import { rootStyle } from '../theme.js';
|
|
19
|
+
import { ICONS } from './blocks.js';
|
|
20
|
+
import { BLOCKS, command, headingLevel, insertBlock, insertFootnote, insertLink, insertWikilink, setHeading, toggleComment, toggleList, toggleMark, toggleQuote, } from './commands.js';
|
|
21
|
+
import { analysisField, editorEnvironment, livePage } from './live.js';
|
|
22
|
+
import { NoteActions } from '../NoteActions.js';
|
|
23
|
+
import { AttachDialog, webImage } from './AttachDialog.js';
|
|
24
|
+
import { ContextMenu } from './ContextMenu.js';
|
|
25
|
+
import { chooseLanguageAt } from './LanguageControl.js';
|
|
26
|
+
import { useEditSaves } from './saves.js';
|
|
27
|
+
import { SaveNoticeDialog } from './SaveNoticeDialog.js';
|
|
28
|
+
/**
|
|
29
|
+
* The line break a note is written with, which the editor writes back: `\n` or `\r\n`. `null`
|
|
30
|
+
* for a note that mixes them, or has a lone `\r`, which the editor cannot keep byte for byte.
|
|
31
|
+
*/
|
|
32
|
+
export function lineBreakOf(text) {
|
|
33
|
+
if (!text.includes('\r'))
|
|
34
|
+
return '\n';
|
|
35
|
+
const crlf = (text.match(/\r\n/g) ?? []).length;
|
|
36
|
+
const cr = (text.match(/\r/g) ?? []).length;
|
|
37
|
+
const lf = (text.match(/\n/g) ?? []).length;
|
|
38
|
+
return crlf === cr && crlf === lf ? '\r\n' : null;
|
|
39
|
+
}
|
|
40
|
+
/** The change a host's new text makes to the editor's: the middle that differs, prefix and suffix kept. */
|
|
41
|
+
function difference(current, next) {
|
|
42
|
+
if (current === next)
|
|
43
|
+
return null;
|
|
44
|
+
let start = 0;
|
|
45
|
+
while (start < current.length && start < next.length && current[start] === next[start])
|
|
46
|
+
start++;
|
|
47
|
+
let end = 0;
|
|
48
|
+
while (end < current.length - start && end < next.length - start && current[current.length - 1 - end] === next[next.length - 1 - end])
|
|
49
|
+
end++;
|
|
50
|
+
return { from: start, to: current.length - end, insert: next.slice(start, next.length - end) };
|
|
51
|
+
}
|
|
52
|
+
/** The note back to the text edit mode opened, as one change, the least that differs, which Undo reverses (#67). */
|
|
53
|
+
function revert(state, opened) {
|
|
54
|
+
const change = difference(state.doc.toString(), opened);
|
|
55
|
+
return change ? { changes: change, selection: { anchor: Math.min(change.from + change.insert.length, opened.length) } } : null;
|
|
56
|
+
}
|
|
57
|
+
/** A change that came from the host, which is not handed back to it. */
|
|
58
|
+
const fromHost = Annotation.define();
|
|
59
|
+
/** The names a host suggests after `[[`, completed with the closing brackets. */
|
|
60
|
+
function wikilinkCompletion(host) {
|
|
61
|
+
return async (context) => {
|
|
62
|
+
const typed = context.matchBefore(/\[\[[^\]|#\n]*$/);
|
|
63
|
+
const suggest = host().suggest;
|
|
64
|
+
if (!typed || !suggest)
|
|
65
|
+
return null;
|
|
66
|
+
const query = typed.text.slice(2);
|
|
67
|
+
const names = await suggest(query);
|
|
68
|
+
const closed = context.state.sliceDoc(context.pos, context.pos + 2) === ']]';
|
|
69
|
+
return {
|
|
70
|
+
from: typed.from + 2,
|
|
71
|
+
options: names.map((name) => ({ label: name, apply: closed ? name : `${name}]]` })),
|
|
72
|
+
validFor: /^[^\]|#\n]*$/,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const shortcuts = (view) => keymap.of([
|
|
77
|
+
{ key: 'Mod-b', run: command((state) => toggleMark(state, '**')) },
|
|
78
|
+
{ key: 'Mod-i', run: command((state) => toggleMark(state, '_')) },
|
|
79
|
+
{ key: 'Mod-Shift-x', run: command((state) => toggleMark(state, '~~')) },
|
|
80
|
+
{ key: 'Mod-Shift-h', run: command((state) => toggleMark(state, '==')) },
|
|
81
|
+
{ key: 'Mod-e', run: command((state) => toggleMark(state, '`')) },
|
|
82
|
+
{ key: 'Mod-k', run: command(insertLink) },
|
|
83
|
+
{ key: 'Mod-Shift-k', run: command(insertWikilink) },
|
|
84
|
+
{ key: 'Mod-Shift-7', run: command((state) => toggleList(state, 'ordered')) },
|
|
85
|
+
{ key: 'Mod-Shift-8', run: command((state) => toggleList(state, 'bullet')) },
|
|
86
|
+
{ key: 'Mod-Shift-9', run: command((state) => toggleList(state, 'task')) },
|
|
87
|
+
{ key: 'Mod-Shift-.', run: command(toggleQuote) },
|
|
88
|
+
{ key: 'Mod-/', run: command(toggleComment) },
|
|
89
|
+
...[0, 1, 2, 3, 4, 5, 6].map((level) => ({ key: `Mod-Alt-${level}`, run: command((state) => setHeading(state, level)) })),
|
|
90
|
+
{ key: 'Escape', run: () => (view()?.contentDOM.blur(), true) },
|
|
91
|
+
]);
|
|
92
|
+
/** The editing menu's keys: the context-menu key and Shift+F10 open it at the cursor (#81). */
|
|
93
|
+
const menuKeys = (open) => keymap.of([
|
|
94
|
+
{ key: 'Shift-F10', run: open },
|
|
95
|
+
{ key: 'ContextMenu', run: open },
|
|
96
|
+
]);
|
|
97
|
+
/**
|
|
98
|
+
* Inserts one of the toolbar's blocks; a code block then opens the choice of its language, its
|
|
99
|
+
* search focused, so the author names the language first (#84).
|
|
100
|
+
*/
|
|
101
|
+
function insertBlockCommand(name) {
|
|
102
|
+
const insert = command((state) => insertBlock(state, BLOCKS[name].text, BLOCKS[name].cursor));
|
|
103
|
+
if (name !== 'codeBlock')
|
|
104
|
+
return insert;
|
|
105
|
+
return (view) => {
|
|
106
|
+
if (!insert(view))
|
|
107
|
+
return false;
|
|
108
|
+
const line = view.state.doc.lineAt(view.state.selection.main.head);
|
|
109
|
+
if (line.number > 1)
|
|
110
|
+
chooseLanguageAt(view, view.state.doc.line(line.number - 1).from);
|
|
111
|
+
return true;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const NOT_ATTACHING = { state: 'idle', message: null, fraction: null, queued: 0 };
|
|
115
|
+
/** How long the attach button says a file was not attached, when the host did not say why, in milliseconds. */
|
|
116
|
+
const ATTACH_FAILED_MS = 3000;
|
|
117
|
+
/** A host's fraction, kept between 0 and 1, or none when it gave no number. */
|
|
118
|
+
function fractionOf(report) {
|
|
119
|
+
const fraction = report?.fraction;
|
|
120
|
+
return typeof fraction === 'number' && Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : null;
|
|
121
|
+
}
|
|
122
|
+
/** What the paperclip says: its word, the host's step and the files waiting, or why the last one failed. */
|
|
123
|
+
function attachSays(attaching, words) {
|
|
124
|
+
if (attaching.state === 'failed')
|
|
125
|
+
return attaching.message ?? words.attachFailed;
|
|
126
|
+
if (attaching.state === 'idle')
|
|
127
|
+
return words.attach;
|
|
128
|
+
const step = attaching.message ?? words.attaching;
|
|
129
|
+
return attaching.queued > 0 ? `${step} (${words.attachQueued(attaching.queued)})` : step;
|
|
130
|
+
}
|
|
131
|
+
/** The toolbar of the editor: the marks, the blocks and the history, each a command. */
|
|
132
|
+
function Toolbar({ view, host, end, opened, onAttach, attaching, attachButton, }) {
|
|
133
|
+
const words = host.strings.editor;
|
|
134
|
+
const disabled = !view || view.state.readOnly;
|
|
135
|
+
const run = (action) => () => view && action(view);
|
|
136
|
+
const button = (name, action, shortcut) => (_jsx("button", { type: "button", className: "edit-control", title: shortcut ? `${words[name]} (${shortcut})` : words[name], "aria-label": words[name], disabled: disabled, onMouseDown: (event) => event.preventDefault(), onClick: run(action), children: ICONS[name] }, name));
|
|
137
|
+
const block = (name) => button(name, insertBlockCommand(name));
|
|
138
|
+
const level = view ? headingLevel(view.state) : 0;
|
|
139
|
+
return (_jsxs("div", { className: "edit-toolbar", role: "toolbar", "aria-label": words.toolbar, children: [_jsxs("div", { className: "edit-toolbar-tools", children: [_jsxs("span", { className: "edit-toolbar-group", children: [_jsx("button", { type: "button", className: "edit-control", title: words.revert, "aria-label": words.revert, disabled: !view || view.state.readOnly || view.state.doc.toString() === opened, onMouseDown: (event) => event.preventDefault(), onClick: run(command((state) => revert(state, opened))), children: ICONS.revert }), button('undo', undo, 'Mod-Z'), button('redo', redo, 'Mod-Shift-Z')] }), _jsx("span", { className: "edit-toolbar-group", children: _jsxs("select", { className: "edit-heading", "aria-label": words.heading, title: words.heading, value: level, disabled: disabled, onChange: (event) => view && command((state) => setHeading(state, Number(event.target.value)))(view), children: [_jsx("option", { value: 0, children: words.paragraph }), [1, 2, 3, 4, 5, 6].map((each) => (_jsx("option", { value: each, children: `${words.heading} ${each}` }, each)))] }) }), _jsxs("span", { className: "edit-toolbar-group", children: [button('bold', command((state) => toggleMark(state, '**')), 'Mod-B'), button('italic', command((state) => toggleMark(state, '_')), 'Mod-I'), button('strikethrough', command((state) => toggleMark(state, '~~')), 'Mod-Shift-X'), button('highlight', command((state) => toggleMark(state, '==')), 'Mod-Shift-H'), button('code', command((state) => toggleMark(state, '`')), 'Mod-E')] }), _jsxs("span", { className: "edit-toolbar-group", children: [button('link', command(insertLink), 'Mod-K'), button('wikilink', command(insertWikilink), 'Mod-Shift-K'), button('footnote', command(insertFootnote)), button('comment', command(toggleComment), 'Mod-/'), _jsxs("span", { className: "edit-attach-control", children: [_jsx("button", { ref: attachButton, type: "button", className: "edit-control", title: attachSays(attaching, words), "aria-label": words.attach, "aria-busy": attaching.state === 'busy' ? true : undefined, "data-attach": attaching.state, disabled: disabled, onMouseDown: (event) => event.preventDefault(), onClick: onAttach, children: attaching.state === 'busy' ? ICONS.saving : attaching.state === 'failed' ? ICONS.notSaved : ICONS.attach }), attaching.state === 'busy' && attaching.fraction !== null && (_jsx("span", { className: "edit-attach-progress", role: "progressbar", "aria-label": words.attach, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.round(attaching.fraction * 100), style: { ['--edit-attach-fraction']: attaching.fraction } })), _jsx("span", { className: "visually-hidden", role: "status", "aria-live": "polite", children: attaching.state === 'idle' ? '' : attachSays(attaching, words) })] })] }), _jsxs("span", { className: "edit-toolbar-group", children: [button('bulletList', command((state) => toggleList(state, 'bullet')), 'Mod-Shift-8'), button('orderedList', command((state) => toggleList(state, 'ordered')), 'Mod-Shift-7'), button('taskList', command((state) => toggleList(state, 'task')), 'Mod-Shift-9'), button('quote', command(toggleQuote), 'Mod-Shift-.')] }), _jsxs("span", { className: "edit-toolbar-group", children: [block('callout'), block('table'), block('codeBlock'), block('math'), block('diagram'), block('rule')] })] }), end] }));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Where the save of the author's last change stands, in the toolbar before the switch: an icon for
|
|
143
|
+
* each state, in a slot of one size, so the toolbar never moves as the author types; its word is
|
|
144
|
+
* its title. A failed save's icon is the button that tries again (#66). When the host said why —
|
|
145
|
+
* an alert on a save that succeeded, or the reason for one it refused — the icon is the button that
|
|
146
|
+
* opens what it said, with the host's title as its tooltip, and the author's choices (#86). Whoever
|
|
147
|
+
* hears the page is told the whole of it, why the note stays in edit included.
|
|
148
|
+
*/
|
|
149
|
+
function SaveStatus({ saves, blocked, host, onDecided }) {
|
|
150
|
+
const { state, notice } = saves;
|
|
151
|
+
const words = host.strings.editor;
|
|
152
|
+
const [open, setOpen] = useState(false);
|
|
153
|
+
const button = useRef(null);
|
|
154
|
+
// A notice the save no longer carries has nothing left to show.
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!notice)
|
|
157
|
+
setOpen(false);
|
|
158
|
+
}, [notice]);
|
|
159
|
+
const base = state === 'saving' ? words.saving : state === 'saved' ? host.strings.saved : state === 'failed' ? host.strings.notSaved : '';
|
|
160
|
+
const shown = notice ? notice.title : base;
|
|
161
|
+
const said = state === 'failed' && blocked ? (notice ? `${words.stayInEdit}. ${notice.title}` : words.stayInEdit) : shown;
|
|
162
|
+
const icon = state === 'saving' ? ICONS.saving : state === 'saved' ? ICONS.saved : state === 'alert' ? ICONS.alert : state === 'failed' ? ICONS.notSaved : null;
|
|
163
|
+
const retryLabel = `${said}. ${words.retry}`;
|
|
164
|
+
return (_jsxs("div", { className: "edit-save", "data-save": state, title: state === 'failed' || notice ? undefined : said || undefined, children: [notice ? (_jsx("button", { ref: button, type: "button", className: "edit-save-icon edit-save-notice", title: notice.title, "aria-label": notice.title, "aria-haspopup": "dialog", onClick: () => setOpen(true), children: icon })) : state === 'failed' ? (_jsx("button", { type: "button", className: "edit-save-icon edit-save-retry", title: retryLabel, "aria-label": retryLabel, onClick: saves.retry, children: icon })) : (_jsx("span", { className: "edit-save-icon", children: icon })), _jsx("span", { className: "visually-hidden", role: "status", "aria-live": "polite", children: said }), open && notice && (_jsx(SaveNoticeDialog, { notice: notice, onClose: () => setOpen(false), onClosed: (chosen) => (chosen ? onDecided() : button.current?.focus()), onAction: (action) => {
|
|
165
|
+
setOpen(false);
|
|
166
|
+
saves.run(action);
|
|
167
|
+
} }))] }));
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Edits a note on the page that draws it. Exported at `@memorysmithapp/markdown-renderer/edit`
|
|
171
|
+
* for a host that edits without the switch of `NoteBody`; `NoteBody` loads it on demand.
|
|
172
|
+
*/
|
|
173
|
+
export function NoteEditor({ markdown: given, onChange, editable = true, corner, autoFocus = false, leaving = false, onLeave, ...host }) {
|
|
174
|
+
const resolved = useMemo(() => resolveHost(host),
|
|
175
|
+
// Every member of the contract, so a host that passes the same functions keeps the same host.
|
|
176
|
+
HOST_KEYS.map((key) => host[key]));
|
|
177
|
+
const parent = useRef(null);
|
|
178
|
+
const [view, setView] = useState(null);
|
|
179
|
+
const [, redraw] = useReducer((count) => count + 1, 0);
|
|
180
|
+
const environment = useRef(new Compartment());
|
|
181
|
+
const access = useRef(new Compartment());
|
|
182
|
+
const saves = useEditSaves(onChange);
|
|
183
|
+
const latest = useRef({ hand: saves.hand, host: resolved, onLeave });
|
|
184
|
+
latest.current = { hand: saves.hand, host: resolved, onLeave };
|
|
185
|
+
/**
|
|
186
|
+
* The texts handed to the host that it has not given back yet. A host that saves before it
|
|
187
|
+
* passes the note again gives back a text the editor has since moved past; that echo is not a
|
|
188
|
+
* change, and is never applied over what the author wrote after it.
|
|
189
|
+
*/
|
|
190
|
+
const handed = useRef([]);
|
|
191
|
+
const bar = useRef(null);
|
|
192
|
+
const [blocked, setBlocked] = useState(false);
|
|
193
|
+
const [refusals, setRefusals] = useState(0);
|
|
194
|
+
const editorView = useRef(null);
|
|
195
|
+
const [attaching, setAttaching] = useState(NOT_ATTACHING);
|
|
196
|
+
// Each run of attaching, so that a run a later one replaced says nothing more.
|
|
197
|
+
const attachRun = useRef(0);
|
|
198
|
+
// The refusal the paperclip said, shown again at the top of the dialog it opens (#88).
|
|
199
|
+
const [refusal, setRefusal] = useState(null);
|
|
200
|
+
/**
|
|
201
|
+
* Hands each file to the host's `attach`, one after the other, and embeds it as `![[name]]` on a
|
|
202
|
+
* line of its own where the cursor is once the host answered its name; a file the host could not
|
|
203
|
+
* keep writes nothing, and the button says so for a moment (#75) — or, when the host said why,
|
|
204
|
+
* until the author opens it again. The host's steps are shown as they come (#88).
|
|
205
|
+
*/
|
|
206
|
+
const attachFiles = useRef(() => undefined);
|
|
207
|
+
attachFiles.current = (files, at) => {
|
|
208
|
+
const attach = resolved.attach;
|
|
209
|
+
const target = editorView.current;
|
|
210
|
+
if (!attach || !target || target.state.readOnly || files.length === 0)
|
|
211
|
+
return;
|
|
212
|
+
if (at !== undefined)
|
|
213
|
+
target.dispatch({ selection: { anchor: at } });
|
|
214
|
+
const run = ++attachRun.current;
|
|
215
|
+
const live = () => run === attachRun.current && editorView.current !== null;
|
|
216
|
+
void (async () => {
|
|
217
|
+
let failed = false;
|
|
218
|
+
let reason = null;
|
|
219
|
+
for (const [index, { file, name: wanted, description }] of files.entries()) {
|
|
220
|
+
const queued = files.length - 1 - index;
|
|
221
|
+
let settled = false;
|
|
222
|
+
setAttaching({ state: 'busy', message: null, fraction: null, queued });
|
|
223
|
+
// A step the host reports after the file settled, or for a run since replaced, says nothing.
|
|
224
|
+
const progress = (report) => {
|
|
225
|
+
if (settled || !live())
|
|
226
|
+
return;
|
|
227
|
+
setAttaching({ state: 'busy', message: typeof report?.message === 'string' && report.message ? report.message : null, fraction: fractionOf(report), queued });
|
|
228
|
+
};
|
|
229
|
+
try {
|
|
230
|
+
const name = await attach(file, { name: wanted, description }, progress);
|
|
231
|
+
insertLine(`![[${name}]]`);
|
|
232
|
+
}
|
|
233
|
+
catch (rejection) {
|
|
234
|
+
failed = true;
|
|
235
|
+
if (isAttachRefusal(rejection))
|
|
236
|
+
reason = rejection.message;
|
|
237
|
+
}
|
|
238
|
+
settled = true;
|
|
239
|
+
}
|
|
240
|
+
if (live())
|
|
241
|
+
setAttaching(failed ? { ...NOT_ATTACHING, state: 'failed', message: reason } : NOT_ATTACHING);
|
|
242
|
+
})();
|
|
243
|
+
};
|
|
244
|
+
/** A line of its own at the cursor: an embed, or an image of the web (#78). */
|
|
245
|
+
const insertLine = (text) => {
|
|
246
|
+
const current = editorView.current;
|
|
247
|
+
if (current)
|
|
248
|
+
command((state) => insertBlock(state, text, text.length + 1))(current);
|
|
249
|
+
};
|
|
250
|
+
const attachButton = useRef(null);
|
|
251
|
+
const [attachOpen, setAttachOpen] = useState(false);
|
|
252
|
+
/** Where the editing menu is open, in the window's coordinates (#81). */
|
|
253
|
+
const [menu, setMenu] = useState(null);
|
|
254
|
+
const openMenu = useRef((x, y) => setMenu({ x, y }));
|
|
255
|
+
/** The menu's groups, from the same commands as the toolbar: Format with a selection, Insert, Edit. */
|
|
256
|
+
const menuGroups = (target) => {
|
|
257
|
+
const words = resolved.strings.editor;
|
|
258
|
+
const item = (name, action, shortcut) => ({
|
|
259
|
+
name,
|
|
260
|
+
label: words[name],
|
|
261
|
+
icon: ICONS[name],
|
|
262
|
+
shortcut,
|
|
263
|
+
run: () => action(target),
|
|
264
|
+
});
|
|
265
|
+
const block = (name) => item(name, insertBlockCommand(name));
|
|
266
|
+
const selection = target.state.selection.main;
|
|
267
|
+
const selected = target.state.sliceDoc(selection.from, selection.to);
|
|
268
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
|
|
269
|
+
const groups = [];
|
|
270
|
+
if (!selection.empty) {
|
|
271
|
+
groups.push({
|
|
272
|
+
label: words.menuFormat,
|
|
273
|
+
items: [
|
|
274
|
+
item('bold', command((state) => toggleMark(state, '**')), 'Mod-b'),
|
|
275
|
+
item('italic', command((state) => toggleMark(state, '_')), 'Mod-i'),
|
|
276
|
+
item('strikethrough', command((state) => toggleMark(state, '~~')), 'Mod-Shift-x'),
|
|
277
|
+
item('highlight', command((state) => toggleMark(state, '==')), 'Mod-Shift-h'),
|
|
278
|
+
item('code', command((state) => toggleMark(state, '`')), 'Mod-e'),
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
groups.push({
|
|
283
|
+
label: words.menuInsert,
|
|
284
|
+
items: [
|
|
285
|
+
item('link', command(insertLink), 'Mod-k'),
|
|
286
|
+
item('wikilink', command(insertWikilink), 'Mod-Shift-k'),
|
|
287
|
+
item('bulletList', command((state) => toggleList(state, 'bullet')), 'Mod-Shift-8'),
|
|
288
|
+
item('orderedList', command((state) => toggleList(state, 'ordered')), 'Mod-Shift-7'),
|
|
289
|
+
item('taskList', command((state) => toggleList(state, 'task')), 'Mod-Shift-9'),
|
|
290
|
+
item('quote', command(toggleQuote), 'Mod-Shift-.'),
|
|
291
|
+
block('callout'),
|
|
292
|
+
block('table'),
|
|
293
|
+
block('codeBlock'),
|
|
294
|
+
block('math'),
|
|
295
|
+
block('diagram'),
|
|
296
|
+
block('rule'),
|
|
297
|
+
item('footnote', command(insertFootnote)),
|
|
298
|
+
item('comment', command(toggleComment), 'Mod-/'),
|
|
299
|
+
{ name: 'attach', label: words.attach, icon: ICONS.attach, run: () => setAttachOpen(true) },
|
|
300
|
+
],
|
|
301
|
+
});
|
|
302
|
+
groups.push({
|
|
303
|
+
label: words.menuEdit,
|
|
304
|
+
items: [
|
|
305
|
+
{
|
|
306
|
+
name: 'cut',
|
|
307
|
+
label: words.cut,
|
|
308
|
+
icon: ICONS.cut,
|
|
309
|
+
shortcut: 'Mod-x',
|
|
310
|
+
disabled: selection.empty || !clipboard,
|
|
311
|
+
run: () => void clipboard?.writeText(selected).then(() => target.dispatch({ changes: { from: selection.from, to: selection.to }, userEvent: 'delete.cut' })),
|
|
312
|
+
},
|
|
313
|
+
{ name: 'copy', label: words.copy, icon: ICONS.copy, shortcut: 'Mod-c', disabled: selection.empty || !clipboard, run: () => void clipboard?.writeText(selected) },
|
|
314
|
+
{
|
|
315
|
+
name: 'paste',
|
|
316
|
+
label: words.paste,
|
|
317
|
+
icon: ICONS.paste,
|
|
318
|
+
shortcut: 'Mod-v',
|
|
319
|
+
disabled: !clipboard?.readText,
|
|
320
|
+
run: () => void clipboard?.readText().then((text) => target.dispatch({ ...target.state.replaceSelection(text), userEvent: 'input.paste', scrollIntoView: true }), () => undefined),
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
});
|
|
324
|
+
return groups;
|
|
325
|
+
};
|
|
326
|
+
/** A pasted or dropped file, kept under its own name and with no description. */
|
|
327
|
+
const asChosen = (files) => files.map((file) => ({ file, name: file.name, description: '' }));
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
// A failure the host explained stays until the author acts; a bare one is said for a moment.
|
|
330
|
+
if (attaching.state !== 'failed' || attaching.message !== null)
|
|
331
|
+
return;
|
|
332
|
+
const timer = setTimeout(() => setAttaching(NOT_ATTACHING), ATTACH_FAILED_MS);
|
|
333
|
+
return () => clearTimeout(timer);
|
|
334
|
+
}, [attaching]);
|
|
335
|
+
// The note as edit mode opened it, as the editor holds its text: lines joined by `\n`.
|
|
336
|
+
const opened = useRef(given.replace(/\r\n?/g, '\n'));
|
|
337
|
+
const lineBreak = lineBreakOf(given);
|
|
338
|
+
const readOnly = !editable || lineBreak === null;
|
|
339
|
+
/**
|
|
340
|
+
* The editor splits its text on any line break, so a paste or a block written with `\n` is lines
|
|
341
|
+
* like any other, and writes the note back with the break it was written with: what a host
|
|
342
|
+
* receives is CRLF throughout when the note was.
|
|
343
|
+
*/
|
|
344
|
+
const written = useRef(lineBreak ?? '\n');
|
|
345
|
+
written.current = lineBreak ?? written.current;
|
|
346
|
+
const output = (state) => state.doc.sliceString(0, state.doc.length, written.current);
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
if (!parent.current)
|
|
349
|
+
return;
|
|
350
|
+
const created = new EditorView({
|
|
351
|
+
parent: parent.current,
|
|
352
|
+
state: EditorState.create({
|
|
353
|
+
doc: given,
|
|
354
|
+
extensions: [
|
|
355
|
+
access.current.of([EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]),
|
|
356
|
+
environment.current.of(editorEnvironment.of({ host: resolved })),
|
|
357
|
+
history(),
|
|
358
|
+
markdown(),
|
|
359
|
+
EditorView.lineWrapping,
|
|
360
|
+
EditorView.contentAttributes.of({ 'aria-multiline': 'true', spellcheck: 'true' }),
|
|
361
|
+
livePage(),
|
|
362
|
+
autocompletion({ override: [wikilinkCompletion(() => latest.current.host)], icons: false }),
|
|
363
|
+
shortcuts(() => created),
|
|
364
|
+
keymap.of([...markdownKeymap, ...historyKeymap, indentWithTab, ...defaultKeymap]),
|
|
365
|
+
// A file pasted or dropped is attached, where the host keeps files (#75).
|
|
366
|
+
menuKeys((target) => {
|
|
367
|
+
if (target.state.readOnly)
|
|
368
|
+
return false;
|
|
369
|
+
const at = target.coordsAtPos(target.state.selection.main.head);
|
|
370
|
+
const box = target.contentDOM.getBoundingClientRect();
|
|
371
|
+
openMenu.current(at?.left ?? box.left, at?.bottom ?? box.top);
|
|
372
|
+
return true;
|
|
373
|
+
}),
|
|
374
|
+
EditorView.domEventHandlers({
|
|
375
|
+
// The editing menu where the pointer is; Shift keeps the browser's, and so do the
|
|
376
|
+
// drawn blocks' own fields, which are the browser's to edit (#81).
|
|
377
|
+
contextmenu: (event, target) => {
|
|
378
|
+
if (event.shiftKey || target.state.readOnly)
|
|
379
|
+
return false;
|
|
380
|
+
if (event.target instanceof Element && event.target.closest('[data-ms-control]'))
|
|
381
|
+
return false;
|
|
382
|
+
event.preventDefault();
|
|
383
|
+
const at = target.posAtCoords({ x: event.clientX, y: event.clientY });
|
|
384
|
+
const selection = target.state.selection.main;
|
|
385
|
+
if (at !== null && (at < selection.from || at > selection.to))
|
|
386
|
+
target.dispatch({ selection: { anchor: at } });
|
|
387
|
+
openMenu.current(event.clientX, event.clientY);
|
|
388
|
+
return true;
|
|
389
|
+
},
|
|
390
|
+
paste: (event) => {
|
|
391
|
+
const files = [...(event.clipboardData?.files ?? [])];
|
|
392
|
+
if (files.length === 0 || !latest.current.host.attach)
|
|
393
|
+
return false;
|
|
394
|
+
event.preventDefault();
|
|
395
|
+
attachFiles.current(asChosen(files));
|
|
396
|
+
return true;
|
|
397
|
+
},
|
|
398
|
+
drop: (event, target) => {
|
|
399
|
+
const files = [...(event.dataTransfer?.files ?? [])];
|
|
400
|
+
if (files.length === 0 || !latest.current.host.attach)
|
|
401
|
+
return false;
|
|
402
|
+
event.preventDefault();
|
|
403
|
+
attachFiles.current(asChosen(files), target.posAtCoords({ x: event.clientX, y: event.clientY }) ?? undefined);
|
|
404
|
+
return true;
|
|
405
|
+
},
|
|
406
|
+
}),
|
|
407
|
+
EditorView.updateListener.of((update) => {
|
|
408
|
+
if (update.docChanged && !update.transactions.some((transaction) => transaction.annotation(fromHost))) {
|
|
409
|
+
const text = output(update.state);
|
|
410
|
+
handed.current.push(text);
|
|
411
|
+
if (handed.current.length > 100)
|
|
412
|
+
handed.current.shift();
|
|
413
|
+
latest.current.hand(text);
|
|
414
|
+
}
|
|
415
|
+
if (update.docChanged || update.selectionSet || update.focusChanged)
|
|
416
|
+
redraw();
|
|
417
|
+
}),
|
|
418
|
+
],
|
|
419
|
+
}),
|
|
420
|
+
});
|
|
421
|
+
editorView.current = created;
|
|
422
|
+
setView(created);
|
|
423
|
+
if (autoFocus) {
|
|
424
|
+
// The cursor starts where the body does, so the frontmatter stays drawn as the form.
|
|
425
|
+
created.dispatch({ selection: { anchor: created.state.field(analysisField).bodyFrom } });
|
|
426
|
+
created.focus();
|
|
427
|
+
}
|
|
428
|
+
return () => {
|
|
429
|
+
editorView.current = null;
|
|
430
|
+
created.destroy();
|
|
431
|
+
};
|
|
432
|
+
// The editor is made once; what changes later reaches it through the effects below.
|
|
433
|
+
}, []);
|
|
434
|
+
useEffect(() => {
|
|
435
|
+
view?.dispatch({ effects: environment.current.reconfigure(editorEnvironment.of({ host: resolved })) });
|
|
436
|
+
}, [view, resolved]);
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
view?.dispatch({ effects: access.current.reconfigure([EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]) });
|
|
439
|
+
}, [view, readOnly]);
|
|
440
|
+
// A note the host changed — a tick saved, another device — is taken in as the change it is.
|
|
441
|
+
useEffect(() => {
|
|
442
|
+
if (!view)
|
|
443
|
+
return;
|
|
444
|
+
const echo = handed.current.indexOf(given);
|
|
445
|
+
if (echo !== -1) {
|
|
446
|
+
handed.current.splice(0, echo + 1);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
handed.current = [];
|
|
450
|
+
const change = difference(output(view.state), given);
|
|
451
|
+
if (change)
|
|
452
|
+
view.dispatch({ changes: change, annotations: [fromHost.of(true)] });
|
|
453
|
+
}, [view, given]);
|
|
454
|
+
/**
|
|
455
|
+
* The toolbar grows out of the switch and shrinks back into it (#65): its clip starts and ends
|
|
456
|
+
* on the corner that holds the switch, measured from the toolbar's top right edge.
|
|
457
|
+
*/
|
|
458
|
+
useLayoutEffect(() => {
|
|
459
|
+
const toolbar = bar.current?.querySelector('.edit-toolbar');
|
|
460
|
+
const end = toolbar?.querySelector('.edit-corner');
|
|
461
|
+
if (!toolbar || !end)
|
|
462
|
+
return;
|
|
463
|
+
const box = toolbar.getBoundingClientRect();
|
|
464
|
+
const corner = end.getBoundingClientRect();
|
|
465
|
+
// With the toolbar's own padding around it, so the corner alone reads as a box.
|
|
466
|
+
const style = typeof getComputedStyle === 'function' ? getComputedStyle(toolbar) : null;
|
|
467
|
+
const left = parseFloat(style?.paddingLeft ?? '') || 0;
|
|
468
|
+
const bottom = (parseFloat(style?.paddingBottom ?? '') || 0) + (parseFloat(style?.borderBottomWidth ?? '') || 0);
|
|
469
|
+
toolbar.style.setProperty('--edit-corner-width', `${Math.max(0, box.right - corner.left + left)}px`);
|
|
470
|
+
toolbar.style.setProperty('--edit-corner-height', `${Math.max(0, corner.bottom - box.top + bottom)}px`);
|
|
471
|
+
});
|
|
472
|
+
// Going back to reading waits for the save in flight; a failed one keeps the note here.
|
|
473
|
+
const folding = leaving && saves.state !== 'saving' && saves.state !== 'failed';
|
|
474
|
+
useEffect(() => {
|
|
475
|
+
if (saves.state !== 'failed')
|
|
476
|
+
setBlocked(false);
|
|
477
|
+
if (!leaving || saves.state === 'saving')
|
|
478
|
+
return;
|
|
479
|
+
if (saves.state === 'failed') {
|
|
480
|
+
setBlocked(true);
|
|
481
|
+
setRefusals((count) => count + 1);
|
|
482
|
+
latest.current.onLeave?.(false);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const toolbar = bar.current?.querySelector('.edit-toolbar');
|
|
486
|
+
const animation = toolbar && typeof getComputedStyle === 'function' ? getComputedStyle(toolbar).animationName : '';
|
|
487
|
+
let done = false;
|
|
488
|
+
const leave = () => {
|
|
489
|
+
if (done)
|
|
490
|
+
return;
|
|
491
|
+
done = true;
|
|
492
|
+
latest.current.onLeave?.(true);
|
|
493
|
+
};
|
|
494
|
+
// A theme that does not fold the toolbar, or a reader who asked for less motion, goes at once.
|
|
495
|
+
if (!toolbar || !animation || animation === 'none') {
|
|
496
|
+
leave();
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
toolbar.addEventListener('animationend', leave);
|
|
500
|
+
const timer = setTimeout(leave, 600);
|
|
501
|
+
return () => {
|
|
502
|
+
toolbar.removeEventListener('animationend', leave);
|
|
503
|
+
clearTimeout(timer);
|
|
504
|
+
};
|
|
505
|
+
}, [leaving, saves.state]);
|
|
506
|
+
return (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: `markdown markdown-theme-${resolved.theme.name} markdown-editing`, "data-theme": resolved.scheme, style: rootStyle(resolved.theme, resolved.scheme), children: [_jsx("div", { ref: bar, className: "edit-bar", "data-leaving": folding ? '' : undefined, children: _jsx(Toolbar, { view: view, host: resolved, opened: opened.current, onAttach: () => {
|
|
507
|
+
// Opening the paperclip is the author acting on a refusal: the dialog says it once more.
|
|
508
|
+
if (attaching.state === 'failed') {
|
|
509
|
+
setRefusal(attaching.message);
|
|
510
|
+
setAttaching(NOT_ATTACHING);
|
|
511
|
+
}
|
|
512
|
+
else
|
|
513
|
+
setRefusal(null);
|
|
514
|
+
setAttachOpen(true);
|
|
515
|
+
}, attaching: attaching, attachButton: attachButton, end: _jsxs("div", { className: "edit-corner", "aria-busy": leaving && saves.state === 'saving' ? true : undefined, "data-blocked": blocked ? '' : undefined, children: [_jsx(SaveStatus, { saves: saves, blocked: blocked, host: resolved, onDecided: () => view?.focus() }), corner ?? _jsx(NoteActions, { text: () => (view ? output(view.state) : given) })] }, blocked ? `blocked-${refusals}` : 'corner') }) }), menu && view && (_jsx(ContextMenu, { x: menu.x, y: menu.y, label: resolved.strings.editor.menu, groups: menuGroups(view), onClose: (back) => {
|
|
516
|
+
setMenu(null);
|
|
517
|
+
if (back)
|
|
518
|
+
view.focus();
|
|
519
|
+
} })), attachOpen && (_jsx(AttachDialog, { refusal: refusal, onClose: () => setAttachOpen(false), onClosed: (inserted) => !inserted && attachButton.current?.focus(), onAttach: (files) => attachFiles.current(files), onEmbed: (name) => insertLine(`![[${name}]]`), onLink: (name, address) => insertLine(webImage(name, address)) })), lineBreak === null && _jsx("p", { className: "edit-unavailable", children: resolved.strings.editUnavailable }), _jsx("div", { ref: parent, className: "markdown-editor" })] }) }));
|
|
520
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the host said of a save, and the author's choices (#86): a modal dialog with the host's
|
|
3
|
+
* title, its explanation and one button per action. It never closes by itself, since its job is to
|
|
4
|
+
* have the author decide; Escape or its × close it and leave the save where it stood, and running
|
|
5
|
+
* an action closes it before the action runs.
|
|
6
|
+
*/
|
|
7
|
+
import type { SaveAction, SaveNotice } from './saves.js';
|
|
8
|
+
export declare function SaveNoticeDialog({ notice, onClose, onClosed, onAction, }: {
|
|
9
|
+
notice: SaveNotice;
|
|
10
|
+
/** Escape or the ×: the author did not choose. */
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
/** Once the dialog is closed, whether an action was chosen, so the focus goes where it belongs. */
|
|
13
|
+
onClosed: (chosen: boolean) => void;
|
|
14
|
+
onAction: (action: SaveAction) => void;
|
|
15
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* What the host said of a save, and the author's choices (#86): a modal dialog with the host's
|
|
4
|
+
* title, its explanation and one button per action. It never closes by itself, since its job is to
|
|
5
|
+
* have the author decide; Escape or its × close it and leave the save where it stood, and running
|
|
6
|
+
* an action closes it before the action runs.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useId, useRef } from 'react';
|
|
9
|
+
import { useHost } from '../host.js';
|
|
10
|
+
import { ICON_CLOSE } from '../mode-icons.js';
|
|
11
|
+
export function SaveNoticeDialog({ notice, onClose, onClosed, onAction, }) {
|
|
12
|
+
const host = useHost();
|
|
13
|
+
const dialog = useRef(null);
|
|
14
|
+
const chosen = useRef(false);
|
|
15
|
+
const closed = useRef(onClosed);
|
|
16
|
+
closed.current = onClosed;
|
|
17
|
+
const id = useId();
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const element = dialog.current;
|
|
20
|
+
if (!element)
|
|
21
|
+
return;
|
|
22
|
+
if (typeof element.showModal === 'function')
|
|
23
|
+
element.showModal();
|
|
24
|
+
else
|
|
25
|
+
element.setAttribute('open', '');
|
|
26
|
+
element.querySelector('.edit-notice-action')?.focus();
|
|
27
|
+
return () => {
|
|
28
|
+
if (element.open && typeof element.close === 'function')
|
|
29
|
+
element.close();
|
|
30
|
+
// Only once the dialog is closed may the page behind it take the focus.
|
|
31
|
+
closed.current(chosen.current);
|
|
32
|
+
};
|
|
33
|
+
}, []);
|
|
34
|
+
return (_jsx("dialog", { ref: dialog, className: "edit-source edit-notice", "aria-modal": "true", "aria-labelledby": `${id}-title`, "aria-describedby": `${id}-message`, onCancel: (event) => {
|
|
35
|
+
event.preventDefault();
|
|
36
|
+
onClose();
|
|
37
|
+
}, children: _jsxs("div", { className: "edit-source-frame", children: [_jsxs("header", { className: "edit-source-header", children: [_jsx("h2", { id: `${id}-title`, className: "edit-source-title", children: notice.title }), _jsx("button", { type: "button", className: "edit-control edit-source-close", "aria-label": host.strings.close, title: host.strings.close, onClick: onClose, children: ICON_CLOSE })] }), _jsx("p", { id: `${id}-message`, className: "edit-notice-message", children: notice.message }), _jsx("div", { className: "edit-notice-actions", children: notice.actions.map((action, index) => (_jsx("button", { type: "button", className: "edit-attach-button edit-notice-action", onClick: () => {
|
|
38
|
+
chosen.current = true;
|
|
39
|
+
onAction(action);
|
|
40
|
+
}, children: action.label }, index))) })] }) }));
|
|
41
|
+
}
|