@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,126 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The attach dialog (#78): the three ways a file or an image comes into a note. From the computer —
4
+ * one or more files, each kept by the host's `attach` under the name and with the description the
5
+ * author gives; from the notebook — a file it already keeps, listed by the host's `files`, found by
6
+ * name or description; from the web — an image at a public address, written as `![name](address)`.
7
+ */
8
+ import { useEffect, useId, useRef, useState } from 'react';
9
+ import { fileAddress, fileKind, formatBytes, rendersAs } from '../file-types.js';
10
+ import { useHost } from '../host.js';
11
+ import { ICONS } from './blocks.js';
12
+ /** An address of the web, the only kind an image from the web may have. */
13
+ const WEB = /^https?:\/\/[^\s]+$/i;
14
+ /** The Markdown of an image at a web address, its name the alternative text (§3.14). */
15
+ export function webImage(name, address) {
16
+ const alt = name.replace(/([\\[\]])/g, '\\$1');
17
+ const target = /[\s()<>]/.test(address) ? `<${address.replace(/[<>]/g, encodeURIComponent)}>` : address;
18
+ return `![${alt}](${target})`;
19
+ }
20
+ export function AttachDialog({ refusal = null, onClose, onClosed, onAttach, onEmbed, onLink, }) {
21
+ const host = useHost();
22
+ const words = host.strings.editor;
23
+ const ways = [...(host.attach ? ['computer'] : []), ...(host.files ? ['notebook'] : []), 'web'];
24
+ const [way, setWay] = useState(ways[0]);
25
+ const dialog = useRef(null);
26
+ const inserted = useRef(false);
27
+ const closed = useRef(onClosed);
28
+ closed.current = onClosed;
29
+ const id = useId();
30
+ useEffect(() => {
31
+ const element = dialog.current;
32
+ if (!element)
33
+ return;
34
+ if (typeof element.showModal === 'function')
35
+ element.showModal();
36
+ else
37
+ element.setAttribute('open', '');
38
+ element.querySelector('[role="tab"][aria-selected="true"]')?.focus();
39
+ return () => {
40
+ if (element.open && typeof element.close === 'function')
41
+ element.close();
42
+ closed.current(inserted.current);
43
+ };
44
+ }, []);
45
+ const done = (action) => {
46
+ inserted.current = true;
47
+ onClose();
48
+ action();
49
+ };
50
+ const label = { computer: words.fromComputer, notebook: words.fromNotebook, web: words.fromWeb };
51
+ return (_jsx("dialog", { ref: dialog, className: "edit-source edit-attach", "aria-modal": "true", "aria-labelledby": `${id}-title`, onCancel: (event) => {
52
+ event.preventDefault();
53
+ onClose();
54
+ }, onClick: (event) => event.target === dialog.current && onClose(), 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: words.attachTitle }), _jsx("button", { type: "button", className: "edit-control edit-source-close", "aria-label": host.strings.close, title: host.strings.close, onClick: onClose, children: ICONS.remove })] }), refusal && _jsx("p", { className: "edit-attach-error edit-attach-refusal", children: refusal }), _jsx("div", { className: "edit-attach-ways", role: "tablist", "aria-label": words.attachTitle, children: ways.map((each) => (_jsx("button", { type: "button", role: "tab", id: `${id}-${each}`, "aria-selected": each === way, "aria-controls": `${id}-panel`, tabIndex: each === way ? 0 : -1, className: "edit-attach-way", onClick: () => setWay(each), onKeyDown: (event) => {
55
+ const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
56
+ if (step === 0)
57
+ return;
58
+ event.preventDefault();
59
+ const next = ways[(ways.indexOf(way) + step + ways.length) % ways.length];
60
+ setWay(next);
61
+ event.currentTarget.parentElement?.querySelector(`[id="${id}-${next}"]`)?.focus();
62
+ }, children: label[each] }, each))) }), _jsxs("div", { className: "edit-attach-panel", role: "tabpanel", id: `${id}-panel`, "aria-labelledby": `${id}-${way}`, children: [way === 'computer' && _jsx(FromComputer, { onAttach: (files) => done(() => onAttach(files)) }), way === 'notebook' && _jsx(FromNotebook, { onEmbed: (name) => done(() => onEmbed(name)) }), way === 'web' && _jsx(FromWeb, { onLink: (name, address) => done(() => onLink(name, address)) })] })] }) }));
63
+ }
64
+ /** Files of the computer, each with the name and the description the author gives it. */
65
+ function FromComputer({ onAttach }) {
66
+ const host = useHost();
67
+ const words = host.strings.editor;
68
+ const [chosen, setChosen] = useState([]);
69
+ const picker = useRef(null);
70
+ const update = (index, change) => setChosen((all) => all.map((each, at) => (at === index ? { ...each, ...change } : each)));
71
+ const ready = chosen.length > 0 && chosen.every((each) => each.name.trim().length > 0);
72
+ return (_jsxs("form", { className: "edit-attach-form", onSubmit: (event) => {
73
+ event.preventDefault();
74
+ if (ready)
75
+ onAttach(chosen.map((each) => ({ ...each, name: each.name.trim(), description: each.description.trim() })));
76
+ }, children: [_jsx("input", { ref: picker, type: "file", multiple: true, hidden: true, onChange: (event) => {
77
+ const files = [...(event.target.files ?? [])];
78
+ setChosen((all) => [...all, ...files.map((file) => ({ file, name: file.name, description: '' }))]);
79
+ event.target.value = '';
80
+ } }), _jsxs("button", { type: "button", className: "edit-attach-button", onClick: () => picker.current?.click(), children: [ICONS.attach, " ", words.chooseFiles] }), chosen.map((each, index) => (_jsxs("fieldset", { className: "edit-attach-file", children: [_jsxs("legend", { className: "edit-attach-file-kind", children: [fileKind(each.file.type || 'application/octet-stream', host.strings.fileKinds), " \u00B7 ", formatBytes(each.file.size, host.strings.locale)] }), _jsxs("label", { children: [_jsx("span", { children: words.fileName }), _jsx("input", { className: "edit-attach-input", value: each.name, required: true, onChange: (event) => update(index, { name: event.target.value }) })] }), _jsxs("label", { children: [_jsx("span", { children: words.fileDescription }), _jsx("input", { className: "edit-attach-input", value: each.description, onChange: (event) => update(index, { description: event.target.value }) })] }), _jsx("button", { type: "button", className: "edit-control", "aria-label": `${words.removeItem} ${each.name}`, title: words.removeItem, onClick: () => setChosen((all) => all.filter((_, at) => at !== index)), children: ICONS.remove })] }, index))), _jsx("div", { className: "edit-attach-actions", children: _jsx("button", { type: "submit", className: "edit-attach-button edit-attach-primary", disabled: !ready, children: words.attachConfirm }) })] }));
81
+ }
82
+ /** The files the notebook keeps, found by name or description, one chosen to embed. */
83
+ function FromNotebook({ onEmbed }) {
84
+ const host = useHost();
85
+ const words = host.strings.editor;
86
+ const [files, setFiles] = useState(null);
87
+ const [query, setQuery] = useState('');
88
+ useEffect(() => {
89
+ let live = true;
90
+ Promise.resolve()
91
+ .then(() => host.files?.() ?? [])
92
+ .then((listed) => live && setFiles([...listed].sort((a, b) => a.name.localeCompare(b.name))), () => live && setFiles('failed'));
93
+ return () => {
94
+ live = false;
95
+ };
96
+ }, [host.files]);
97
+ if (files === null)
98
+ return _jsx("p", { className: "edit-attach-note", children: host.strings.loading });
99
+ if (files === 'failed')
100
+ return _jsx("p", { className: "edit-attach-note", children: host.strings.failed });
101
+ const wanted = query.trim().toLowerCase();
102
+ const shown = wanted ? files.filter((each) => `${each.name}\n${each.description ?? ''}`.toLowerCase().includes(wanted)) : files;
103
+ return (_jsxs("div", { className: "edit-attach-files", children: [_jsx("input", { type: "search", className: "edit-attach-input", placeholder: words.searchFiles, "aria-label": words.searchFiles, value: query, onChange: (event) => setQuery(event.target.value) }), files.length === 0 ? (_jsx("p", { className: "edit-attach-note", children: words.noFiles })) : shown.length === 0 ? (_jsx("p", { className: "edit-attach-note", children: words.noMatch })) : (_jsx("ul", { className: "edit-attach-list", children: shown.map((each) => {
104
+ const preview = rendersAs(each.mimeType) === 'image' ? fileAddress(each.url) : '';
105
+ return (_jsx("li", { children: _jsxs("button", { type: "button", className: "edit-attach-item", onClick: () => onEmbed(each.name), children: [_jsx("span", { className: "edit-attach-preview", "aria-hidden": "true", children: preview ? _jsx("img", { src: preview, alt: "" }) : ICONS.attach }), _jsxs("span", { className: "edit-attach-item-body", children: [_jsx("span", { className: "edit-attach-item-name", children: each.name }), _jsxs("span", { className: "edit-attach-item-note", children: [fileKind(each.mimeType, host.strings.fileKinds), " \u00B7 ", formatBytes(each.bytes, host.strings.locale)] }), each.description && _jsx("span", { className: "edit-attach-item-note", children: each.description })] })] }) }, each.name));
106
+ }) }))] }));
107
+ }
108
+ /** An image at a public address, and the name it is described by. */
109
+ function FromWeb({ onLink }) {
110
+ const words = useHost().strings.editor;
111
+ const [address, setAddress] = useState('');
112
+ const [name, setName] = useState('');
113
+ const [invalid, setInvalid] = useState(false);
114
+ const submit = (event) => {
115
+ event.preventDefault();
116
+ // A space pasted with an address is part of it, written as the web writes it.
117
+ const target = address.trim().replace(/ /g, '%20');
118
+ if (!WEB.test(target))
119
+ return setInvalid(true);
120
+ onLink(name.trim(), target);
121
+ };
122
+ return (_jsxs("form", { className: "edit-attach-form", onSubmit: submit, noValidate: true, children: [_jsxs("label", { children: [_jsx("span", { children: words.webAddress }), _jsx("input", { className: "edit-attach-input", type: "url", inputMode: "url", placeholder: "https://", value: address, "aria-invalid": invalid || undefined, onChange: (event) => {
123
+ setAddress(event.target.value);
124
+ setInvalid(false);
125
+ } })] }), invalid && (_jsx("p", { className: "edit-attach-error", role: "alert", children: words.webInvalid })), _jsxs("label", { children: [_jsx("span", { children: words.webName }), _jsx("input", { className: "edit-attach-input", value: name, onChange: (event) => setName(event.target.value) })] }), _jsx("div", { className: "edit-attach-actions", children: _jsx("button", { type: "submit", className: "edit-attach-button edit-attach-primary", disabled: address.trim().length === 0, children: words.webInsert }) })] }));
126
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The editor's own menu on a right-click in edit mode (#81): what the toolbar formats and inserts,
3
+ * where the author is, with the clipboard's three verbs. It is a `role="menu"` reached by keyboard,
4
+ * kept inside the window, and closed by Escape, a click outside it, a scroll, or an entry run.
5
+ */
6
+ import { type ReactNode } from 'react';
7
+ export interface MenuItem {
8
+ readonly name: string;
9
+ readonly label: string;
10
+ readonly icon: ReactNode;
11
+ /** The keys that do the same, as CodeMirror writes them: `Mod-b`, `Mod-Shift-x`. */
12
+ readonly shortcut?: string;
13
+ readonly disabled?: boolean;
14
+ readonly run: () => void;
15
+ }
16
+ export interface MenuGroup {
17
+ readonly label: string;
18
+ readonly items: readonly MenuItem[];
19
+ }
20
+ /** A shortcut as the reader's keyboard writes it: `⌘⇧X` on a Mac, `Ctrl+Shift+X` elsewhere. */
21
+ export declare function shortcutText(shortcut: string): string;
22
+ export declare function ContextMenu({ x, y, label, groups, onClose, }: {
23
+ x: number;
24
+ y: number;
25
+ label: string;
26
+ groups: readonly MenuGroup[];
27
+ /** The menu closed; `back` says the focus goes back to the text, as after Escape or an entry. */
28
+ onClose: (back: boolean) => void;
29
+ }): import("react").JSX.Element;
@@ -0,0 +1,95 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The editor's own menu on a right-click in edit mode (#81): what the toolbar formats and inserts,
4
+ * where the author is, with the clipboard's three verbs. It is a `role="menu"` reached by keyboard,
5
+ * kept inside the window, and closed by Escape, a click outside it, a scroll, or an entry run.
6
+ */
7
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
8
+ const MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
9
+ /** A shortcut as the reader's keyboard writes it: `⌘⇧X` on a Mac, `Ctrl+Shift+X` elsewhere. */
10
+ export function shortcutText(shortcut) {
11
+ const keys = shortcut.split('-').map((key) => (key.length === 1 ? key.toUpperCase() : key));
12
+ if (MAC)
13
+ return keys.map((key) => ({ Mod: '⌘', Shift: '⇧', Alt: '⌥' })[key] ?? key).join('');
14
+ return keys.map((key) => ({ Mod: 'Ctrl' })[key] ?? key).join('+');
15
+ }
16
+ /** The same shortcut as `aria-keyshortcuts` names it. */
17
+ function ariaShortcut(shortcut) {
18
+ return shortcut
19
+ .split('-')
20
+ .map((key) => (key === 'Mod' ? (MAC ? 'Meta' : 'Control') : key.length === 1 ? key.toUpperCase() : key))
21
+ .join('+');
22
+ }
23
+ export function ContextMenu({ x, y, label, groups, onClose, }) {
24
+ const menu = useRef(null);
25
+ const [place, setPlace] = useState({ left: x, top: y });
26
+ const close = useRef(onClose);
27
+ close.current = onClose;
28
+ // Kept inside the window: a menu opened near the right or the bottom edge opens the other way.
29
+ useLayoutEffect(() => {
30
+ const element = menu.current;
31
+ if (!element)
32
+ return;
33
+ const box = element.getBoundingClientRect();
34
+ const margin = 8;
35
+ const width = window.innerWidth;
36
+ const height = window.innerHeight;
37
+ setPlace({
38
+ left: Math.max(margin, x + box.width + margin > width ? Math.max(margin, x - box.width) : x),
39
+ top: Math.max(margin, y + box.height + margin > height ? Math.max(margin, height - box.height - margin) : y),
40
+ });
41
+ element.querySelector('[role="menuitem"]:not([aria-disabled="true"])')?.focus();
42
+ }, [x, y]);
43
+ useEffect(() => {
44
+ const outside = (event) => {
45
+ if (!menu.current?.contains(event.target))
46
+ close.current(false);
47
+ };
48
+ const scrolled = (event) => {
49
+ if (!menu.current?.contains(event.target))
50
+ close.current(false);
51
+ };
52
+ document.addEventListener('mousedown', outside, true);
53
+ // The click that opened the menu may move the cursor, and the editor scroll it into view: a
54
+ // scroll closes the menu only once that one is over.
55
+ const listen = setTimeout(() => {
56
+ window.addEventListener('scroll', scrolled, true);
57
+ window.addEventListener('resize', scrolled);
58
+ }, 200);
59
+ return () => {
60
+ clearTimeout(listen);
61
+ document.removeEventListener('mousedown', outside, true);
62
+ window.removeEventListener('scroll', scrolled, true);
63
+ window.removeEventListener('resize', scrolled);
64
+ };
65
+ }, []);
66
+ const move = (step) => {
67
+ const items = [...(menu.current?.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])') ?? [])];
68
+ if (items.length === 0)
69
+ return;
70
+ const at = items.indexOf(document.activeElement);
71
+ const next = step === 'first' ? 0 : step === 'last' ? items.length - 1 : (at + step + items.length) % items.length;
72
+ items[next]?.focus();
73
+ };
74
+ return (_jsx("div", { ref: menu, className: "edit-menu", role: "menu", "aria-label": label, style: { left: place.left, top: place.top }, "data-ms-control": "", onContextMenu: (event) => event.preventDefault(), onKeyDown: (event) => {
75
+ if (event.key === 'ArrowDown')
76
+ move(1);
77
+ else if (event.key === 'ArrowUp')
78
+ move(-1);
79
+ else if (event.key === 'Home')
80
+ move('first');
81
+ else if (event.key === 'End')
82
+ move('last');
83
+ else if (event.key === 'Escape' || event.key === 'Tab')
84
+ onClose(true);
85
+ else
86
+ return;
87
+ event.preventDefault();
88
+ event.stopPropagation();
89
+ }, children: groups.map((group, index) => (_jsxs("div", { role: "group", "aria-label": group.label, className: "edit-menu-group", children: [index > 0 && _jsx("div", { role: "separator", className: "edit-menu-separator" }), _jsx("div", { className: "edit-menu-heading", "aria-hidden": "true", children: group.label }), group.items.map((item) => (_jsxs("button", { type: "button", role: "menuitem", className: "edit-menu-item", tabIndex: -1, "aria-disabled": item.disabled || undefined, "aria-keyshortcuts": item.shortcut ? ariaShortcut(item.shortcut) : undefined, onMouseDown: (event) => event.preventDefault(), onClick: () => {
90
+ if (item.disabled)
91
+ return;
92
+ onClose(true);
93
+ item.run();
94
+ }, children: [_jsx("span", { className: "edit-menu-icon", "aria-hidden": "true", children: item.icon }), _jsx("span", { className: "edit-menu-label", children: item.label }), item.shortcut && _jsx("span", { className: "edit-menu-shortcut", children: shortcutText(item.shortcut) })] }, item.name)))] }, group.label))) }));
95
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The language of a fenced code block, chosen at its top right in edit mode (#84): a button that
3
+ * says the language, and a combobox that searches every grammar Prism carries — by the name a
4
+ * person reads, by Prism's identifier, or by an alias, so `py` finds Python — with plain text and a
5
+ * Mermaid diagram first. Choosing rewrites the opening fence's info string and nothing else.
6
+ */
7
+ import type { EditorView } from '@codemirror/view';
8
+ /** Opens the control of the fence that starts at `from` once it is drawn. */
9
+ export declare function chooseLanguageAt(view: EditorView, from: number): void;
10
+ /**
11
+ * The fence's opening line rewritten with `language` as the first word of its info string, the
12
+ * rest of it kept; `null` takes the word away.
13
+ */
14
+ export declare function withLanguage(fence: string, language: string | null): string;
15
+ export declare function LanguageControl({ view, from, language }: {
16
+ view: EditorView;
17
+ from: number;
18
+ language: string | null;
19
+ }): import("react").JSX.Element;
@@ -0,0 +1,124 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
3
+ import { grammarOf } from '../highlight.js';
4
+ import { useHost } from '../host.js';
5
+ import { GRAMMARS } from '../prism-grammars.js';
6
+ import { ICONS } from './blocks.js';
7
+ /** The code blocks whose control opens as soon as it is drawn: one the toolbar or the menu just inserted. */
8
+ const opening = new WeakMap();
9
+ /** Opens the control of the fence that starts at `from` once it is drawn. */
10
+ export function chooseLanguageAt(view, from) {
11
+ opening.set(view, from);
12
+ }
13
+ /**
14
+ * The fence's opening line rewritten with `language` as the first word of its info string, the
15
+ * rest of it kept; `null` takes the word away.
16
+ */
17
+ export function withLanguage(fence, language) {
18
+ const match = /^([ \t>]*(?:`{3,}|~{3,}))([ \t]*)(\S*)(.*)$/.exec(fence);
19
+ if (!match)
20
+ return fence;
21
+ const [, marker, , , rest] = match;
22
+ return language ? `${marker}${language}${rest}` : `${marker}${rest?.replace(/^[ \t]+/, '') ?? ''}`;
23
+ }
24
+ export function LanguageControl({ view, from, language }) {
25
+ const host = useHost();
26
+ const words = host.strings.editor;
27
+ const id = useId();
28
+ const [open, setOpen] = useState(false);
29
+ const [query, setQuery] = useState('');
30
+ const [active, setActive] = useState(0);
31
+ const button = useRef(null);
32
+ const box = useRef(null);
33
+ const grammar = grammarOf(language);
34
+ const choices = useMemo(() => {
35
+ const all = [
36
+ { value: null, label: words.plainText, words: [words.plainText.toLowerCase(), 'text', 'plain'] },
37
+ { value: 'mermaid', label: words.mermaidDiagram, words: [words.mermaidDiagram.toLowerCase(), 'mermaid'] },
38
+ ...GRAMMARS.filter((each) => !each.part)
39
+ .map((each) => ({ value: each.id, label: each.title, words: [each.title.toLowerCase(), each.id, ...each.aliases.map((alias) => alias.toLowerCase())] }))
40
+ .sort((a, b) => a.label.localeCompare(b.label)),
41
+ ];
42
+ // A name no grammar answers is offered as itself, drawn as plain code.
43
+ if (language && !grammar && language.toLowerCase() !== 'mermaid')
44
+ all.splice(1, 0, { value: language, label: `${language} · ${words.notHighlighted}`, words: [language.toLowerCase()] });
45
+ return all;
46
+ }, [words, language, grammar]);
47
+ const wanted = query.trim().toLowerCase();
48
+ // What the search finds, best first: a name or an alias that is the search, then one that starts
49
+ // with it, then one that holds it; the order of the list within each.
50
+ const shown = useMemo(() => {
51
+ if (!wanted)
52
+ return choices;
53
+ const rank = (each) => (each.words.some((word) => word === wanted) ? 0 : each.words.some((word) => word.startsWith(wanted)) ? 1 : 2);
54
+ return choices
55
+ .filter((each) => each.words.some((word) => word.includes(wanted)))
56
+ .map((each, order) => ({ each, order, rank: rank(each) }))
57
+ .sort((a, b) => a.rank - b.rank || a.order - b.order)
58
+ .map(({ each }) => {
59
+ const alias = each.words.slice(2).find((word) => word.startsWith(wanted));
60
+ return alias && !each.label.toLowerCase().includes(wanted) ? { ...each, hint: alias } : each;
61
+ });
62
+ }, [choices, wanted]);
63
+ const current = language === null ? null : (grammar?.id ?? language.toLowerCase());
64
+ const label = language === null ? words.plainText : language.toLowerCase() === 'mermaid' ? words.mermaidDiagram : (grammar?.title ?? language);
65
+ // A block the toolbar or the menu just inserted opens its choice at once, its search focused.
66
+ useEffect(() => {
67
+ if (opening.get(view) !== from)
68
+ return;
69
+ opening.delete(view);
70
+ setOpen(true);
71
+ }, [view, from]);
72
+ useEffect(() => {
73
+ if (!open)
74
+ return;
75
+ setActive(Math.max(0, shown.findIndex((each) => each.value === current)));
76
+ box.current?.querySelector('input')?.focus();
77
+ const outside = (event) => {
78
+ if (!box.current?.contains(event.target))
79
+ setOpen(false);
80
+ };
81
+ document.addEventListener('mousedown', outside, true);
82
+ return () => document.removeEventListener('mousedown', outside, true);
83
+ // The active option is placed once, as the list opens.
84
+ }, [open]);
85
+ useEffect(() => {
86
+ box.current?.querySelector(`[id="${id}-option-${active}"]`)?.scrollIntoView?.({ block: 'nearest' });
87
+ }, [active, id]);
88
+ const close = (back) => {
89
+ setOpen(false);
90
+ setQuery('');
91
+ if (back === 'text')
92
+ view.focus();
93
+ else
94
+ button.current?.focus();
95
+ };
96
+ const choose = (choice) => {
97
+ const line = view.state.doc.lineAt(from);
98
+ const next = withLanguage(line.text, choice.value);
99
+ if (next !== line.text)
100
+ view.dispatch({ changes: { from: line.from, to: line.to, insert: next }, userEvent: 'input.form' });
101
+ // The cursor goes into the code, where the author writes next.
102
+ const after = view.state.doc.lineAt(from);
103
+ if (after.number < view.state.doc.lines)
104
+ view.dispatch({ selection: { anchor: view.state.doc.line(after.number + 1).from } });
105
+ close('text');
106
+ };
107
+ return (_jsxs("span", { className: "edit-language", ref: box, "data-ms-control": "", children: [_jsxs("button", { ref: button, type: "button", className: "edit-language-button", "aria-haspopup": "listbox", "aria-expanded": open, title: words.language, "aria-label": `${words.language}: ${label}`, onMouseDown: (event) => event.preventDefault(), onClick: () => (open ? close('button') : setOpen(true)), children: [label, _jsx("span", { className: "edit-language-caret", "aria-hidden": "true", children: ICONS.caret })] }), open && (_jsxs("span", { className: "edit-language-popover", children: [_jsx("input", { className: "edit-language-search", role: "combobox", "aria-expanded": "true", "aria-controls": `${id}-list`, "aria-autocomplete": "list", "aria-activedescendant": shown[active] ? `${id}-option-${active}` : undefined, "aria-label": words.searchLanguages, placeholder: words.searchLanguages, value: query, onChange: (event) => {
108
+ setQuery(event.target.value);
109
+ setActive(0);
110
+ }, onKeyDown: (event) => {
111
+ if (event.key === 'ArrowDown')
112
+ setActive((at) => Math.min(shown.length - 1, at + 1));
113
+ else if (event.key === 'ArrowUp')
114
+ setActive((at) => Math.max(0, at - 1));
115
+ else if (event.key === 'Enter' && shown[active])
116
+ choose(shown[active]);
117
+ else if (event.key === 'Escape')
118
+ close('text');
119
+ else
120
+ return;
121
+ event.preventDefault();
122
+ event.stopPropagation();
123
+ } }), _jsx("span", { className: "edit-language-list", role: "listbox", id: `${id}-list`, "aria-label": words.language, children: shown.length === 0 ? (_jsx("span", { className: "edit-language-none", children: words.noLanguage })) : (shown.map((each, index) => (_jsxs("span", { id: `${id}-option-${index}`, role: "option", "aria-selected": each.value === current, "data-active": index === active || undefined, className: "edit-language-option", onMouseDown: (event) => event.preventDefault(), onMouseEnter: () => setActive(index), onClick: () => choose(each), children: [_jsx("span", { children: each.label }), each.hint && _jsx("span", { className: "edit-language-hint", children: each.hint })] }, each.value ?? '')))) })] }))] }));
124
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A note edited on the page that draws it (#48, ADR-006).
3
+ *
4
+ * The editor holds the note's bytes and nothing else: CodeMirror keeps the text, the live page
5
+ * (`live.ts`) draws it as the reading surface would, and every change the author makes — typed,
6
+ * from the toolbar, in a table's cell or in the properties form — is a change to those bytes,
7
+ * handed to the host with `onChange`. What the author did not touch is never rewritten, so the
8
+ * host receives a note that differs from the one it gave only where it was edited (#50).
9
+ */
10
+ import { type ReactNode } from 'react';
11
+ import { type NoteHost } from '../host.js';
12
+ export interface NoteEditorProps extends Omit<NoteHost, 'writable' | 'onToggleTask'> {
13
+ /** The note as the host keeps it, frontmatter included. A new value that differs from the editor's is taken in. */
14
+ readonly markdown: string;
15
+ /**
16
+ * The note after each change the author makes, byte for byte the original wherever it was not
17
+ * edited. A promise answered is the save of that change, which the editor reports (#64); one that
18
+ * resolves with a `SaveOutcome` or rejects with a `SaveRefusal` has the host's words and actions (#86).
19
+ */
20
+ readonly onChange?: (markdown: string) => unknown;
21
+ /** Whether the author may change the note. Defaults to `true`. */
22
+ readonly editable?: boolean;
23
+ /** What sits at the top right of the note, such as the switch back to reading. */
24
+ readonly corner?: ReactNode;
25
+ /** Whether the editor takes the focus when it opens. */
26
+ readonly autoFocus?: boolean;
27
+ /**
28
+ * The author asked to go back to reading. The editor waits for a save in flight, folds its
29
+ * toolbar into the corner, and answers through `onLeave`: `true` once it may go, `false` when
30
+ * the last save failed and the note stays in edit.
31
+ */
32
+ readonly leaving?: boolean;
33
+ readonly onLeave?: (left: boolean) => void;
34
+ }
35
+ /**
36
+ * The line break a note is written with, which the editor writes back: `\n` or `\r\n`. `null`
37
+ * for a note that mixes them, or has a lone `\r`, which the editor cannot keep byte for byte.
38
+ */
39
+ export declare function lineBreakOf(text: string): '\n' | '\r\n' | null;
40
+ /**
41
+ * Edits a note on the page that draws it. Exported at `@memorysmithapp/markdown-renderer/edit`
42
+ * for a host that edits without the switch of `NoteBody`; `NoteBody` loads it on demand.
43
+ */
44
+ export declare function NoteEditor({ markdown: given, onChange, editable, corner, autoFocus, leaving, onLeave, ...host }: NoteEditorProps): import("react").JSX.Element;