@memorysmithapp/markdown-renderer 0.4.0 → 0.6.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 CHANGED
@@ -20,7 +20,7 @@ import 'katex/dist/katex.min.css';
20
20
  resolve={(name) => addressOfTheOneNoteNamed(name)} // or null: the link is pending
21
21
  keeps={(name) => notebookKeepsAFile(name)} // only an embed reaches a file
22
22
  fileOf={(name) => describe(name)} // { mimeType, bytes, url, description }: drawn by its type, as MemorySmith does
23
- renderLink={({ href, className, children }) => <RouterLink to={href} className={className}>{children}</RouterLink>}
23
+ renderLink={({ href, className, children }) => <a href={href} className={className} onClick={follow(href)}>{children}</a>} // follow: your router's navigate, captured where it is
24
24
  loadNote={(name) => fetchTheBodyOf(name)} // for an embedded note; the package never fetches
25
25
  writable
26
26
  onToggleTask={(ordinal) => save(toggleTaskAt(latest(), ordinal))} // a promise locks the item under "Updating…" until it settles
@@ -29,7 +29,7 @@ import 'katex/dist/katex.min.css';
29
29
  />;
30
30
  ```
31
31
 
32
- Every prop is optional, and every default is the safe one: with no `resolve` every link is pending, with no `keeps` no embed reaches a file. A file `fileOf` describes is drawn by its media type, never its name: an image, an audio or a video player, and anything else a card with its type, size, description, *Open* where a browser shows it and *Download*; give `renderAttachment` instead to draw files yourself. The stylesheet styles nothing outside `.markdown`. The faces of the default theme are Inter and Space Grotesk, which the page loads.
32
+ Every prop is optional, and every default is the safe one: with no `resolve` every link is pending, with no `keeps` no embed reaches a file. A file `fileOf` describes is drawn by its media type, never its name: an image, an audio or a video player, and anything else a card with its type, size, description, *Open* where a browser shows it and *Download*; give `renderAttachment` instead to draw files yourself. In edit mode, `renderLink`, `renderAttachment` and a theme's slots may be drawn in a root of their own, outside your React tree, so what they return must not read your context: a router's `<Link>` throws there, and the block shows its Markdown under a notice instead. The stylesheet styles nothing outside `.markdown`. The faces of the default theme are Inter and Space Grotesk, which the page loads.
33
33
 
34
34
  ## Themes
35
35
 
@@ -76,7 +76,7 @@ Import `structure.css` instead of `styles.css` for a theme that draws everything
76
76
 
77
77
  ## Edit
78
78
 
79
- A note the host makes `editable` carries a button at its top right that switches between reading and editing it on the same page. The author edits what they see: marks, headings, lists, tasks, links and the names of notes after `[[`, callouts, tables cell by cell, code, formulas and diagrams with their drawing live, the properties as a form, footnotes and comments. The note is the bytes you gave, and every change comes back whole through `onChange`, unchanged wherever the author did not touch it — the order and quoting of the frontmatter, a list's marker, a table's spacing, a comment, a line break. The package never saves. The toolbar grows out of the switch, which becomes its last control; its first tool undoes every change since editing began, and a right-click on the text opens a menu of the same tools where you are (Shift keeps the browser's); beside the switch, in both modes, Show Markdown opens the note's Markdown, as you have it or would receive it, in a dialog, and Copy Markdown copies it.
79
+ A note the host makes `editable` carries a button at its top right that switches between reading and editing it on the same page. The author edits what they see: marks, headings, lists, tasks, links and the names of notes after `[[`, callouts, tables cell by cell, code, formulas and diagrams with their drawing live, the properties as a form, footnotes and comments. The note is the bytes you gave, and every change comes back whole through `onChange`, unchanged wherever the author did not touch it — the order and quoting of the frontmatter, a list's marker, a table's spacing, a comment, a line break. The package never saves. The toolbar grows out of the switch, which becomes its last control; its first tool undoes every change since editing began, and a right-click on the text opens a menu of the same tools where you are (Shift keeps the browser's); beside the switch, in both modes, Show Markdown opens the note's Markdown, as you have it or would receive it, in a dialog, and Copy Markdown copies it. Those two are reading, so a note that is not `editable` carries them too, without the switch; pass `actions={false}` to hide them, for a print, a preview or an embed.
80
80
 
81
81
  ```tsx
82
82
  <NoteBody
@@ -84,13 +84,31 @@ A note the host makes `editable` carries a button at its top right that switches
84
84
  editable // the switch, at the top right
85
85
  onChange={(next) => save(next)} // after every change; return the save's promise
86
86
  suggest={(typed) => namesOfNotesLike(typed)} // what [[ offers
87
- attach={(file, { name, description }) => keepInNotebook(file, name, description)} // the name it is kept under
87
+ attach={(file, { name, description }, progress) => keepInNotebook(file, name, description, progress)} // the name it is kept under
88
88
  files={() => filesOfTheNotebook()} // what the attach dialog lists: { name, mimeType, bytes, url, description }
89
- modeButton="icon" // or "label", the icon and its word
89
+ modeButton="label" // the icon and its word; "icon", the default, the icon alone
90
90
  />
91
91
  ```
92
92
 
93
- When `onChange` returns the promise of its save, the editor shows before the switch an icon for whether the last change is *Saving…*, *Saved* or *Not saved*, the last one's icon trying again when clicked, and going back to reading waits for the save, staying in edit when it failed. Return nothing and no status is shown. Pass the saved note back as `markdown` whenever you like: a text the editor already moved past is not applied over what the author typed since.
93
+ When `onChange` returns the promise of its save, the editor shows before the switch an icon for whether the last change is *Saving…*, *Saved* or *Not saved*, the last one's icon trying again when clicked, and going back to reading waits for the save, staying in edit when it failed. Return nothing and no status is shown. When there is something to tell the author, resolve the promise with a `SaveOutcome` (the save went through, and they have a choice to make) or reject it with a `SaveRefusal` (it did not, and why): each is a `title`, a `message` and the `actions` to choose from, in your words. The icon then opens a dialog with them; an action's promise is followed like a save, and the author can close the dialog and come back to it:
94
+
95
+ ```tsx
96
+ onChange={async (next) => {
97
+ const saved = await save(next);
98
+ if (saved.conflict) throw { title: 'Somebody else wrote this note', message: 'Your edit was not saved.', actions: [
99
+ { label: 'Discard my edit and reload', run: () => reload() },
100
+ { label: 'Save anyway', run: () => save(next, { force: true }) },
101
+ ] } satisfies SaveRefusal;
102
+ if (saved.renamed) return { title: 'Note renamed', message: `${saved.links} links reached the old name.`, actions: [
103
+ { label: 'Keep the links broken', run: () => undefined },
104
+ { label: 'Keep the old name as an alias', run: () => addAlias() },
105
+ ] } satisfies SaveOutcome;
106
+ }}
107
+ ```
108
+
109
+ While `attach` keeps a file, call `progress({ message, fraction })` with each step in your words — *Sending part 12 of 40*, and `0.3` — and the paperclip shows it, a bar filling when you give the fraction. Reject with `{ message }` (an `AttachRefusal`, not an `Error`) to tell the author why the file was refused; the paperclip says it until they open it again. An `Error` keeps the paperclip's own *The file was not attached*.
110
+
111
+ Pass the saved note back as `markdown` whenever you like: a text the editor already moved past is not applied over what the author typed since.
94
112
 
95
113
  `mode` and `onModeChange` let you control the mode yourself, and the words come from `strings` (`edit`, `view` and `editor`). The editor is CodeMirror, loaded the first time a note is edited, so a page that only draws never loads it. To edit without the switch, import it from its own entry point:
96
114
 
@@ -7,7 +7,7 @@ export interface NoteBodyProps extends NoteHost {
7
7
  readonly markdown: string;
8
8
  /**
9
9
  * Whether the note offers the switch to edit it, at its top right (#51). Defaults to `false`:
10
- * no switch, and the page is the one a reader always had.
10
+ * no switch, the note only read, with Show and Copy Markdown as `actions` says.
11
11
  */
12
12
  readonly editable?: boolean;
13
13
  /** The mode, for a host that controls it; left out, the note keeps its own, starting from `defaultMode`. */
@@ -19,11 +19,23 @@ export interface NoteBodyProps extends NoteHost {
19
19
  /**
20
20
  * The note after each change made in edit mode, byte for byte the original wherever it was not
21
21
  * edited. The package never saves it; a host that answers with the promise of its save has the
22
- * editor say whether it was saved, and going back to reading wait for it (#64).
22
+ * editor say whether it was saved, and going back to reading wait for it (#64). The promise may
23
+ * resolve with a `SaveOutcome`, a save that succeeded with something the author must decide, or
24
+ * reject with a `SaveRefusal`, the reason it was refused: either opens, from the save's status,
25
+ * a dialog with the host's words and its actions (#86).
23
26
  */
24
27
  readonly onChange?: (markdown: string) => unknown;
25
- /** Whether the switch shows its icon alone or its icon and its word. Defaults to `label`. */
28
+ /**
29
+ * Whether the switch shows its icon alone or its icon and its word. Defaults to `icon`, like Show
30
+ * Markdown and Copy Markdown beside it, its word then its name and its title (#92).
31
+ */
26
32
  readonly modeButton?: 'icon' | 'label';
33
+ /**
34
+ * Whether the note offers Show Markdown and Copy Markdown at its top right, editable or not:
35
+ * reading a note's Markdown and copying it are reading (#87). Defaults to `true`; `false` hides
36
+ * them, for a print, a preview or an embed.
37
+ */
38
+ readonly actions?: boolean;
27
39
  }
28
40
  /** Whether a note is read or edited. */
29
41
  export type NoteMode = 'view' | 'edit';
@@ -33,4 +45,4 @@ export type NoteMode = 'view' | 'edit';
33
45
  * It renders inside `.markdown`, so the stylesheet of the package applies, and asks the host for
34
46
  * every piece of notebook data through the props of `NoteHost`.
35
47
  */
36
- export declare function NoteBody({ editable, mode, defaultMode, onModeChange, onChange, modeButton, ...rest }: NoteBodyProps): import("react").JSX.Element;
48
+ export declare function NoteBody({ editable, mode, defaultMode, onModeChange, onChange, modeButton, actions, ...rest }: NoteBodyProps): import("react").JSX.Element;
package/dist/NoteBody.js CHANGED
@@ -34,14 +34,14 @@ function ModeSwitch({ mode, display, toggle }) {
34
34
  * It renders inside `.markdown`, so the stylesheet of the package applies, and asks the host for
35
35
  * every piece of notebook data through the props of `NoteHost`.
36
36
  */
37
- export function NoteBody({ editable = false, mode, defaultMode = 'view', onModeChange, onChange, modeButton = 'label', ...rest }) {
37
+ export function NoteBody({ editable = false, mode, defaultMode = 'view', onModeChange, onChange, modeButton = 'icon', actions = true, ...rest }) {
38
38
  const [own, setOwn] = useState(defaultMode);
39
39
  // The author asked to go back to reading, and the editor is finishing: a save, the toolbar folding.
40
40
  const [leaving, setLeaving] = useState(false);
41
41
  // The last text handed to the host in edit mode, which Show and Copy Markdown give there (#76).
42
42
  const handed = useRef(null);
43
43
  const current = editable ? (mode ?? own) : 'view';
44
- if (!editable)
44
+ if (!editable && !actions)
45
45
  return _jsx(NoteView, { ...rest });
46
46
  const go = (next) => {
47
47
  if (mode === undefined)
@@ -67,13 +67,14 @@ export function NoteBody({ editable = false, mode, defaultMode = 'view', onModeC
67
67
  };
68
68
  const { writable: _writable, onToggleTask: _toggle, ...host } = rest;
69
69
  return (_jsx(HostOnly, { host: rest, children: (resolved) => {
70
- // The switch, with Show and Copy Markdown beside it in both modes (#76).
71
- const corner = (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: "note-corner", children: [_jsx(NoteActions, { text: () => (current === 'edit' ? (handed.current ?? rest.markdown) : rest.markdown) }), _jsx(ModeSwitch, { mode: current, display: modeButton, toggle: toggle })] }) }));
70
+ // Show and Copy Markdown for whoever reads the note, in both modes (#76, #87), and the
71
+ // switch only where the note is editable.
72
+ const corner = (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: "note-corner", children: [actions && _jsx(NoteActions, { text: () => (current === 'edit' ? (handed.current ?? rest.markdown) : rest.markdown) }), editable && _jsx(ModeSwitch, { mode: current, display: modeButton, toggle: toggle })] }) }));
72
73
  if (current === 'view')
73
- return _jsx(NoteView, { ...rest, corner: corner });
74
+ return _jsx(NoteView, { ...rest, corner: corner, switched: editable });
74
75
  return (
75
76
  // While the editor loads, the note stays on the page as it was.
76
- _jsx(Suspense, { fallback: _jsx(NoteView, { ...rest, corner: corner }), children: _jsx(NoteEditor, { ...host, onChange: hand, corner: corner, autoFocus: true, leaving: leaving, onLeave: onLeave }) }));
77
+ _jsx(Suspense, { fallback: _jsx(NoteView, { ...rest, corner: corner, switched: true }), children: _jsx(NoteEditor, { ...host, onChange: hand, corner: corner, autoFocus: true, leaving: leaving, onLeave: onLeave }) }));
77
78
  } }));
78
79
  }
79
80
  /** The host resolved once, for what is drawn around the note. */
@@ -82,7 +83,7 @@ function HostOnly({ host, children }) {
82
83
  return _jsx(_Fragment, { children: children(resolved) });
83
84
  }
84
85
  /** The note drawn for reading: the page MemorySmith's reading page draws. */
85
- function NoteView({ markdown, writable = false, onToggleTask, corner, ...host }) {
86
+ function NoteView({ markdown, writable = false, onToggleTask, corner, switched = false, ...host }) {
86
87
  const resolved = useMemo(() => resolveHost(host),
87
88
  // Every member of the contract, so a host that passes the same functions keeps the same host.
88
89
  HOST_KEYS.map((key) => host[key]));
@@ -146,7 +147,7 @@ function NoteView({ markdown, writable = false, onToggleTask, corner, ...host })
146
147
  // The runs and whether an embed cut them are the whole input.
147
148
  [runs.join('\u0000'), runs.length < segments.length]);
148
149
  let run = 0;
149
- return (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: `markdown markdown-theme-${resolved.theme.name}${corner ? ' has-mode-switch' : ''}`, "data-theme": resolved.scheme, style: rootStyle(resolved.theme, resolved.scheme), children: [corner, _jsx(Properties, { properties: properties }), segments.map((segment, index) => {
150
+ return (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: `markdown markdown-theme-${resolved.theme.name}${corner ? ' has-note-corner' : ''}${switched ? ' has-mode-switch' : ''}`, "data-theme": resolved.scheme, style: rootStyle(resolved.theme, resolved.scheme), children: [corner, _jsx(Properties, { properties: properties }), segments.map((segment, index) => {
150
151
  if (!('rendered' in segment)) {
151
152
  return _jsx(Transclusion, { target: segment.target, anchor: segment.anchor }, index);
152
153
  }
@@ -11,7 +11,9 @@ export interface ChosenFile {
11
11
  }
12
12
  /** The Markdown of an image at a web address, its name the alternative text (§3.14). */
13
13
  export declare function webImage(name: string, address: string): string;
14
- export declare function AttachDialog({ onClose, onClosed, onAttach, onEmbed, onLink, }: {
14
+ export declare function AttachDialog({ refusal, onClose, onClosed, onAttach, onEmbed, onLink, }: {
15
+ /** Why the host refused the last file, said at the top of the dialog the author opened after it (#88). */
16
+ refusal?: string | null;
15
17
  onClose: () => void;
16
18
  /** Once the dialog is closed: whether something was put in the note, which then has the focus. */
17
19
  onClosed: (inserted: boolean) => void;
@@ -17,7 +17,7 @@ export function webImage(name, address) {
17
17
  const target = /[\s()<>]/.test(address) ? `<${address.replace(/[<>]/g, encodeURIComponent)}>` : address;
18
18
  return `![${alt}](${target})`;
19
19
  }
20
- export function AttachDialog({ onClose, onClosed, onAttach, onEmbed, onLink, }) {
20
+ export function AttachDialog({ refusal = null, onClose, onClosed, onAttach, onEmbed, onLink, }) {
21
21
  const host = useHost();
22
22
  const words = host.strings.editor;
23
23
  const ways = [...(host.attach ? ['computer'] : []), ...(host.files ? ['notebook'] : []), 'web'];
@@ -51,7 +51,7 @@ export function AttachDialog({ onClose, onClosed, onAttach, onEmbed, onLink, })
51
51
  return (_jsx("dialog", { ref: dialog, className: "edit-source edit-attach", "aria-modal": "true", "aria-labelledby": `${id}-title`, onCancel: (event) => {
52
52
  event.preventDefault();
53
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 })] }), _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) => {
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
55
  const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
56
56
  if (step === 0)
57
57
  return;
@@ -14,7 +14,8 @@ export interface NoteEditorProps extends Omit<NoteHost, 'writable' | 'onToggleTa
14
14
  readonly markdown: string;
15
15
  /**
16
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).
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).
18
19
  */
19
20
  readonly onChange?: (markdown: string) => unknown;
20
21
  /** Whether the author may change the note. Defaults to `true`. */
@@ -14,16 +14,18 @@ import { markdown, markdownKeymap } from '@codemirror/lang-markdown';
14
14
  import { Annotation, Compartment, EditorState } from '@codemirror/state';
15
15
  import { EditorView, keymap } from '@codemirror/view';
16
16
  import { useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react';
17
- import { HOST_KEYS, HostProvider, resolveHost } from '../host.js';
17
+ import { HOST_KEYS, HostProvider, isAttachRefusal, resolveHost } from '../host.js';
18
18
  import { rootStyle } from '../theme.js';
19
19
  import { ICONS } from './blocks.js';
20
20
  import { BLOCKS, command, headingLevel, insertBlock, insertFootnote, insertLink, insertWikilink, setHeading, toggleComment, toggleList, toggleMark, toggleQuote, } from './commands.js';
21
21
  import { analysisField, editorEnvironment, livePage } from './live.js';
22
+ import { measureMetrics } from './heights.js';
22
23
  import { NoteActions } from '../NoteActions.js';
23
24
  import { AttachDialog, webImage } from './AttachDialog.js';
24
25
  import { ContextMenu } from './ContextMenu.js';
25
26
  import { chooseLanguageAt } from './LanguageControl.js';
26
27
  import { useEditSaves } from './saves.js';
28
+ import { SaveNoticeDialog } from './SaveNoticeDialog.js';
27
29
  /**
28
30
  * The line break a note is written with, which the editor writes back: `\n` or `\r\n`. `null`
29
31
  * for a note that mixes them, or has a lone `\r`, which the editor cannot keep byte for byte.
@@ -110,8 +112,23 @@ function insertBlockCommand(name) {
110
112
  return true;
111
113
  };
112
114
  }
113
- /** How long the attach button says a file was not attached, in milliseconds. */
115
+ const NOT_ATTACHING = { state: 'idle', message: null, fraction: null, queued: 0 };
116
+ /** How long the attach button says a file was not attached, when the host did not say why, in milliseconds. */
114
117
  const ATTACH_FAILED_MS = 3000;
118
+ /** A host's fraction, kept between 0 and 1, or none when it gave no number. */
119
+ function fractionOf(report) {
120
+ const fraction = report?.fraction;
121
+ return typeof fraction === 'number' && Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : null;
122
+ }
123
+ /** What the paperclip says: its word, the host's step and the files waiting, or why the last one failed. */
124
+ function attachSays(attaching, words) {
125
+ if (attaching.state === 'failed')
126
+ return attaching.message ?? words.attachFailed;
127
+ if (attaching.state === 'idle')
128
+ return words.attach;
129
+ const step = attaching.message ?? words.attaching;
130
+ return attaching.queued > 0 ? `${step} (${words.attachQueued(attaching.queued)})` : step;
131
+ }
115
132
  /** The toolbar of the editor: the marks, the blocks and the history, each a command. */
116
133
  function Toolbar({ view, host, end, opened, onAttach, attaching, attachButton, }) {
117
134
  const words = host.strings.editor;
@@ -120,21 +137,35 @@ function Toolbar({ view, host, end, opened, onAttach, attaching, attachButton, }
120
137
  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
138
  const block = (name) => button(name, insertBlockCommand(name));
122
139
  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] }));
140
+ return (_jsxs("div", { className: "edit-toolbar", role: "toolbar", "aria-label": words.toolbar, children: [_jsxs("div", { className: "edit-toolbar-tools", children: [_jsxs("span", { className: "edit-toolbar-group", children: [_jsx("button", { type: "button", className: "edit-control", title: words.revert, "aria-label": words.revert, disabled: !view || view.state.readOnly || view.state.doc.toString() === opened, onMouseDown: (event) => event.preventDefault(), onClick: run(command((state) => revert(state, opened))), children: ICONS.revert }), button('undo', undo, 'Mod-Z'), button('redo', redo, 'Mod-Shift-Z')] }), _jsx("span", { className: "edit-toolbar-group", children: _jsxs("select", { className: "edit-heading", "aria-label": words.heading, title: words.heading, value: level, disabled: disabled, onChange: (event) => view && command((state) => setHeading(state, Number(event.target.value)))(view), children: [_jsx("option", { value: 0, children: words.paragraph }), [1, 2, 3, 4, 5, 6].map((each) => (_jsx("option", { value: each, children: `${words.heading} ${each}` }, each)))] }) }), _jsxs("span", { className: "edit-toolbar-group", children: [button('bold', command((state) => toggleMark(state, '**')), 'Mod-B'), button('italic', command((state) => toggleMark(state, '_')), 'Mod-I'), button('strikethrough', command((state) => toggleMark(state, '~~')), 'Mod-Shift-X'), button('highlight', command((state) => toggleMark(state, '==')), 'Mod-Shift-H'), button('code', command((state) => toggleMark(state, '`')), 'Mod-E')] }), _jsxs("span", { className: "edit-toolbar-group", children: [button('link', command(insertLink), 'Mod-K'), button('wikilink', command(insertWikilink), 'Mod-Shift-K'), button('footnote', command(insertFootnote)), button('comment', command(toggleComment), 'Mod-/'), _jsxs("span", { className: "edit-attach-control", children: [_jsx("button", { ref: attachButton, type: "button", className: "edit-control", title: attachSays(attaching, words), "aria-label": words.attach, "aria-busy": attaching.state === 'busy' ? true : undefined, "data-attach": attaching.state, disabled: disabled, onMouseDown: (event) => event.preventDefault(), onClick: onAttach, children: attaching.state === 'busy' ? ICONS.saving : attaching.state === 'failed' ? ICONS.notSaved : ICONS.attach }), attaching.state === 'busy' && attaching.fraction !== null && (_jsx("span", { className: "edit-attach-progress", role: "progressbar", "aria-label": words.attach, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.round(attaching.fraction * 100), style: { ['--edit-attach-fraction']: attaching.fraction } })), _jsx("span", { className: "visually-hidden", role: "status", "aria-live": "polite", children: attaching.state === 'idle' ? '' : attachSays(attaching, words) })] })] }), _jsxs("span", { className: "edit-toolbar-group", children: [button('bulletList', command((state) => toggleList(state, 'bullet')), 'Mod-Shift-8'), button('orderedList', command((state) => toggleList(state, 'ordered')), 'Mod-Shift-7'), button('taskList', command((state) => toggleList(state, 'task')), 'Mod-Shift-9'), button('quote', command(toggleQuote), 'Mod-Shift-.')] }), _jsxs("span", { className: "edit-toolbar-group", children: [block('callout'), block('table'), block('codeBlock'), block('math'), block('diagram'), block('rule')] })] }), end] }));
124
141
  }
125
142
  /**
126
143
  * Where the save of the author's last change stands, in the toolbar before the switch: an icon for
127
144
  * 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.
145
+ * its title. A failed save's icon is the button that tries again (#66). When the host said why —
146
+ * an alert on a save that succeeded, or the reason for one it refused — the icon is the button that
147
+ * opens what it said, with the host's title as its tooltip, and the author's choices (#86). Whoever
148
+ * hears the page is told the whole of it, why the note stays in edit included.
130
149
  */
131
- function SaveStatus({ state, blocked, retry, host }) {
150
+ function SaveStatus({ saves, blocked, host, onDecided }) {
151
+ const { state, notice } = saves;
132
152
  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;
153
+ const [open, setOpen] = useState(false);
154
+ const button = useRef(null);
155
+ // A notice the save no longer carries has nothing left to show.
156
+ useEffect(() => {
157
+ if (!notice)
158
+ setOpen(false);
159
+ }, [notice]);
160
+ const base = state === 'saving' ? words.saving : state === 'saved' ? host.strings.saved : state === 'failed' ? host.strings.notSaved : '';
161
+ const shown = notice ? notice.title : base;
162
+ const said = state === 'failed' && blocked ? (notice ? `${words.stayInEdit}. ${notice.title}` : words.stayInEdit) : shown;
163
+ const icon = state === 'saving' ? ICONS.saving : state === 'saved' ? ICONS.saved : state === 'alert' ? ICONS.alert : state === 'failed' ? ICONS.notSaved : null;
136
164
  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 })] }));
165
+ return (_jsxs("div", { className: "edit-save", "data-save": state, title: state === 'failed' || notice ? undefined : said || undefined, children: [notice ? (_jsx("button", { ref: button, type: "button", className: "edit-save-icon edit-save-notice", title: notice.title, "aria-label": notice.title, "aria-haspopup": "dialog", onClick: () => setOpen(true), children: icon })) : state === 'failed' ? (_jsx("button", { type: "button", className: "edit-save-icon edit-save-retry", title: retryLabel, "aria-label": retryLabel, onClick: saves.retry, children: icon })) : (_jsx("span", { className: "edit-save-icon", children: icon })), _jsx("span", { className: "visually-hidden", role: "status", "aria-live": "polite", children: said }), open && notice && (_jsx(SaveNoticeDialog, { notice: notice, onClose: () => setOpen(false), onClosed: (chosen) => (chosen ? onDecided() : button.current?.focus()), onAction: (action) => {
166
+ setOpen(false);
167
+ saves.run(action);
168
+ } }))] }));
138
169
  }
139
170
  /**
140
171
  * Edits a note on the page that draws it. Exported at `@memorysmithapp/markdown-renderer/edit`
@@ -162,11 +193,16 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
162
193
  const [blocked, setBlocked] = useState(false);
163
194
  const [refusals, setRefusals] = useState(0);
164
195
  const editorView = useRef(null);
165
- const [attaching, setAttaching] = useState('idle');
196
+ const [attaching, setAttaching] = useState(NOT_ATTACHING);
197
+ // Each run of attaching, so that a run a later one replaced says nothing more.
198
+ const attachRun = useRef(0);
199
+ // The refusal the paperclip said, shown again at the top of the dialog it opens (#88).
200
+ const [refusal, setRefusal] = useState(null);
166
201
  /**
167
202
  * Hands each file to the host's `attach`, one after the other, and embeds it as `![[name]]` on a
168
203
  * 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).
204
+ * keep writes nothing, and the button says so for a moment (#75) — or, when the host said why,
205
+ * until the author opens it again. The host's steps are shown as they come (#88).
170
206
  */
171
207
  const attachFiles = useRef(() => undefined);
172
208
  attachFiles.current = (files, at) => {
@@ -176,20 +212,34 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
176
212
  return;
177
213
  if (at !== undefined)
178
214
  target.dispatch({ selection: { anchor: at } });
179
- setAttaching('busy');
215
+ const run = ++attachRun.current;
216
+ const live = () => run === attachRun.current && editorView.current !== null;
180
217
  void (async () => {
181
218
  let failed = false;
182
- for (const { file, name: wanted, description } of files) {
219
+ let reason = null;
220
+ for (const [index, { file, name: wanted, description }] of files.entries()) {
221
+ const queued = files.length - 1 - index;
222
+ let settled = false;
223
+ setAttaching({ state: 'busy', message: null, fraction: null, queued });
224
+ // A step the host reports after the file settled, or for a run since replaced, says nothing.
225
+ const progress = (report) => {
226
+ if (settled || !live())
227
+ return;
228
+ setAttaching({ state: 'busy', message: typeof report?.message === 'string' && report.message ? report.message : null, fraction: fractionOf(report), queued });
229
+ };
183
230
  try {
184
- const name = await attach(file, { name: wanted, description });
231
+ const name = await attach(file, { name: wanted, description }, progress);
185
232
  insertLine(`![[${name}]]`);
186
233
  }
187
- catch {
234
+ catch (rejection) {
188
235
  failed = true;
236
+ if (isAttachRefusal(rejection))
237
+ reason = rejection.message;
189
238
  }
239
+ settled = true;
190
240
  }
191
- if (editorView.current)
192
- setAttaching(failed ? 'failed' : 'idle');
241
+ if (live())
242
+ setAttaching(failed ? { ...NOT_ATTACHING, state: 'failed', message: reason } : NOT_ATTACHING);
193
243
  })();
194
244
  };
195
245
  /** A line of its own at the cursor: an embed, or an image of the web (#78). */
@@ -277,9 +327,10 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
277
327
  /** A pasted or dropped file, kept under its own name and with no description. */
278
328
  const asChosen = (files) => files.map((file) => ({ file, name: file.name, description: '' }));
279
329
  useEffect(() => {
280
- if (attaching !== 'failed')
330
+ // A failure the host explained stays until the author acts; a bare one is said for a moment.
331
+ if (attaching.state !== 'failed' || attaching.message !== null)
281
332
  return;
282
- const timer = setTimeout(() => setAttaching('idle'), ATTACH_FAILED_MS);
333
+ const timer = setTimeout(() => setAttaching(NOT_ATTACHING), ATTACH_FAILED_MS);
283
334
  return () => clearTimeout(timer);
284
335
  }, [attaching]);
285
336
  // The note as edit mode opened it, as the editor holds its text: lines joined by `\n`.
@@ -308,7 +359,7 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
308
359
  markdown(),
309
360
  EditorView.lineWrapping,
310
361
  EditorView.contentAttributes.of({ 'aria-multiline': 'true', spellcheck: 'true' }),
311
- livePage(),
362
+ livePage(measureMetrics(parent.current)),
312
363
  autocompletion({ override: [wikilinkCompletion(() => latest.current.host)], icons: false }),
313
364
  shortcuts(() => created),
314
365
  keymap.of([...markdownKeymap, ...historyKeymap, indentWithTab, ...defaultKeymap]),
@@ -453,9 +504,18 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
453
504
  clearTimeout(timer);
454
505
  };
455
506
  }, [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) => {
507
+ 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: () => {
508
+ // Opening the paperclip is the author acting on a refusal: the dialog says it once more.
509
+ if (attaching.state === 'failed') {
510
+ setRefusal(attaching.message);
511
+ setAttaching(NOT_ATTACHING);
512
+ }
513
+ else
514
+ setRefusal(null);
515
+ setAttachOpen(true);
516
+ }, attaching: attaching, attachButton: attachButton, end: _jsxs("div", { className: "edit-corner", "aria-busy": leaving && saves.state === 'saving' ? true : undefined, "data-blocked": blocked ? '' : undefined, children: [_jsx(SaveStatus, { saves: saves, blocked: blocked, host: resolved, onDecided: () => view?.focus() }), corner ?? _jsx(NoteActions, { text: () => (view ? output(view.state) : given) })] }, blocked ? `blocked-${refusals}` : 'corner') }) }), menu && view && (_jsx(ContextMenu, { x: menu.x, y: menu.y, label: resolved.strings.editor.menu, groups: menuGroups(view), onClose: (back) => {
457
517
  setMenu(null);
458
518
  if (back)
459
519
  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" })] }) }));
520
+ } })), attachOpen && (_jsx(AttachDialog, { refusal: refusal, onClose: () => setAttachOpen(false), onClosed: (inserted) => !inserted && attachButton.current?.focus(), onAttach: (files) => attachFiles.current(files), onEmbed: (name) => insertLine(`![[${name}]]`), onLink: (name, address) => insertLine(webImage(name, address)) })), lineBreak === null && _jsx("p", { className: "edit-unavailable", children: resolved.strings.editUnavailable }), _jsx("div", { ref: parent, className: "markdown-editor" })] }) }));
461
521
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * What the host said of a save, and the author's choices (#86): a modal dialog with the host's
3
+ * title, its explanation and one button per action. It never closes by itself, since its job is to
4
+ * have the author decide; Escape or its × close it and leave the save where it stood, and running
5
+ * an action closes it before the action runs.
6
+ */
7
+ import type { SaveAction, SaveNotice } from './saves.js';
8
+ export declare function SaveNoticeDialog({ notice, onClose, onClosed, onAction, }: {
9
+ notice: SaveNotice;
10
+ /** Escape or the ×: the author did not choose. */
11
+ onClose: () => void;
12
+ /** Once the dialog is closed, whether an action was chosen, so the focus goes where it belongs. */
13
+ onClosed: (chosen: boolean) => void;
14
+ onAction: (action: SaveAction) => void;
15
+ }): import("react").JSX.Element;
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * What the host said of a save, and the author's choices (#86): a modal dialog with the host's
4
+ * title, its explanation and one button per action. It never closes by itself, since its job is to
5
+ * have the author decide; Escape or its × close it and leave the save where it stood, and running
6
+ * an action closes it before the action runs.
7
+ */
8
+ import { useEffect, useId, useRef } from 'react';
9
+ import { useHost } from '../host.js';
10
+ import { ICON_CLOSE } from '../mode-icons.js';
11
+ export function SaveNoticeDialog({ notice, onClose, onClosed, onAction, }) {
12
+ const host = useHost();
13
+ const dialog = useRef(null);
14
+ const chosen = useRef(false);
15
+ const closed = useRef(onClosed);
16
+ closed.current = onClosed;
17
+ const id = useId();
18
+ useEffect(() => {
19
+ const element = dialog.current;
20
+ if (!element)
21
+ return;
22
+ if (typeof element.showModal === 'function')
23
+ element.showModal();
24
+ else
25
+ element.setAttribute('open', '');
26
+ element.querySelector('.edit-notice-action')?.focus();
27
+ return () => {
28
+ if (element.open && typeof element.close === 'function')
29
+ element.close();
30
+ // Only once the dialog is closed may the page behind it take the focus.
31
+ closed.current(chosen.current);
32
+ };
33
+ }, []);
34
+ return (_jsx("dialog", { ref: dialog, className: "edit-source edit-notice", "aria-modal": "true", "aria-labelledby": `${id}-title`, "aria-describedby": `${id}-message`, onCancel: (event) => {
35
+ event.preventDefault();
36
+ onClose();
37
+ }, children: _jsxs("div", { className: "edit-source-frame", children: [_jsxs("header", { className: "edit-source-header", children: [_jsx("h2", { id: `${id}-title`, className: "edit-source-title", children: notice.title }), _jsx("button", { type: "button", className: "edit-control edit-source-close", "aria-label": host.strings.close, title: host.strings.close, onClick: onClose, children: ICON_CLOSE })] }), _jsx("p", { id: `${id}-message`, className: "edit-notice-message", children: notice.message }), _jsx("div", { className: "edit-notice-actions", children: notice.actions.map((action, index) => (_jsx("button", { type: "button", className: "edit-attach-button edit-notice-action", onClick: () => {
38
+ chosen.current = true;
39
+ onAction(action);
40
+ }, children: action.label }, index))) })] }) }));
41
+ }
@@ -91,6 +91,7 @@ export declare const ICONS: {
91
91
  readonly saving: ReactNode;
92
92
  readonly saved: ReactNode;
93
93
  readonly notSaved: ReactNode;
94
+ readonly alert: ReactNode;
94
95
  readonly edit: ReactNode;
95
96
  readonly view: ReactNode;
96
97
  };
@@ -475,6 +475,7 @@ export const ICONS = {
475
475
  saving: icon('M21 12a9 9 0 1 1-6.219-8.56'),
476
476
  saved: icon('M20 6 9 17l-5-5'),
477
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'),
478
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'),
479
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'),
480
481
  };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How tall a drawn block will be before it is drawn (#91).
3
+ *
4
+ * CodeMirror lays out the blocks it has not drawn yet by the height their widget estimates, and
5
+ * reads that estimate once, when the block's decoration is made; a block drawn taller than its
6
+ * estimate grows the document under the scrollbar as the author scrolls to it. Each block
7
+ * estimates its height from what it draws — its lines wrapped at the editor's width, its rows, its
8
+ * properties — and a block drawn once at a width is remembered at its measured height, so the same
9
+ * block made again, moved by an edit above it or in the next edit of the note, starts right.
10
+ */
11
+ /** What an estimate reads of the editor: its text column, its line height and its characters. */
12
+ export interface BlockMetrics {
13
+ readonly width: number;
14
+ readonly lineHeight: number;
15
+ readonly charWidth: number;
16
+ }
17
+ /** What an estimate assumes before the editor has been measured, as in a test with no layout. */
18
+ export declare const DEFAULT_METRICS: BlockMetrics;
19
+ /**
20
+ * The metrics of a text column laid out in `parent`, read before the editor exists there, so the
21
+ * first estimates are made at the width the note is drawn at. Falls back to `DEFAULT_METRICS` where
22
+ * nothing is laid out.
23
+ */
24
+ export declare function measureMetrics(parent: HTMLElement): BlockMetrics;
25
+ /** Metrics as measured, any that could not be read replaced by the default. */
26
+ export declare function withinReason(metrics: BlockMetrics): BlockMetrics;
27
+ /** What a block is, for its estimate. */
28
+ export type BlockKind = 'frontmatter' | 'callout' | 'table' | 'embed' | 'diagram' | 'preview' | 'add-properties';
29
+ /** The height a block of this kind and these lines is drawn at, near enough to lay out the page. */
30
+ export declare function estimateHeight(kind: BlockKind, source: string, metrics: BlockMetrics): number;
31
+ /** The height a block of this content was drawn at at this width, if it was. */
32
+ export declare function measuredHeight(identity: string, metrics: BlockMetrics): number | undefined;
33
+ /** Remembers the height a block was drawn at. */
34
+ export declare function rememberHeight(identity: string, metrics: BlockMetrics, height: number): void;