@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.
Files changed (59) hide show
  1. package/README.md +50 -4
  2. package/dist/Markdown.d.ts +6 -2
  3. package/dist/Markdown.js +126 -17
  4. package/dist/NoteActions.d.ts +9 -0
  5. package/dist/NoteActions.js +68 -0
  6. package/dist/NoteBody.d.ts +32 -2
  7. package/dist/NoteBody.js +74 -7
  8. package/dist/edit/AttachDialog.d.ts +23 -0
  9. package/dist/edit/AttachDialog.js +126 -0
  10. package/dist/edit/ContextMenu.d.ts +29 -0
  11. package/dist/edit/ContextMenu.js +95 -0
  12. package/dist/edit/LanguageControl.d.ts +19 -0
  13. package/dist/edit/LanguageControl.js +124 -0
  14. package/dist/edit/NoteEditor.d.ts +44 -0
  15. package/dist/edit/NoteEditor.js +520 -0
  16. package/dist/edit/SaveNoticeDialog.d.ts +15 -0
  17. package/dist/edit/SaveNoticeDialog.js +41 -0
  18. package/dist/edit/analyze.d.ts +150 -0
  19. package/dist/edit/analyze.js +268 -0
  20. package/dist/edit/blocks.d.ts +97 -0
  21. package/dist/edit/blocks.js +481 -0
  22. package/dist/edit/commands.d.ts +78 -0
  23. package/dist/edit/commands.js +257 -0
  24. package/dist/edit/frontmatter.d.ts +45 -0
  25. package/dist/edit/frontmatter.js +142 -0
  26. package/dist/edit/index.d.ts +5 -0
  27. package/dist/edit/index.js +7 -0
  28. package/dist/edit/live.d.ts +28 -0
  29. package/dist/edit/live.js +675 -0
  30. package/dist/edit/saves.d.ts +48 -0
  31. package/dist/edit/saves.js +73 -0
  32. package/dist/edit/table.d.ts +58 -0
  33. package/dist/edit/table.js +185 -0
  34. package/dist/file-types.d.ts +30 -0
  35. package/dist/file-types.js +70 -0
  36. package/dist/highlight.d.ts +21 -7
  37. package/dist/highlight.js +56 -7
  38. package/dist/host.d.ts +82 -3
  39. package/dist/host.js +16 -0
  40. package/dist/index.d.ts +6 -4
  41. package/dist/index.js +1 -0
  42. package/dist/mode-icons.d.ts +6 -0
  43. package/dist/mode-icons.js +11 -0
  44. package/dist/prism-grammars.d.ts +18 -0
  45. package/dist/prism-grammars.js +305 -0
  46. package/dist/remark-callouts.d.ts +6 -0
  47. package/dist/remark-callouts.js +2 -2
  48. package/dist/remark-ring.d.ts +5 -0
  49. package/dist/remark-ring.js +25 -15
  50. package/dist/slots.js +17 -1
  51. package/dist/strings.d.ts +129 -1
  52. package/dist/strings.js +127 -1
  53. package/dist/task-saves.d.ts +35 -0
  54. package/dist/task-saves.js +86 -0
  55. package/dist/theme.d.ts +47 -0
  56. package/dist/theme.js +2 -2
  57. package/package.json +14 -2
  58. package/structure.css +355 -0
  59. package/theme-default.css +1099 -0
@@ -0,0 +1,481 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The blocks the editor draws in place of their text while the author is not editing it, each
4
+ * drawn by the reading surface's own components so that edit mode looks like the page (ADR-006):
5
+ * a run of notation through `Markdown`, a callout with its type and folding controls, a table
6
+ * whose cells are edited where they stand (#56), and the frontmatter as a form (#58).
7
+ *
8
+ * Every edit they make is a change to the note's bytes, dispatched to the editor; none of them
9
+ * keeps a copy of the note.
10
+ */
11
+ import { ChangeSet } from '@codemirror/state';
12
+ import { useEffect, useRef, useState } from 'react';
13
+ import { useHost } from '../host.js';
14
+ import { Markdown } from '../Markdown.js';
15
+ import { hideComments, resolveWikilinks } from '../markdown.js';
16
+ import { isTrue, readProperties } from '../properties.js';
17
+ import { Transclusion } from '../Transclusion.js';
18
+ import { splitEmbeds } from '../transclusion.js';
19
+ import { wikilinkAddress } from '../wikilink-address.js';
20
+ import { addEntry, readFrontmatter, removeEntry, renameKey, setList, setScalar, } from './frontmatter.js';
21
+ import { addColumn, addRow, alignColumn, columnCount, readTable, removeColumn, removeRow, setCell, } from './table.js';
22
+ /** Whether a block's edit is being applied: the block is drawn anew, and the field it held lets the focus go. */
23
+ let writing = false;
24
+ /**
25
+ * Applies changes to the note, as one step of its history.
26
+ *
27
+ * An edit draws its block again, and the field that held the focus is taken out of the page while
28
+ * the editor updates; the browser tells it it lost the focus, and it would commit what it holds a
29
+ * second time, inside that update. That second commit is the first one again, so it is dropped —
30
+ * and so is any commit that arrives while the editor is updating for another reason.
31
+ */
32
+ function write(view, changes, event = 'input.form') {
33
+ const empty = changes instanceof ChangeSet ? changes.empty : changes.length === 0;
34
+ if (empty || view.state.readOnly || writing)
35
+ return;
36
+ writing = true;
37
+ try {
38
+ view.dispatch({ changes: changes instanceof ChangeSet ? changes : [...changes], userEvent: event });
39
+ }
40
+ catch (error) {
41
+ if (!(error instanceof Error && /update is in progress/.test(error.message)))
42
+ throw error;
43
+ }
44
+ finally {
45
+ writing = false;
46
+ }
47
+ }
48
+ /** A run of notation drawn as the reading surface draws it: comments out, wikilinks resolved, embeds expanded. */
49
+ export function Snippet({ text }) {
50
+ const host = useHost();
51
+ const body = hideComments(text).text;
52
+ return (_jsx(_Fragment, { children: splitEmbeds(body, host.keeps).map((segment, index) => segment.kind === 'text' ? (_jsx(Markdown, { children: resolveWikilinks(segment.text, (name) => wikilinkAddress(host, name), host.keeps) }, index)) : (_jsx(Transclusion, { target: segment.target, anchor: segment.anchor }, index))) }));
53
+ }
54
+ /** A small button of the editor's own, outside the note's notation. */
55
+ function Control({ label, onClick, children, pressed }) {
56
+ return (_jsx("button", { type: "button", className: "edit-control", title: label, "aria-label": label, "aria-pressed": pressed, "data-ms-control": "", onMouseDown: (event) => event.preventDefault(), onClick: onClick, children: children }));
57
+ }
58
+ // ── Callouts ─────────────────────────────────────────────────────────────────
59
+ /** The types a callout may take from the toolbar: the families the default theme draws. */
60
+ export const CALLOUT_TYPES = [
61
+ 'note', 'info', 'todo', 'abstract', 'tip', 'success', 'question', 'warning', 'failure', 'danger', 'bug', 'example', 'quote',
62
+ ];
63
+ /**
64
+ * A callout drawn as the page draws it, with its type and its folding chosen in place: each
65
+ * choice rewrites the marker `[!type]±` of its first line and nothing else (#55).
66
+ */
67
+ export function CalloutBlock({ view, text, marker }) {
68
+ const { strings } = useHost();
69
+ const words = strings.editor;
70
+ const types = CALLOUT_TYPES.includes(marker.type) ? [...CALLOUT_TYPES] : [marker.type, ...CALLOUT_TYPES];
71
+ const rewrite = (type, fold) => write(view, [{ from: marker.from, to: marker.to, insert: `[!${type}]${fold ?? ''}` }]);
72
+ return (_jsxs("div", { className: "edit-block edit-callout", children: [_jsxs("div", { className: "edit-block-controls", "data-ms-control": "", children: [_jsx("select", { "aria-label": words.calloutType, title: words.calloutType, value: marker.type, onChange: (event) => rewrite(event.target.value, marker.fold), children: types.map((type) => (_jsx("option", { value: type, children: strings.callouts[type] ?? type }, type))) }), _jsxs("select", { "aria-label": words.fold, title: words.fold, value: marker.fold ?? '', onChange: (event) => rewrite(marker.type, (event.target.value || null)), children: [_jsx("option", { value: "", children: words.foldNone }), _jsx("option", { value: "+", children: words.foldOpen }), _jsx("option", { value: "-", children: words.foldClosed })] })] }), _jsx(Snippet, { text: text })] }));
73
+ }
74
+ // ── Tables ───────────────────────────────────────────────────────────────────
75
+ /** Where the focus goes back to when a table is drawn again after an edit of one of its cells. */
76
+ let pendingFocus = null;
77
+ /**
78
+ * The cell a table's controls act on, kept across the table being drawn again: adding a column
79
+ * redraws it, and the alignment chosen next is for the cell the author was in, not the first (#69).
80
+ */
81
+ const chosenCells = new WeakMap();
82
+ /**
83
+ * A table whose cells are edited where they stand. A cell commits its text when it loses the focus,
84
+ * or on Enter (to the cell below) and Tab (to the next one); each commit rewrites that cell's
85
+ * content only. The controls add and remove rows and columns and align the column of the cell last
86
+ * focused.
87
+ */
88
+ export function TableBlock({ view, from, to }) {
89
+ const { strings } = useHost();
90
+ const words = strings.editor;
91
+ const source = view.state.doc.sliceString(0, view.state.doc.length, '\n');
92
+ const table = readTable(source, from, to);
93
+ const width = columnCount(table);
94
+ const [focus, setFocused] = useState(() => {
95
+ const chosen = chosenCells.get(view);
96
+ return chosen && chosen.from === table.from && chosen.row < table.rows.length && chosen.column < width
97
+ ? { row: chosen.row, column: chosen.column }
98
+ : { row: table.rows.length > 2 ? 2 : 0, column: 0 };
99
+ });
100
+ const setFocus = (cell) => {
101
+ chosenCells.set(view, { from: table.from, ...cell });
102
+ setFocused(cell);
103
+ };
104
+ // The cell being edited shows its source; every other one is drawn as the page draws it.
105
+ const [editing, setEditing] = useState(null);
106
+ const root = useRef(null);
107
+ useEffect(() => {
108
+ if (pendingFocus && pendingFocus.from === table.from) {
109
+ const { row, column } = pendingFocus;
110
+ pendingFocus = null;
111
+ setEditing({ row, column });
112
+ setFocus({ row, column });
113
+ }
114
+ });
115
+ // A cell that starts being edited takes the focus, with the cursor at its end.
116
+ useEffect(() => {
117
+ if (!editing)
118
+ return;
119
+ const cell = root.current?.querySelector(`[data-row="${editing.row}"][data-column="${editing.column}"][contenteditable]`);
120
+ if (!cell || document.activeElement === cell)
121
+ return;
122
+ cell.focus();
123
+ const selection = window.getSelection();
124
+ selection?.selectAllChildren(cell);
125
+ selection?.collapseToEnd();
126
+ }, [editing]);
127
+ const commit = (row, column, value, next, more = []) => {
128
+ const changes = [...setCell(table, row, column, value), ...more];
129
+ if (next)
130
+ pendingFocus = { from: table.from, ...next };
131
+ if (changes.length > 0)
132
+ write(view, changes);
133
+ else if (next) {
134
+ pendingFocus = null;
135
+ setEditing(next);
136
+ setFocus(next);
137
+ }
138
+ else
139
+ setEditing(null);
140
+ };
141
+ const onKey = (event, row, column) => {
142
+ const value = event.currentTarget.textContent ?? '';
143
+ if (event.key === 'Enter') {
144
+ event.preventDefault();
145
+ const below = row === 0 ? 2 : row + 1;
146
+ commit(row, column, value, below < table.rows.length ? { row: below, column } : undefined);
147
+ if (below >= table.rows.length)
148
+ event.currentTarget.blur();
149
+ }
150
+ else if (event.key === 'Tab') {
151
+ event.preventDefault();
152
+ const step = event.shiftKey ? -1 : 1;
153
+ let nextColumn = column + step;
154
+ let nextRow = row;
155
+ if (nextColumn >= width) {
156
+ nextColumn = 0;
157
+ nextRow = row === 0 ? 2 : row + 1;
158
+ }
159
+ else if (nextColumn < 0) {
160
+ nextColumn = width - 1;
161
+ nextRow = row === 2 ? 0 : row - 1;
162
+ }
163
+ if (nextRow >= table.rows.length) {
164
+ // Tab past the last cell adds a row and goes on in it, as a table does in a document.
165
+ commit(row, column, value, { row: nextRow, column: 0 }, addRow(table, row));
166
+ }
167
+ else {
168
+ commit(row, column, value, nextRow >= 0 && nextRow !== 1 ? { row: nextRow, column: nextColumn } : undefined);
169
+ }
170
+ }
171
+ else if (event.key === 'Escape') {
172
+ event.preventDefault();
173
+ event.currentTarget.textContent = table.rows[row]?.cells[column]?.text ?? '';
174
+ event.currentTarget.blur();
175
+ view.focus();
176
+ }
177
+ };
178
+ const cell = (row, column) => {
179
+ const text = table.rows[row]?.cells[column]?.text ?? '';
180
+ const Tag = row === 0 ? 'th' : 'td';
181
+ const style = table.align[column] ? { textAlign: table.align[column] ?? undefined } : undefined;
182
+ if (editing?.row !== row || editing.column !== column || view.state.readOnly) {
183
+ return (_jsx(Tag, { style: style, children: _jsx("span", { className: "edit-cell edit-cell-drawn", role: "button", tabIndex: 0, "aria-label": `${row === 0 ? 1 : row} · ${column + 1}`, "data-row": row, "data-column": column, "data-ms-control": "", onMouseDown: (event) => {
184
+ if (event.metaKey || event.ctrlKey || view.state.readOnly)
185
+ return;
186
+ event.preventDefault();
187
+ setFocus({ row, column });
188
+ setEditing({ row, column });
189
+ }, onFocus: () => setFocus({ row, column }), onKeyDown: (event) => {
190
+ if (event.key === 'Enter' || event.key === 'F2') {
191
+ event.preventDefault();
192
+ setEditing({ row, column });
193
+ }
194
+ }, children: _jsx(Snippet, { text: text.replace(/\\\|/g, '|') }) }) }, column));
195
+ }
196
+ return (_jsx(Tag, { style: style, children: _jsx("span", { className: "edit-cell", contentEditable: !view.state.readOnly, suppressContentEditableWarning: true, role: "textbox", "aria-label": `${row === 0 ? 1 : row} · ${column + 1}`, "data-row": row, "data-column": column, "data-ms-control": "", onFocus: () => setFocus({ row, column }), onBlur: (event) => {
197
+ commit(row, column, event.currentTarget.textContent ?? '');
198
+ setEditing((current) => (current?.row === row && current.column === column ? null : current));
199
+ }, onKeyDown: (event) => onKey(event, row, column), onPaste: (event) => {
200
+ event.preventDefault();
201
+ document.execCommand('insertText', false, event.clipboardData.getData('text/plain').replace(/\r?\n/g, ' '));
202
+ }, children: text }) }, column));
203
+ };
204
+ /**
205
+ * What a control of the table does, after the text of the cell being edited: the control keeps
206
+ * the focus where it is, so the cell has not committed, and its text would go with the table
207
+ * drawn again (#69). Both are one change, which one Undo takes back.
208
+ */
209
+ const act = (control) => {
210
+ const open = editing && root.current?.querySelector(`[data-row="${editing.row}"][data-column="${editing.column}"][contenteditable]`);
211
+ const typed = open && editing ? setCell(table, editing.row, editing.column, open.textContent ?? '') : [];
212
+ setEditing(null);
213
+ if (typed.length === 0)
214
+ return write(view, control(table));
215
+ const first = view.state.changes([...typed]);
216
+ const next = view.state.update({ changes: first }).state;
217
+ const fresh = readTable(next.doc.sliceString(0, next.doc.length, '\n'), first.mapPos(table.from), first.mapPos(table.to));
218
+ write(view, first.compose(next.changes([...control(fresh)])));
219
+ };
220
+ const align = (alignment) => act((current) => alignColumn(current, focus.column, alignment));
221
+ const bodyRows = table.rows.slice(2);
222
+ return (_jsxs("div", { className: "edit-block edit-table", children: [_jsxs("div", { className: "edit-block-controls", "data-ms-control": "", children: [_jsx(Control, { label: words.addRow, onClick: () => act((current) => addRow(current, focus.row)), children: ICONS.rowAdd }), _jsx(Control, { label: words.removeRow, onClick: () => act((current) => removeRow(current, focus.row)), children: ICONS.rowRemove }), _jsx(Control, { label: words.addColumn, onClick: () => act((current) => addColumn(current, focus.column)), children: ICONS.columnAdd }), _jsx(Control, { label: words.removeColumn, onClick: () => act((current) => removeColumn(current, focus.column)), children: ICONS.columnRemove }), _jsx(Control, { label: words.alignLeft, pressed: table.align[focus.column] === 'left', onClick: () => align(table.align[focus.column] === 'left' ? null : 'left'), children: ICONS.alignLeft }), _jsx(Control, { label: words.alignCenter, pressed: table.align[focus.column] === 'center', onClick: () => align(table.align[focus.column] === 'center' ? null : 'center'), children: ICONS.alignCenter }), _jsx(Control, { label: words.alignRight, pressed: table.align[focus.column] === 'right', onClick: () => align(table.align[focus.column] === 'right' ? null : 'right'), children: ICONS.alignRight }), _jsx(Control, { label: words.source, onClick: () => {
223
+ act(() => []);
224
+ reveal(view, table.from);
225
+ }, children: ICONS.source })] }), _jsx("div", { className: "markdown", children: _jsxs("table", { ref: root, children: [_jsx("thead", { children: _jsx("tr", { children: Array.from({ length: width }, (_, column) => cell(0, column)) }) }), bodyRows.length > 0 && (_jsx("tbody", { children: bodyRows.map((_, index) => (_jsx("tr", { children: Array.from({ length: width }, (__, column) => cell(index + 2, column)) }, index))) }))] }) })] }));
226
+ }
227
+ /** Shows a block as its text: the cursor goes into it, and the editor draws its source. */
228
+ export function reveal(view, at) {
229
+ view.dispatch({ selection: { anchor: at }, scrollIntoView: true });
230
+ view.focus();
231
+ }
232
+ // ── Frontmatter ──────────────────────────────────────────────────────────────
233
+ /** A text field that commits on blur and on Enter, and goes back to what was written on Escape. */
234
+ function Field({ value, label, onCommit, className, placeholder, multiline = false, type = 'text', size, }) {
235
+ const [draft, setDraft] = useState(value);
236
+ useEffect(() => setDraft(value), [value]);
237
+ const common = {
238
+ className: `edit-field ${className ?? ''}`.trim(),
239
+ 'aria-label': label,
240
+ title: label,
241
+ placeholder,
242
+ value: draft,
243
+ 'data-ms-control': '',
244
+ onBlur: () => draft !== value && onCommit(draft),
245
+ };
246
+ if (multiline) {
247
+ return (_jsx("textarea", { ...common, rows: Math.min(6, Math.max(2, Math.ceil(draft.length / 60))), onChange: (event) => setDraft(event.target.value), onKeyDown: (event) => {
248
+ if (event.key === 'Escape')
249
+ setDraft(value);
250
+ if (event.key === 'Enter' && !event.shiftKey) {
251
+ event.preventDefault();
252
+ event.target.blur();
253
+ }
254
+ } }));
255
+ }
256
+ return (_jsx("input", { ...common, type: type, size: size, onChange: (event) => {
257
+ setDraft(event.target.value);
258
+ if (type === 'date' && event.target.value !== value && event.target.value !== '')
259
+ onCommit(event.target.value);
260
+ }, onKeyDown: (event) => {
261
+ if (event.key === 'Escape')
262
+ setDraft(value);
263
+ if (event.key === 'Enter')
264
+ event.target.blur();
265
+ } }));
266
+ }
267
+ /** A list value as chips, each removable, with a field that adds one on Enter. */
268
+ /**
269
+ * A list's items as chips and the field that adds the next. `typing` says the author is still in
270
+ * the field — an item added with Enter or a comma, the last taken back with Backspace — so the
271
+ * field keeps the focus once the form is drawn again; an item committed by leaving it does not.
272
+ */
273
+ function ListField({ items, label, onCommit }) {
274
+ const { strings } = useHost();
275
+ const [draft, setDraft] = useState('');
276
+ const field = useRef(null);
277
+ const add = (typing) => {
278
+ const item = draft.trim();
279
+ if (item.length === 0)
280
+ return;
281
+ setDraft('');
282
+ onCommit([...items, item], typing);
283
+ };
284
+ return (_jsxs("span", { className: "prop-tags edit-list", children: [items.map((item, index) => (_jsxs("span", { className: "prop-tag", children: [item, _jsx("button", { type: "button", className: "edit-chip-remove", "data-ms-control": "", "aria-label": `${strings.editor.removeItem} ${item}`, title: strings.editor.removeItem, onMouseDown: (event) => event.preventDefault(), onClick: () => onCommit(items.filter((_, at) => at !== index), false), children: "\u00D7" })] }, `${item}-${index}`))), _jsx("input", { ref: field, className: "edit-field edit-list-add", "aria-label": `${strings.editor.addItem}: ${label}`, placeholder: strings.editor.addItem, value: draft, "data-ms-control": "", onChange: (event) => setDraft(event.target.value), onBlur: () => add(false), onKeyDown: (event) => {
285
+ if (event.key === 'Enter' || event.key === ',') {
286
+ event.preventDefault();
287
+ add(true);
288
+ }
289
+ if (event.key === 'Backspace' && draft === '' && items.length > 0)
290
+ onCommit(items.slice(0, -1), true);
291
+ } }), _jsx(Control, { label: `${strings.editor.addItem} ${label}`, onClick: () => (draft.trim() === '' ? field.current?.focus() : add(true)), children: ICONS.add })] }));
292
+ }
293
+ /** The value of one entry, edited by the shape it was read in (§6.3). */
294
+ function ValueField({ view, front, entry, written }) {
295
+ const { strings } = useHost();
296
+ const label = `${strings.editor.propertyValue}: ${entry.key}`;
297
+ if (entry.shape === 'list') {
298
+ return (_jsx(ListField, { items: entry.values, label: entry.key, onCommit: (items, typing) => {
299
+ if (typing)
300
+ focusAfterDraw.set(view, entry.key);
301
+ write(view, setList(front, entry.key, items));
302
+ } }));
303
+ }
304
+ if (entry.shape === 'boolean') {
305
+ const value = entry.values[0] ?? 'false';
306
+ // Chosen as reading draws it, Yes or No, and written in the words the author used: `true`/`false` or `yes`/`no`.
307
+ const choose = (on) => {
308
+ if (on === isTrue(value))
309
+ return;
310
+ const yesNo = /^(yes|no)$/i.test(value);
311
+ const word = yesNo ? (on ? 'yes' : 'no') : on ? 'true' : 'false';
312
+ const cased = value === value.toUpperCase() ? word.toUpperCase() : value[0] === value[0]?.toUpperCase() ? word[0].toUpperCase() + word.slice(1) : word;
313
+ write(view, setScalar(front, entry.key, cased));
314
+ };
315
+ return (_jsxs("select", { className: "edit-boolean", value: isTrue(value) ? 'yes' : 'no', "aria-label": label, title: label, "data-ms-control": "", onChange: (event) => choose(event.target.value === 'yes'), children: [_jsx("option", { value: "yes", children: strings.yes }), _jsx("option", { value: "no", children: strings.no })] }));
316
+ }
317
+ if (entry.shape === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(entry.values[0] ?? '')) {
318
+ return _jsx(Field, { type: "date", value: entry.values[0] ?? '', label: label, onCommit: (value) => write(view, setScalar(front, entry.key, value)) });
319
+ }
320
+ return (_jsx(Field, { value: entry.values[0] ?? written, label: label, multiline: entry.shape === 'prose', onCommit: (value) => write(view, setScalar(front, entry.key, value)) }));
321
+ }
322
+ function emptyValue(type) {
323
+ if (type === 'boolean')
324
+ return 'false';
325
+ if (type === 'date') {
326
+ const today = new Date();
327
+ const two = (value) => String(value).padStart(2, '0');
328
+ return `${today.getFullYear()}-${two(today.getMonth() + 1)}-${two(today.getDate())}`;
329
+ }
330
+ return '';
331
+ }
332
+ /** The type a property's value has, as the form offers it. */
333
+ function typeOf(shape) {
334
+ return shape === 'list' ? 'list' : shape === 'date' ? 'date' : shape === 'boolean' ? 'boolean' : 'text';
335
+ }
336
+ /**
337
+ * A property given another type, its value carried over where the new type can hold it: a list's
338
+ * items joined as text, a text split at its commas as a list's items; a value the new type
339
+ * cannot hold becomes that type's empty one. Undo takes it back.
340
+ */
341
+ function retype(front, key, read, to) {
342
+ const from = typeOf(read.shape);
343
+ if (from === to)
344
+ return [];
345
+ const values = read.values.filter((value) => value.length > 0);
346
+ if (to === 'list') {
347
+ // A text splits at its commas, as a list joined as text was written.
348
+ const items = from === 'boolean' ? [] : from === 'text' ? values.flatMap((value) => value.split(',')).map((item) => item.trim()).filter(Boolean) : values;
349
+ return setList(front, key, items);
350
+ }
351
+ if (to === 'boolean')
352
+ return setScalar(front, key, 'false');
353
+ if (to === 'date')
354
+ return setScalar(front, key, emptyValue('date'));
355
+ return setScalar(front, key, from === 'list' ? values.join(', ') : '');
356
+ }
357
+ /**
358
+ * The choice of a property's type, drawn as the icon of its type before its key: a select laid
359
+ * over the icon, so it opens where the author looks and is reached by keyboard and named (#71).
360
+ */
361
+ function TypeChoice({ value, label, onChoose }) {
362
+ const words = useHost().strings.editor;
363
+ return (_jsxs(_Fragment, { children: [_jsxs("select", { className: "edit-type", "aria-label": label, title: label, value: value, "data-ms-control": "", onChange: (event) => onChoose(event.target.value), children: [_jsx("option", { value: "text", children: words.typeText }), _jsx("option", { value: "list", children: words.typeList }), _jsx("option", { value: "boolean", children: words.typeBoolean }), _jsx("option", { value: "date", children: words.typeDate })] }), _jsx("span", { className: "edit-type-caret", "aria-hidden": "true", children: ICONS.caret })] }));
364
+ }
365
+ /**
366
+ * The property whose value field takes the focus once the form is drawn again, per editor: one
367
+ * just made (#70), or a list the author is adding items to.
368
+ */
369
+ const focusAfterDraw = new WeakMap();
370
+ export function PropertiesBlock({ view, to }) {
371
+ const { strings } = useHost();
372
+ const words = strings.editor;
373
+ const source = view.state.doc.sliceString(0, view.state.doc.length, '\n');
374
+ const front = readFrontmatter(source);
375
+ const properties = readProperties(source.slice(0, to));
376
+ const [adding, setAdding] = useState('');
377
+ const [type, setType] = useState('text');
378
+ const box = useRef(null);
379
+ // A property just made, or a list being typed in: its value field takes the focus, a list's the field of its next item.
380
+ // The form is drawn into a widget the editor places after, so the focus waits for it to be there.
381
+ useEffect(() => {
382
+ const key = focusAfterDraw.get(view);
383
+ if (!key || !front?.entries.some((entry) => entry.key === key))
384
+ return;
385
+ const names = [`${words.propertyValue}: ${key}`, `${words.addItem}: ${key}`];
386
+ let tries = 0;
387
+ let timer;
388
+ const place = () => {
389
+ const field = [...(box.current?.querySelectorAll('[aria-label]') ?? [])].find((each) => names.includes(each.getAttribute('aria-label') ?? ''));
390
+ if (field?.isConnected) {
391
+ focusAfterDraw.delete(view);
392
+ field.focus();
393
+ }
394
+ else if (++tries < 10)
395
+ timer = setTimeout(place, 16);
396
+ };
397
+ place();
398
+ return () => clearTimeout(timer);
399
+ });
400
+ if (!front)
401
+ return null;
402
+ const nameEntry = front.entries.find((entry) => entry.key === 'name');
403
+ const name = nameEntry ? source.slice(nameEntry.valueSpan.from, nameEntry.valueSpan.to).replace(/^(["'])([\s\S]*)\1$/, '$2') : '';
404
+ const add = () => {
405
+ const key = adding.trim();
406
+ if (!/^[A-Za-z0-9_-]+$/.test(key))
407
+ return;
408
+ setAdding('');
409
+ focusAfterDraw.set(view, key);
410
+ // A list starts inline and empty, so its first item is written as `key: [first]`.
411
+ write(view, addEntry(front, key, type === 'list' ? [] : emptyValue(type)));
412
+ };
413
+ return (_jsxs("div", { className: "edit-block edit-properties", ref: box, children: [_jsx("div", { className: "edit-block-controls", "data-ms-control": "", children: _jsx(Control, { label: words.source, onClick: () => reveal(view, 4), children: ICONS.source }) }), _jsx(Field, { className: "note-title edit-title", value: name, label: words.title, placeholder: strings.unnamedNote, onCommit: (value) => write(view, value.trim() === '' && nameEntry ? removeEntry(front, 'name') : setScalar(front, 'name', value.trim())) }), _jsx("div", { className: "properties-box", children: _jsxs("div", { className: "metadata-container", children: [front.entries
414
+ .filter((entry) => entry.key !== 'name')
415
+ .map((entry) => {
416
+ const found = properties.entries.find((each) => each.key === entry.key) ?? { key: entry.key, shape: 'empty', values: [] };
417
+ // A list with no items is empty to the page, which draws nothing; to the form it is still a list.
418
+ const read = found.shape === 'empty' && entry.form !== 'scalar' ? { ...found, shape: 'list' } : found;
419
+ const kind = typeOf(read.shape);
420
+ // The property itself is handled at its key — its type, its removal, its name — and its value at the value.
421
+ return (_jsxs("div", { className: "metadata-property edit-property", "data-property-type": kind === 'boolean' ? 'checkbox' : kind, children: [_jsxs("span", { className: "metadata-property-key", children: [_jsx(TypeChoice, { value: kind, label: `${words.propertyType}: ${entry.key}`, onChoose: (to) => {
422
+ focusAfterDraw.set(view, entry.key);
423
+ write(view, retype(front, entry.key, read, to));
424
+ } }), _jsx(Control, { label: `${words.removeProperty}: ${entry.key}`, onClick: () => write(view, removeEntry(front, entry.key)), children: ICONS.remove }), _jsx(Field, { value: entry.key, label: `${words.propertyKey}: ${entry.key}`, className: "edit-key", size: Math.max(4, entry.key.length + 1), onCommit: (next) => write(view, renameKey(front, entry.key, next.trim())) })] }), _jsx("span", { className: "metadata-property-value", children: _jsx(ValueField, { view: view, front: front, entry: read, written: source.slice(entry.valueSpan.from, entry.valueSpan.to) }) })] }, entry.key));
425
+ }), _jsxs("div", { className: "metadata-property edit-property edit-property-new", "data-property-type": type === 'boolean' ? 'checkbox' : type, children: [_jsxs("span", { className: "metadata-property-key", children: [_jsx(TypeChoice, { value: type, label: `${words.propertyType}: ${words.addProperty}`, onChoose: setType }), _jsx("span", { className: "edit-control edit-key-spacer", "aria-hidden": "true", children: ICONS.remove }), _jsx("input", { className: "edit-field edit-key", size: Math.max(words.addProperty.length, adding.length + 1), "aria-label": words.addProperty, placeholder: words.addProperty, value: adding, "data-ms-control": "", onChange: (event) => setAdding(event.target.value.replace(/[^A-Za-z0-9_-]/g, '')), onKeyDown: (event) => event.key === 'Enter' && add() })] }), _jsx("span", { className: "metadata-property-value", children: _jsx(Control, { label: words.addProperty, onClick: add, children: ICONS.add }) })] })] }) })] }));
426
+ }
427
+ /** The control a note with no frontmatter shows above its body, which gives it one. */
428
+ export function AddProperties({ view }) {
429
+ const { strings } = useHost();
430
+ return (_jsx("div", { className: "edit-add-properties", children: _jsxs("button", { type: "button", className: "edit-control edit-control-labelled", "data-ms-control": "", onMouseDown: (event) => event.preventDefault(), onClick: () => write(view, [{ from: 0, to: 0, insert: '---\n---\n' }]), children: [ICONS.add, _jsx("span", { children: strings.editor.addProperties })] }) }));
431
+ }
432
+ // ── Icons ────────────────────────────────────────────────────────────────────
433
+ /** Lucide's outlines (ISC), drawn in the current colour at the size of the text. */
434
+ function icon(...paths) {
435
+ return (_jsx("svg", { className: "edit-icon", viewBox: "0 0 24 24", width: "1em", height: "1em", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: paths.map((d) => (_jsx("path", { d: d }, d))) }));
436
+ }
437
+ export const ICONS = {
438
+ add: icon('M5 12h14', 'M12 5v14'),
439
+ remove: icon('M18 6 6 18', 'm6 6 12 12'),
440
+ source: icon('m16 18 6-6-6-6', 'm8 6-6 6 6 6'),
441
+ rowAdd: icon('M3 12h18', 'M3 6h18', 'M12 15v6', 'M9 18h6'),
442
+ rowRemove: icon('M3 12h18', 'M3 6h18', 'M9 18h6'),
443
+ columnAdd: icon('M12 3v18', 'M6 3v18', 'M15 12h6', 'M18 9v6'),
444
+ columnRemove: icon('M12 3v18', 'M6 3v18', 'M15 12h6'),
445
+ alignLeft: icon('M21 6H3', 'M15 12H3', 'M17 18H3'),
446
+ alignCenter: icon('M21 6H3', 'M17 12H7', 'M19 18H5'),
447
+ alignRight: icon('M21 6H3', 'M21 12H9', 'M21 18H7'),
448
+ undo: icon('M9 14 4 9l5-5', 'M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11'),
449
+ redo: icon('m15 14 5-5-5-5', 'M20 9H9.5A5.5 5.5 0 0 0 4 14.5 5.5 5.5 0 0 0 9.5 20H13'),
450
+ bold: icon('M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8'),
451
+ italic: icon('M19 4h-9', 'M14 20H5', 'M15 4 9 20'),
452
+ strikethrough: icon('M16 4H9a3 3 0 0 0-2.83 4', 'M14 12a4 4 0 0 1 0 8H6', 'M4 12h16'),
453
+ highlight: icon('m9 11-6 6v3h9l3-3', 'm22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4'),
454
+ code: icon('m18 16 4-4-4-4', 'm6 8-4 4 4 4', 'm14.5 4-5 16'),
455
+ link: icon('M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71', 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'),
456
+ wikilink: icon('M15 3h6v6', 'M10 14 21 3', 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'),
457
+ bulletList: icon('M3 12h.01', 'M3 18h.01', 'M3 6h.01', 'M8 12h13', 'M8 18h13', 'M8 6h13'),
458
+ orderedList: icon('M10 12h11', 'M10 18h11', 'M10 6h11', 'M4 10h2', 'M4 6h1v4', 'M6 18H4c0-1 2-2 2-3s-1-1.5-2-1'),
459
+ taskList: icon('m3 17 2 2 4-4', 'm3 7 2 2 4-4', 'M13 6h8', 'M13 12h8', 'M13 18h8'),
460
+ quote: icon('M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z', 'M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z'),
461
+ callout: icon('M12 16v-4', 'M12 8h.01', 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z'),
462
+ table: icon('M12 3v18', 'M3 9h18', 'M3 15h18', 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z'),
463
+ codeBlock: icon('M10 9.5 8 12l2 2.5', 'm14 9.5 2 2.5-2 2.5', 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z'),
464
+ math: icon('M18 7V5a1 1 0 0 0-1-1H6.5a.5.5 0 0 0-.4.8l4.5 6a2 2 0 0 1 0 2.4l-4.5 6a.5.5 0 0 0 .4.8H17a1 1 0 0 0 1-1v-2'),
465
+ diagram: icon('M5 3h4v4H5z', 'M15 17h4v4h-4z', 'M7 7v4a2 2 0 0 0 2 2h6a2 2 0 0 1 2 2v2'),
466
+ rule: icon('M5 12h14'),
467
+ footnote: icon('M4 7V4h16v3', 'M9 20h6', 'M12 4v16', 'M19 13v6', 'M17 15l2-2'),
468
+ comment: icon('M7.9 20A9 9 0 1 0 4 16.1L2 22Z'),
469
+ caret: icon('m6 9 6 6 6-6'),
470
+ cut: icon('M6 3a3 3 0 1 0 0 6 3 3 0 0 0 0-6z', 'M6 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6z', 'M20 4 8.12 15.88', 'M14.47 14.48 20 20', 'M8.12 8.12 12 12'),
471
+ copy: icon('M10 8h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z', 'M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2'),
472
+ paste: icon('M9 2h6a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z', 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2'),
473
+ attach: icon('m16 6-8.414 8.586a2 2 0 0 0 2.829 2.828l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551'),
474
+ revert: icon('M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8', 'M3 3v5h5'),
475
+ saving: icon('M21 12a9 9 0 1 1-6.219-8.56'),
476
+ saved: icon('M20 6 9 17l-5-5'),
477
+ notSaved: icon('M12 2a10 10 0 1 0 0 20 10 10 0 1 0 0-20z', 'M12 8v4', 'M12 16h.01'),
478
+ alert: icon('m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3', 'M12 9v4', 'M12 17h.01'),
479
+ edit: icon('M21.17 6.81a1 1 0 0 0-3.99-3.99L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z', 'm15 5 4 4'),
480
+ view: icon('M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0', 'M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z'),
481
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * What the toolbar and the shortcuts of the editor do to a note (#52, #53, #57, #59).
3
+ *
4
+ * Each command is a change to the bytes around the selection and nothing else: a mark is the
5
+ * delimiters written on either side of what is selected, a heading the `#` at the start of its
6
+ * line, a list the marker of each line selected. The page the author sees follows from the text,
7
+ * because the editor draws the text.
8
+ */
9
+ import { type EditorState, type TransactionSpec } from '@codemirror/state';
10
+ import type { EditorView } from '@codemirror/view';
11
+ export type Command = (view: EditorView) => boolean;
12
+ /** A command from a function of the state, which answers `null` when it does not apply. */
13
+ export declare function command(spec: (state: EditorState) => TransactionSpec | null): Command;
14
+ /**
15
+ * Writes or takes away a pair of delimiters around each selection: `**` bold, `_` italic, `~~`
16
+ * struck, `==` marked, `` ` `` code. A selection that the delimiters already wrap, inside it or
17
+ * just outside it, loses them; an empty one gains the pair with the cursor between.
18
+ */
19
+ export declare function toggleMark(state: EditorState, delimiter: string): TransactionSpec;
20
+ /** Makes each selected line a heading of `level`, or a paragraph at 0; a quotation's markers stay. */
21
+ export declare function setHeading(state: EditorState, level: number): TransactionSpec;
22
+ /** The heading level of the line the cursor is on, or 0. */
23
+ export declare function headingLevel(state: EditorState): number;
24
+ export type ListKind = 'bullet' | 'ordered' | 'task';
25
+ /**
26
+ * Makes each selected line an item of a list of `kind`, renumbering an ordered one from 1; when
27
+ * every line already is one, they become paragraphs again. Blank lines among them are left alone;
28
+ * a blank line alone starts a list.
29
+ */
30
+ export declare function toggleList(state: EditorState, kind: ListKind): TransactionSpec;
31
+ /** Quotes each selected line, or unquotes them when every one is quoted. */
32
+ export declare function toggleQuote(state: EditorState): TransactionSpec;
33
+ /**
34
+ * Inserts a block on lines of its own: in place of the current line when it is blank, else after
35
+ * the block the cursor is in, with a blank line on either side. `cursor` is where in the block the
36
+ * cursor goes, from its start; past its end, the cursor goes to the line after it.
37
+ */
38
+ export declare function insertBlock(state: EditorState, block: string, cursor: number): TransactionSpec;
39
+ /** A link around the selection, with its address selected to be typed; `[](https://)` with none. */
40
+ export declare function insertLink(state: EditorState): TransactionSpec;
41
+ /** A wikilink around the selection; with none, `[[]]` with the cursor between, where names are suggested. */
42
+ export declare function insertWikilink(state: EditorState): TransactionSpec;
43
+ /** A comment around the selection, or an empty one with the cursor inside (§7.6). */
44
+ export declare function toggleComment(state: EditorState): TransactionSpec;
45
+ /**
46
+ * A footnote: its reference at the cursor, numbered after the highest number the note uses, and
47
+ * its definition at the end of the note, where the cursor goes to write it (§7.12).
48
+ */
49
+ export declare function insertFootnote(state: EditorState): TransactionSpec;
50
+ /** Flips the task box whose `[` is at `at`, as `toggleTaskAt` does: `[x]` lower case on the way in. */
51
+ export declare function toggleTaskBox(state: EditorState, at: number): TransactionSpec | null;
52
+ /** The blocks the toolbar inserts, each with where the cursor goes in it. */
53
+ export declare const BLOCKS: {
54
+ readonly callout: {
55
+ readonly text: "> [!note]\n> ";
56
+ readonly cursor: 12;
57
+ };
58
+ readonly table: {
59
+ readonly text: "| | |\n| --- | --- |\n| | |";
60
+ readonly cursor: 2;
61
+ };
62
+ readonly codeBlock: {
63
+ readonly text: "```\n\n```";
64
+ readonly cursor: 4;
65
+ };
66
+ readonly math: {
67
+ readonly text: "$$\n\n$$";
68
+ readonly cursor: 3;
69
+ };
70
+ readonly diagram: {
71
+ readonly text: "```mermaid\nflowchart LR\n A --> B\n```";
72
+ readonly cursor: 23;
73
+ };
74
+ readonly rule: {
75
+ readonly text: "---";
76
+ readonly cursor: 4;
77
+ };
78
+ };