@memorysmithapp/markdown-renderer 0.3.1 → 0.4.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 +32 -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 +23 -2
- package/dist/NoteBody.js +73 -7
- package/dist/edit/AttachDialog.d.ts +21 -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 +43 -0
- package/dist/edit/NoteEditor.js +461 -0
- package/dist/edit/analyze.d.ts +150 -0
- package/dist/edit/analyze.js +268 -0
- package/dist/edit/blocks.d.ts +96 -0
- package/dist/edit/blocks.js +480 -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 +4 -0
- package/dist/edit/index.js +6 -0
- package/dist/edit/live.d.ts +28 -0
- package/dist/edit/live.js +675 -0
- package/dist/edit/saves.d.ts +18 -0
- package/dist/edit/saves.js +52 -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 +60 -3
- package/dist/host.js +8 -0
- package/dist/index.d.ts +4 -4
- 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 +127 -1
- package/dist/strings.js +126 -1
- package/dist/task-saves.d.ts +35 -0
- package/dist/task-saves.js +86 -0
- package/dist/theme.d.ts +49 -0
- package/dist/theme.js +3 -2
- package/package.json +14 -2
- package/structure.css +331 -0
- package/theme-default.css +1084 -0
|
@@ -0,0 +1,461 @@
|
|
|
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, 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
|
+
/**
|
|
28
|
+
* The line break a note is written with, which the editor writes back: `\n` or `\r\n`. `null`
|
|
29
|
+
* for a note that mixes them, or has a lone `\r`, which the editor cannot keep byte for byte.
|
|
30
|
+
*/
|
|
31
|
+
export function lineBreakOf(text) {
|
|
32
|
+
if (!text.includes('\r'))
|
|
33
|
+
return '\n';
|
|
34
|
+
const crlf = (text.match(/\r\n/g) ?? []).length;
|
|
35
|
+
const cr = (text.match(/\r/g) ?? []).length;
|
|
36
|
+
const lf = (text.match(/\n/g) ?? []).length;
|
|
37
|
+
return crlf === cr && crlf === lf ? '\r\n' : null;
|
|
38
|
+
}
|
|
39
|
+
/** The change a host's new text makes to the editor's: the middle that differs, prefix and suffix kept. */
|
|
40
|
+
function difference(current, next) {
|
|
41
|
+
if (current === next)
|
|
42
|
+
return null;
|
|
43
|
+
let start = 0;
|
|
44
|
+
while (start < current.length && start < next.length && current[start] === next[start])
|
|
45
|
+
start++;
|
|
46
|
+
let end = 0;
|
|
47
|
+
while (end < current.length - start && end < next.length - start && current[current.length - 1 - end] === next[next.length - 1 - end])
|
|
48
|
+
end++;
|
|
49
|
+
return { from: start, to: current.length - end, insert: next.slice(start, next.length - end) };
|
|
50
|
+
}
|
|
51
|
+
/** The note back to the text edit mode opened, as one change, the least that differs, which Undo reverses (#67). */
|
|
52
|
+
function revert(state, opened) {
|
|
53
|
+
const change = difference(state.doc.toString(), opened);
|
|
54
|
+
return change ? { changes: change, selection: { anchor: Math.min(change.from + change.insert.length, opened.length) } } : null;
|
|
55
|
+
}
|
|
56
|
+
/** A change that came from the host, which is not handed back to it. */
|
|
57
|
+
const fromHost = Annotation.define();
|
|
58
|
+
/** The names a host suggests after `[[`, completed with the closing brackets. */
|
|
59
|
+
function wikilinkCompletion(host) {
|
|
60
|
+
return async (context) => {
|
|
61
|
+
const typed = context.matchBefore(/\[\[[^\]|#\n]*$/);
|
|
62
|
+
const suggest = host().suggest;
|
|
63
|
+
if (!typed || !suggest)
|
|
64
|
+
return null;
|
|
65
|
+
const query = typed.text.slice(2);
|
|
66
|
+
const names = await suggest(query);
|
|
67
|
+
const closed = context.state.sliceDoc(context.pos, context.pos + 2) === ']]';
|
|
68
|
+
return {
|
|
69
|
+
from: typed.from + 2,
|
|
70
|
+
options: names.map((name) => ({ label: name, apply: closed ? name : `${name}]]` })),
|
|
71
|
+
validFor: /^[^\]|#\n]*$/,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const shortcuts = (view) => keymap.of([
|
|
76
|
+
{ key: 'Mod-b', run: command((state) => toggleMark(state, '**')) },
|
|
77
|
+
{ key: 'Mod-i', run: command((state) => toggleMark(state, '_')) },
|
|
78
|
+
{ key: 'Mod-Shift-x', run: command((state) => toggleMark(state, '~~')) },
|
|
79
|
+
{ key: 'Mod-Shift-h', run: command((state) => toggleMark(state, '==')) },
|
|
80
|
+
{ key: 'Mod-e', run: command((state) => toggleMark(state, '`')) },
|
|
81
|
+
{ key: 'Mod-k', run: command(insertLink) },
|
|
82
|
+
{ key: 'Mod-Shift-k', run: command(insertWikilink) },
|
|
83
|
+
{ key: 'Mod-Shift-7', run: command((state) => toggleList(state, 'ordered')) },
|
|
84
|
+
{ key: 'Mod-Shift-8', run: command((state) => toggleList(state, 'bullet')) },
|
|
85
|
+
{ key: 'Mod-Shift-9', run: command((state) => toggleList(state, 'task')) },
|
|
86
|
+
{ key: 'Mod-Shift-.', run: command(toggleQuote) },
|
|
87
|
+
{ key: 'Mod-/', run: command(toggleComment) },
|
|
88
|
+
...[0, 1, 2, 3, 4, 5, 6].map((level) => ({ key: `Mod-Alt-${level}`, run: command((state) => setHeading(state, level)) })),
|
|
89
|
+
{ key: 'Escape', run: () => (view()?.contentDOM.blur(), true) },
|
|
90
|
+
]);
|
|
91
|
+
/** The editing menu's keys: the context-menu key and Shift+F10 open it at the cursor (#81). */
|
|
92
|
+
const menuKeys = (open) => keymap.of([
|
|
93
|
+
{ key: 'Shift-F10', run: open },
|
|
94
|
+
{ key: 'ContextMenu', run: open },
|
|
95
|
+
]);
|
|
96
|
+
/**
|
|
97
|
+
* Inserts one of the toolbar's blocks; a code block then opens the choice of its language, its
|
|
98
|
+
* search focused, so the author names the language first (#84).
|
|
99
|
+
*/
|
|
100
|
+
function insertBlockCommand(name) {
|
|
101
|
+
const insert = command((state) => insertBlock(state, BLOCKS[name].text, BLOCKS[name].cursor));
|
|
102
|
+
if (name !== 'codeBlock')
|
|
103
|
+
return insert;
|
|
104
|
+
return (view) => {
|
|
105
|
+
if (!insert(view))
|
|
106
|
+
return false;
|
|
107
|
+
const line = view.state.doc.lineAt(view.state.selection.main.head);
|
|
108
|
+
if (line.number > 1)
|
|
109
|
+
chooseLanguageAt(view, view.state.doc.line(line.number - 1).from);
|
|
110
|
+
return true;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** How long the attach button says a file was not attached, in milliseconds. */
|
|
114
|
+
const ATTACH_FAILED_MS = 3000;
|
|
115
|
+
/** The toolbar of the editor: the marks, the blocks and the history, each a command. */
|
|
116
|
+
function Toolbar({ view, host, end, opened, onAttach, attaching, attachButton, }) {
|
|
117
|
+
const words = host.strings.editor;
|
|
118
|
+
const disabled = !view || view.state.readOnly;
|
|
119
|
+
const run = (action) => () => view && action(view);
|
|
120
|
+
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));
|
|
121
|
+
const block = (name) => button(name, insertBlockCommand(name));
|
|
122
|
+
const level = view ? headingLevel(view.state) : 0;
|
|
123
|
+
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-/'), _jsx("button", { ref: attachButton, type: "button", className: "edit-control", title: attaching === 'busy' ? words.attaching : attaching === 'failed' ? words.attachFailed : words.attach, "aria-label": words.attach, "aria-busy": attaching === 'busy' ? true : undefined, "data-attach": attaching, disabled: disabled, onMouseDown: (event) => event.preventDefault(), onClick: onAttach, children: attaching === 'busy' ? ICONS.saving : attaching === 'failed' ? ICONS.notSaved : ICONS.attach })] }), _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] }));
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Where the save of the author's last change stands, in the toolbar before the switch: an icon for
|
|
127
|
+
* each state, in a slot of one size, so the toolbar never moves as the author types; its word is
|
|
128
|
+
* its title. A failed save's icon is the button that tries again (#66). Whoever hears the page is
|
|
129
|
+
* told the whole of it, why the note stays in edit included.
|
|
130
|
+
*/
|
|
131
|
+
function SaveStatus({ state, blocked, retry, host }) {
|
|
132
|
+
const words = host.strings.editor;
|
|
133
|
+
const shown = state === 'saving' ? words.saving : state === 'saved' ? host.strings.saved : state === 'failed' ? host.strings.notSaved : '';
|
|
134
|
+
const said = state === 'failed' && blocked ? words.stayInEdit : shown;
|
|
135
|
+
const icon = state === 'saving' ? ICONS.saving : state === 'saved' ? ICONS.saved : state === 'failed' ? ICONS.notSaved : null;
|
|
136
|
+
const retryLabel = `${said}. ${words.retry}`;
|
|
137
|
+
return (_jsxs("div", { className: "edit-save", "data-save": state, title: state === 'failed' ? undefined : said || undefined, children: [state === 'failed' ? (_jsx("button", { type: "button", className: "edit-save-icon edit-save-retry", title: retryLabel, "aria-label": retryLabel, onClick: retry, children: icon })) : (_jsx("span", { className: "edit-save-icon", children: icon })), _jsx("span", { className: "visually-hidden", role: "status", "aria-live": "polite", children: said })] }));
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Edits a note on the page that draws it. Exported at `@memorysmithapp/markdown-renderer/edit`
|
|
141
|
+
* for a host that edits without the switch of `NoteBody`; `NoteBody` loads it on demand.
|
|
142
|
+
*/
|
|
143
|
+
export function NoteEditor({ markdown: given, onChange, editable = true, corner, autoFocus = false, leaving = false, onLeave, ...host }) {
|
|
144
|
+
const resolved = useMemo(() => resolveHost(host),
|
|
145
|
+
// Every member of the contract, so a host that passes the same functions keeps the same host.
|
|
146
|
+
HOST_KEYS.map((key) => host[key]));
|
|
147
|
+
const parent = useRef(null);
|
|
148
|
+
const [view, setView] = useState(null);
|
|
149
|
+
const [, redraw] = useReducer((count) => count + 1, 0);
|
|
150
|
+
const environment = useRef(new Compartment());
|
|
151
|
+
const access = useRef(new Compartment());
|
|
152
|
+
const saves = useEditSaves(onChange);
|
|
153
|
+
const latest = useRef({ hand: saves.hand, host: resolved, onLeave });
|
|
154
|
+
latest.current = { hand: saves.hand, host: resolved, onLeave };
|
|
155
|
+
/**
|
|
156
|
+
* The texts handed to the host that it has not given back yet. A host that saves before it
|
|
157
|
+
* passes the note again gives back a text the editor has since moved past; that echo is not a
|
|
158
|
+
* change, and is never applied over what the author wrote after it.
|
|
159
|
+
*/
|
|
160
|
+
const handed = useRef([]);
|
|
161
|
+
const bar = useRef(null);
|
|
162
|
+
const [blocked, setBlocked] = useState(false);
|
|
163
|
+
const [refusals, setRefusals] = useState(0);
|
|
164
|
+
const editorView = useRef(null);
|
|
165
|
+
const [attaching, setAttaching] = useState('idle');
|
|
166
|
+
/**
|
|
167
|
+
* Hands each file to the host's `attach`, one after the other, and embeds it as `![[name]]` on a
|
|
168
|
+
* line of its own where the cursor is once the host answered its name; a file the host could not
|
|
169
|
+
* keep writes nothing, and the button says so for a moment (#75).
|
|
170
|
+
*/
|
|
171
|
+
const attachFiles = useRef(() => undefined);
|
|
172
|
+
attachFiles.current = (files, at) => {
|
|
173
|
+
const attach = resolved.attach;
|
|
174
|
+
const target = editorView.current;
|
|
175
|
+
if (!attach || !target || target.state.readOnly || files.length === 0)
|
|
176
|
+
return;
|
|
177
|
+
if (at !== undefined)
|
|
178
|
+
target.dispatch({ selection: { anchor: at } });
|
|
179
|
+
setAttaching('busy');
|
|
180
|
+
void (async () => {
|
|
181
|
+
let failed = false;
|
|
182
|
+
for (const { file, name: wanted, description } of files) {
|
|
183
|
+
try {
|
|
184
|
+
const name = await attach(file, { name: wanted, description });
|
|
185
|
+
insertLine(`![[${name}]]`);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
failed = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (editorView.current)
|
|
192
|
+
setAttaching(failed ? 'failed' : 'idle');
|
|
193
|
+
})();
|
|
194
|
+
};
|
|
195
|
+
/** A line of its own at the cursor: an embed, or an image of the web (#78). */
|
|
196
|
+
const insertLine = (text) => {
|
|
197
|
+
const current = editorView.current;
|
|
198
|
+
if (current)
|
|
199
|
+
command((state) => insertBlock(state, text, text.length + 1))(current);
|
|
200
|
+
};
|
|
201
|
+
const attachButton = useRef(null);
|
|
202
|
+
const [attachOpen, setAttachOpen] = useState(false);
|
|
203
|
+
/** Where the editing menu is open, in the window's coordinates (#81). */
|
|
204
|
+
const [menu, setMenu] = useState(null);
|
|
205
|
+
const openMenu = useRef((x, y) => setMenu({ x, y }));
|
|
206
|
+
/** The menu's groups, from the same commands as the toolbar: Format with a selection, Insert, Edit. */
|
|
207
|
+
const menuGroups = (target) => {
|
|
208
|
+
const words = resolved.strings.editor;
|
|
209
|
+
const item = (name, action, shortcut) => ({
|
|
210
|
+
name,
|
|
211
|
+
label: words[name],
|
|
212
|
+
icon: ICONS[name],
|
|
213
|
+
shortcut,
|
|
214
|
+
run: () => action(target),
|
|
215
|
+
});
|
|
216
|
+
const block = (name) => item(name, insertBlockCommand(name));
|
|
217
|
+
const selection = target.state.selection.main;
|
|
218
|
+
const selected = target.state.sliceDoc(selection.from, selection.to);
|
|
219
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
|
|
220
|
+
const groups = [];
|
|
221
|
+
if (!selection.empty) {
|
|
222
|
+
groups.push({
|
|
223
|
+
label: words.menuFormat,
|
|
224
|
+
items: [
|
|
225
|
+
item('bold', command((state) => toggleMark(state, '**')), 'Mod-b'),
|
|
226
|
+
item('italic', command((state) => toggleMark(state, '_')), 'Mod-i'),
|
|
227
|
+
item('strikethrough', command((state) => toggleMark(state, '~~')), 'Mod-Shift-x'),
|
|
228
|
+
item('highlight', command((state) => toggleMark(state, '==')), 'Mod-Shift-h'),
|
|
229
|
+
item('code', command((state) => toggleMark(state, '`')), 'Mod-e'),
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
groups.push({
|
|
234
|
+
label: words.menuInsert,
|
|
235
|
+
items: [
|
|
236
|
+
item('link', command(insertLink), 'Mod-k'),
|
|
237
|
+
item('wikilink', command(insertWikilink), 'Mod-Shift-k'),
|
|
238
|
+
item('bulletList', command((state) => toggleList(state, 'bullet')), 'Mod-Shift-8'),
|
|
239
|
+
item('orderedList', command((state) => toggleList(state, 'ordered')), 'Mod-Shift-7'),
|
|
240
|
+
item('taskList', command((state) => toggleList(state, 'task')), 'Mod-Shift-9'),
|
|
241
|
+
item('quote', command(toggleQuote), 'Mod-Shift-.'),
|
|
242
|
+
block('callout'),
|
|
243
|
+
block('table'),
|
|
244
|
+
block('codeBlock'),
|
|
245
|
+
block('math'),
|
|
246
|
+
block('diagram'),
|
|
247
|
+
block('rule'),
|
|
248
|
+
item('footnote', command(insertFootnote)),
|
|
249
|
+
item('comment', command(toggleComment), 'Mod-/'),
|
|
250
|
+
{ name: 'attach', label: words.attach, icon: ICONS.attach, run: () => setAttachOpen(true) },
|
|
251
|
+
],
|
|
252
|
+
});
|
|
253
|
+
groups.push({
|
|
254
|
+
label: words.menuEdit,
|
|
255
|
+
items: [
|
|
256
|
+
{
|
|
257
|
+
name: 'cut',
|
|
258
|
+
label: words.cut,
|
|
259
|
+
icon: ICONS.cut,
|
|
260
|
+
shortcut: 'Mod-x',
|
|
261
|
+
disabled: selection.empty || !clipboard,
|
|
262
|
+
run: () => void clipboard?.writeText(selected).then(() => target.dispatch({ changes: { from: selection.from, to: selection.to }, userEvent: 'delete.cut' })),
|
|
263
|
+
},
|
|
264
|
+
{ name: 'copy', label: words.copy, icon: ICONS.copy, shortcut: 'Mod-c', disabled: selection.empty || !clipboard, run: () => void clipboard?.writeText(selected) },
|
|
265
|
+
{
|
|
266
|
+
name: 'paste',
|
|
267
|
+
label: words.paste,
|
|
268
|
+
icon: ICONS.paste,
|
|
269
|
+
shortcut: 'Mod-v',
|
|
270
|
+
disabled: !clipboard?.readText,
|
|
271
|
+
run: () => void clipboard?.readText().then((text) => target.dispatch({ ...target.state.replaceSelection(text), userEvent: 'input.paste', scrollIntoView: true }), () => undefined),
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
});
|
|
275
|
+
return groups;
|
|
276
|
+
};
|
|
277
|
+
/** A pasted or dropped file, kept under its own name and with no description. */
|
|
278
|
+
const asChosen = (files) => files.map((file) => ({ file, name: file.name, description: '' }));
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (attaching !== 'failed')
|
|
281
|
+
return;
|
|
282
|
+
const timer = setTimeout(() => setAttaching('idle'), ATTACH_FAILED_MS);
|
|
283
|
+
return () => clearTimeout(timer);
|
|
284
|
+
}, [attaching]);
|
|
285
|
+
// The note as edit mode opened it, as the editor holds its text: lines joined by `\n`.
|
|
286
|
+
const opened = useRef(given.replace(/\r\n?/g, '\n'));
|
|
287
|
+
const lineBreak = lineBreakOf(given);
|
|
288
|
+
const readOnly = !editable || lineBreak === null;
|
|
289
|
+
/**
|
|
290
|
+
* The editor splits its text on any line break, so a paste or a block written with `\n` is lines
|
|
291
|
+
* like any other, and writes the note back with the break it was written with: what a host
|
|
292
|
+
* receives is CRLF throughout when the note was.
|
|
293
|
+
*/
|
|
294
|
+
const written = useRef(lineBreak ?? '\n');
|
|
295
|
+
written.current = lineBreak ?? written.current;
|
|
296
|
+
const output = (state) => state.doc.sliceString(0, state.doc.length, written.current);
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
if (!parent.current)
|
|
299
|
+
return;
|
|
300
|
+
const created = new EditorView({
|
|
301
|
+
parent: parent.current,
|
|
302
|
+
state: EditorState.create({
|
|
303
|
+
doc: given,
|
|
304
|
+
extensions: [
|
|
305
|
+
access.current.of([EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]),
|
|
306
|
+
environment.current.of(editorEnvironment.of({ host: resolved })),
|
|
307
|
+
history(),
|
|
308
|
+
markdown(),
|
|
309
|
+
EditorView.lineWrapping,
|
|
310
|
+
EditorView.contentAttributes.of({ 'aria-multiline': 'true', spellcheck: 'true' }),
|
|
311
|
+
livePage(),
|
|
312
|
+
autocompletion({ override: [wikilinkCompletion(() => latest.current.host)], icons: false }),
|
|
313
|
+
shortcuts(() => created),
|
|
314
|
+
keymap.of([...markdownKeymap, ...historyKeymap, indentWithTab, ...defaultKeymap]),
|
|
315
|
+
// A file pasted or dropped is attached, where the host keeps files (#75).
|
|
316
|
+
menuKeys((target) => {
|
|
317
|
+
if (target.state.readOnly)
|
|
318
|
+
return false;
|
|
319
|
+
const at = target.coordsAtPos(target.state.selection.main.head);
|
|
320
|
+
const box = target.contentDOM.getBoundingClientRect();
|
|
321
|
+
openMenu.current(at?.left ?? box.left, at?.bottom ?? box.top);
|
|
322
|
+
return true;
|
|
323
|
+
}),
|
|
324
|
+
EditorView.domEventHandlers({
|
|
325
|
+
// The editing menu where the pointer is; Shift keeps the browser's, and so do the
|
|
326
|
+
// drawn blocks' own fields, which are the browser's to edit (#81).
|
|
327
|
+
contextmenu: (event, target) => {
|
|
328
|
+
if (event.shiftKey || target.state.readOnly)
|
|
329
|
+
return false;
|
|
330
|
+
if (event.target instanceof Element && event.target.closest('[data-ms-control]'))
|
|
331
|
+
return false;
|
|
332
|
+
event.preventDefault();
|
|
333
|
+
const at = target.posAtCoords({ x: event.clientX, y: event.clientY });
|
|
334
|
+
const selection = target.state.selection.main;
|
|
335
|
+
if (at !== null && (at < selection.from || at > selection.to))
|
|
336
|
+
target.dispatch({ selection: { anchor: at } });
|
|
337
|
+
openMenu.current(event.clientX, event.clientY);
|
|
338
|
+
return true;
|
|
339
|
+
},
|
|
340
|
+
paste: (event) => {
|
|
341
|
+
const files = [...(event.clipboardData?.files ?? [])];
|
|
342
|
+
if (files.length === 0 || !latest.current.host.attach)
|
|
343
|
+
return false;
|
|
344
|
+
event.preventDefault();
|
|
345
|
+
attachFiles.current(asChosen(files));
|
|
346
|
+
return true;
|
|
347
|
+
},
|
|
348
|
+
drop: (event, target) => {
|
|
349
|
+
const files = [...(event.dataTransfer?.files ?? [])];
|
|
350
|
+
if (files.length === 0 || !latest.current.host.attach)
|
|
351
|
+
return false;
|
|
352
|
+
event.preventDefault();
|
|
353
|
+
attachFiles.current(asChosen(files), target.posAtCoords({ x: event.clientX, y: event.clientY }) ?? undefined);
|
|
354
|
+
return true;
|
|
355
|
+
},
|
|
356
|
+
}),
|
|
357
|
+
EditorView.updateListener.of((update) => {
|
|
358
|
+
if (update.docChanged && !update.transactions.some((transaction) => transaction.annotation(fromHost))) {
|
|
359
|
+
const text = output(update.state);
|
|
360
|
+
handed.current.push(text);
|
|
361
|
+
if (handed.current.length > 100)
|
|
362
|
+
handed.current.shift();
|
|
363
|
+
latest.current.hand(text);
|
|
364
|
+
}
|
|
365
|
+
if (update.docChanged || update.selectionSet || update.focusChanged)
|
|
366
|
+
redraw();
|
|
367
|
+
}),
|
|
368
|
+
],
|
|
369
|
+
}),
|
|
370
|
+
});
|
|
371
|
+
editorView.current = created;
|
|
372
|
+
setView(created);
|
|
373
|
+
if (autoFocus) {
|
|
374
|
+
// The cursor starts where the body does, so the frontmatter stays drawn as the form.
|
|
375
|
+
created.dispatch({ selection: { anchor: created.state.field(analysisField).bodyFrom } });
|
|
376
|
+
created.focus();
|
|
377
|
+
}
|
|
378
|
+
return () => {
|
|
379
|
+
editorView.current = null;
|
|
380
|
+
created.destroy();
|
|
381
|
+
};
|
|
382
|
+
// The editor is made once; what changes later reaches it through the effects below.
|
|
383
|
+
}, []);
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
view?.dispatch({ effects: environment.current.reconfigure(editorEnvironment.of({ host: resolved })) });
|
|
386
|
+
}, [view, resolved]);
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
view?.dispatch({ effects: access.current.reconfigure([EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]) });
|
|
389
|
+
}, [view, readOnly]);
|
|
390
|
+
// A note the host changed — a tick saved, another device — is taken in as the change it is.
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
if (!view)
|
|
393
|
+
return;
|
|
394
|
+
const echo = handed.current.indexOf(given);
|
|
395
|
+
if (echo !== -1) {
|
|
396
|
+
handed.current.splice(0, echo + 1);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
handed.current = [];
|
|
400
|
+
const change = difference(output(view.state), given);
|
|
401
|
+
if (change)
|
|
402
|
+
view.dispatch({ changes: change, annotations: [fromHost.of(true)] });
|
|
403
|
+
}, [view, given]);
|
|
404
|
+
/**
|
|
405
|
+
* The toolbar grows out of the switch and shrinks back into it (#65): its clip starts and ends
|
|
406
|
+
* on the corner that holds the switch, measured from the toolbar's top right edge.
|
|
407
|
+
*/
|
|
408
|
+
useLayoutEffect(() => {
|
|
409
|
+
const toolbar = bar.current?.querySelector('.edit-toolbar');
|
|
410
|
+
const end = toolbar?.querySelector('.edit-corner');
|
|
411
|
+
if (!toolbar || !end)
|
|
412
|
+
return;
|
|
413
|
+
const box = toolbar.getBoundingClientRect();
|
|
414
|
+
const corner = end.getBoundingClientRect();
|
|
415
|
+
// With the toolbar's own padding around it, so the corner alone reads as a box.
|
|
416
|
+
const style = typeof getComputedStyle === 'function' ? getComputedStyle(toolbar) : null;
|
|
417
|
+
const left = parseFloat(style?.paddingLeft ?? '') || 0;
|
|
418
|
+
const bottom = (parseFloat(style?.paddingBottom ?? '') || 0) + (parseFloat(style?.borderBottomWidth ?? '') || 0);
|
|
419
|
+
toolbar.style.setProperty('--edit-corner-width', `${Math.max(0, box.right - corner.left + left)}px`);
|
|
420
|
+
toolbar.style.setProperty('--edit-corner-height', `${Math.max(0, corner.bottom - box.top + bottom)}px`);
|
|
421
|
+
});
|
|
422
|
+
// Going back to reading waits for the save in flight; a failed one keeps the note here.
|
|
423
|
+
const folding = leaving && saves.state !== 'saving' && saves.state !== 'failed';
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
if (saves.state !== 'failed')
|
|
426
|
+
setBlocked(false);
|
|
427
|
+
if (!leaving || saves.state === 'saving')
|
|
428
|
+
return;
|
|
429
|
+
if (saves.state === 'failed') {
|
|
430
|
+
setBlocked(true);
|
|
431
|
+
setRefusals((count) => count + 1);
|
|
432
|
+
latest.current.onLeave?.(false);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const toolbar = bar.current?.querySelector('.edit-toolbar');
|
|
436
|
+
const animation = toolbar && typeof getComputedStyle === 'function' ? getComputedStyle(toolbar).animationName : '';
|
|
437
|
+
let done = false;
|
|
438
|
+
const leave = () => {
|
|
439
|
+
if (done)
|
|
440
|
+
return;
|
|
441
|
+
done = true;
|
|
442
|
+
latest.current.onLeave?.(true);
|
|
443
|
+
};
|
|
444
|
+
// A theme that does not fold the toolbar, or a reader who asked for less motion, goes at once.
|
|
445
|
+
if (!toolbar || !animation || animation === 'none') {
|
|
446
|
+
leave();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
toolbar.addEventListener('animationend', leave);
|
|
450
|
+
const timer = setTimeout(leave, 600);
|
|
451
|
+
return () => {
|
|
452
|
+
toolbar.removeEventListener('animationend', leave);
|
|
453
|
+
clearTimeout(timer);
|
|
454
|
+
};
|
|
455
|
+
}, [leaving, saves.state]);
|
|
456
|
+
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: () => setAttachOpen(true), 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, { state: saves.state, blocked: blocked, retry: saves.retry, host: resolved }), 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) => {
|
|
457
|
+
setMenu(null);
|
|
458
|
+
if (back)
|
|
459
|
+
view.focus();
|
|
460
|
+
} })), attachOpen && (_jsx(AttachDialog, { 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" })] }) }));
|
|
461
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where each construct of a note sits in its bytes, for the editor to draw (ADR-006).
|
|
3
|
+
*
|
|
4
|
+
* The editor's model is the note's text, and the page is drawn over it: every construct the
|
|
5
|
+
* reading surface draws is found here as ranges of that text — what it covers, which characters
|
|
6
|
+
* are its notation and which are its content — so the editor can hide the notation, style the
|
|
7
|
+
* content, or draw a block in place, and never rewrite a byte it did not touch.
|
|
8
|
+
*
|
|
9
|
+
* The text is read by the reading surface's own parser (`remark-parse` with GFM and math, at the
|
|
10
|
+
* options of `readingPlugins`), and the notation that parser leaves as text — callouts, wikilinks,
|
|
11
|
+
* marked text, comments, block identifiers — by the same patterns the reading surface uses. No
|
|
12
|
+
* React and no DOM: the analysis is a pure function of the text, and tested as one.
|
|
13
|
+
*/
|
|
14
|
+
/** A range of the text: `from` included, `to` excluded. */
|
|
15
|
+
export interface Span {
|
|
16
|
+
readonly from: number;
|
|
17
|
+
readonly to: number;
|
|
18
|
+
}
|
|
19
|
+
export type InlineMark = 'em' | 'strong' | 'strike' | 'highlight' | 'code';
|
|
20
|
+
/** A construct of the note, found in its text. */
|
|
21
|
+
export type Construct = {
|
|
22
|
+
readonly kind: 'frontmatter';
|
|
23
|
+
readonly from: number;
|
|
24
|
+
readonly to: number;
|
|
25
|
+
} | {
|
|
26
|
+
readonly kind: 'heading';
|
|
27
|
+
readonly from: number;
|
|
28
|
+
readonly to: number;
|
|
29
|
+
readonly level: number;
|
|
30
|
+
readonly marker: Span;
|
|
31
|
+
} | {
|
|
32
|
+
readonly kind: 'mark';
|
|
33
|
+
readonly mark: InlineMark;
|
|
34
|
+
readonly from: number;
|
|
35
|
+
readonly to: number;
|
|
36
|
+
readonly open: Span;
|
|
37
|
+
readonly close: Span;
|
|
38
|
+
} | {
|
|
39
|
+
readonly kind: 'link';
|
|
40
|
+
readonly from: number;
|
|
41
|
+
readonly to: number;
|
|
42
|
+
/** The label; `null` for an address written bare, which has no notation to hide. */
|
|
43
|
+
readonly label: Span | null;
|
|
44
|
+
readonly url: string;
|
|
45
|
+
} | {
|
|
46
|
+
readonly kind: 'wikilink';
|
|
47
|
+
readonly from: number;
|
|
48
|
+
readonly to: number;
|
|
49
|
+
readonly target: string;
|
|
50
|
+
/** What the page shows: the alias, or the target. */
|
|
51
|
+
readonly label: Span;
|
|
52
|
+
readonly embed: boolean;
|
|
53
|
+
} | {
|
|
54
|
+
readonly kind: 'image';
|
|
55
|
+
readonly from: number;
|
|
56
|
+
readonly to: number;
|
|
57
|
+
} | {
|
|
58
|
+
readonly kind: 'inlineMath';
|
|
59
|
+
readonly from: number;
|
|
60
|
+
readonly to: number;
|
|
61
|
+
readonly tex: string;
|
|
62
|
+
} | {
|
|
63
|
+
readonly kind: 'mathBlock';
|
|
64
|
+
readonly from: number;
|
|
65
|
+
readonly to: number;
|
|
66
|
+
readonly tex: string;
|
|
67
|
+
} | {
|
|
68
|
+
readonly kind: 'code';
|
|
69
|
+
readonly from: number;
|
|
70
|
+
readonly to: number;
|
|
71
|
+
readonly language: string | null;
|
|
72
|
+
/** The fence lines; `null` for an indented block. */
|
|
73
|
+
readonly open: Span | null;
|
|
74
|
+
readonly close: Span | null;
|
|
75
|
+
readonly code: string;
|
|
76
|
+
} | {
|
|
77
|
+
readonly kind: 'callout';
|
|
78
|
+
readonly from: number;
|
|
79
|
+
readonly to: number;
|
|
80
|
+
readonly type: string;
|
|
81
|
+
readonly fold: '+' | '-' | null;
|
|
82
|
+
/** `[!type]` and its fold marker, in the first line. */
|
|
83
|
+
readonly marker: Span;
|
|
84
|
+
} | {
|
|
85
|
+
readonly kind: 'quoteMarker';
|
|
86
|
+
readonly from: number;
|
|
87
|
+
readonly to: number;
|
|
88
|
+
} | {
|
|
89
|
+
readonly kind: 'table';
|
|
90
|
+
readonly from: number;
|
|
91
|
+
readonly to: number;
|
|
92
|
+
} | {
|
|
93
|
+
readonly kind: 'listMarker';
|
|
94
|
+
readonly from: number;
|
|
95
|
+
readonly to: number;
|
|
96
|
+
readonly ordered: boolean;
|
|
97
|
+
/** The task box after the marker, `[ ]` or `[x]`. */
|
|
98
|
+
readonly task: (Span & {
|
|
99
|
+
readonly checked: boolean;
|
|
100
|
+
}) | null;
|
|
101
|
+
} | {
|
|
102
|
+
readonly kind: 'rule';
|
|
103
|
+
readonly from: number;
|
|
104
|
+
readonly to: number;
|
|
105
|
+
} | {
|
|
106
|
+
readonly kind: 'embed';
|
|
107
|
+
readonly from: number;
|
|
108
|
+
readonly to: number;
|
|
109
|
+
readonly target: string;
|
|
110
|
+
readonly anchor: string | null;
|
|
111
|
+
} | {
|
|
112
|
+
readonly kind: 'footnoteRef';
|
|
113
|
+
readonly from: number;
|
|
114
|
+
readonly to: number;
|
|
115
|
+
readonly label: string;
|
|
116
|
+
} | {
|
|
117
|
+
readonly kind: 'footnoteDef';
|
|
118
|
+
readonly from: number;
|
|
119
|
+
readonly to: number;
|
|
120
|
+
readonly label: string;
|
|
121
|
+
readonly marker: Span;
|
|
122
|
+
} | {
|
|
123
|
+
readonly kind: 'comment';
|
|
124
|
+
readonly from: number;
|
|
125
|
+
readonly to: number;
|
|
126
|
+
} | {
|
|
127
|
+
readonly kind: 'blockId';
|
|
128
|
+
readonly from: number;
|
|
129
|
+
readonly to: number;
|
|
130
|
+
readonly id: string;
|
|
131
|
+
} | {
|
|
132
|
+
readonly kind: 'html';
|
|
133
|
+
readonly from: number;
|
|
134
|
+
readonly to: number;
|
|
135
|
+
} | {
|
|
136
|
+
readonly kind: 'definition';
|
|
137
|
+
readonly from: number;
|
|
138
|
+
readonly to: number;
|
|
139
|
+
};
|
|
140
|
+
export type ConstructKind = Construct['kind'];
|
|
141
|
+
/** The constructs of a note, in the order of their `from`. */
|
|
142
|
+
export interface Analysis {
|
|
143
|
+
readonly constructs: readonly Construct[];
|
|
144
|
+
/** Where the body starts: after the frontmatter, or 0. */
|
|
145
|
+
readonly bodyFrom: number;
|
|
146
|
+
}
|
|
147
|
+
/** The frontmatter block, from its first `---` to the end of its closing line, or `null`. */
|
|
148
|
+
export declare function frontmatterSpan(text: string): Span | null;
|
|
149
|
+
/** The constructs of a note's text. Offsets are into `text` as given. */
|
|
150
|
+
export declare function analyze(text: string): Analysis;
|