@memorysmithapp/markdown-renderer 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
89
  modeButton="icon" // or "label", the icon and its word
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,20 @@ 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
28
  /** Whether the switch shows its icon alone or its icon and its word. Defaults to `label`. */
26
29
  readonly modeButton?: 'icon' | 'label';
30
+ /**
31
+ * Whether the note offers Show Markdown and Copy Markdown at its top right, editable or not:
32
+ * reading a note's Markdown and copying it are reading (#87). Defaults to `true`; `false` hides
33
+ * them, for a print, a preview or an embed.
34
+ */
35
+ readonly actions?: boolean;
27
36
  }
28
37
  /** Whether a note is read or edited. */
29
38
  export type NoteMode = 'view' | 'edit';
@@ -33,4 +42,4 @@ export type NoteMode = 'view' | 'edit';
33
42
  * It renders inside `.markdown`, so the stylesheet of the package applies, and asks the host for
34
43
  * every piece of notebook data through the props of `NoteHost`.
35
44
  */
36
- export declare function NoteBody({ editable, mode, defaultMode, onModeChange, onChange, modeButton, ...rest }: NoteBodyProps): import("react").JSX.Element;
45
+ 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 = 'label', 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,7 +14,7 @@ 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';
@@ -24,6 +24,7 @@ import { AttachDialog, webImage } from './AttachDialog.js';
24
24
  import { ContextMenu } from './ContextMenu.js';
25
25
  import { chooseLanguageAt } from './LanguageControl.js';
26
26
  import { useEditSaves } from './saves.js';
27
+ import { SaveNoticeDialog } from './SaveNoticeDialog.js';
27
28
  /**
28
29
  * The line break a note is written with, which the editor writes back: `\n` or `\r\n`. `null`
29
30
  * for a note that mixes them, or has a lone `\r`, which the editor cannot keep byte for byte.
@@ -110,8 +111,23 @@ function insertBlockCommand(name) {
110
111
  return true;
111
112
  };
112
113
  }
113
- /** How long the attach button says a file was not attached, in milliseconds. */
114
+ const NOT_ATTACHING = { state: 'idle', message: null, fraction: null, queued: 0 };
115
+ /** How long the attach button says a file was not attached, when the host did not say why, in milliseconds. */
114
116
  const ATTACH_FAILED_MS = 3000;
117
+ /** A host's fraction, kept between 0 and 1, or none when it gave no number. */
118
+ function fractionOf(report) {
119
+ const fraction = report?.fraction;
120
+ return typeof fraction === 'number' && Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : null;
121
+ }
122
+ /** What the paperclip says: its word, the host's step and the files waiting, or why the last one failed. */
123
+ function attachSays(attaching, words) {
124
+ if (attaching.state === 'failed')
125
+ return attaching.message ?? words.attachFailed;
126
+ if (attaching.state === 'idle')
127
+ return words.attach;
128
+ const step = attaching.message ?? words.attaching;
129
+ return attaching.queued > 0 ? `${step} (${words.attachQueued(attaching.queued)})` : step;
130
+ }
115
131
  /** The toolbar of the editor: the marks, the blocks and the history, each a command. */
116
132
  function Toolbar({ view, host, end, opened, onAttach, attaching, attachButton, }) {
117
133
  const words = host.strings.editor;
@@ -120,21 +136,35 @@ function Toolbar({ view, host, end, opened, onAttach, attaching, attachButton, }
120
136
  const button = (name, action, shortcut) => (_jsx("button", { type: "button", className: "edit-control", title: shortcut ? `${words[name]} (${shortcut})` : words[name], "aria-label": words[name], disabled: disabled, onMouseDown: (event) => event.preventDefault(), onClick: run(action), children: ICONS[name] }, name));
121
137
  const block = (name) => button(name, insertBlockCommand(name));
122
138
  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] }));
139
+ return (_jsxs("div", { className: "edit-toolbar", role: "toolbar", "aria-label": words.toolbar, children: [_jsxs("div", { className: "edit-toolbar-tools", children: [_jsxs("span", { className: "edit-toolbar-group", children: [_jsx("button", { type: "button", className: "edit-control", title: words.revert, "aria-label": words.revert, disabled: !view || view.state.readOnly || view.state.doc.toString() === opened, onMouseDown: (event) => event.preventDefault(), onClick: run(command((state) => revert(state, opened))), children: ICONS.revert }), button('undo', undo, 'Mod-Z'), button('redo', redo, 'Mod-Shift-Z')] }), _jsx("span", { className: "edit-toolbar-group", children: _jsxs("select", { className: "edit-heading", "aria-label": words.heading, title: words.heading, value: level, disabled: disabled, onChange: (event) => view && command((state) => setHeading(state, Number(event.target.value)))(view), children: [_jsx("option", { value: 0, children: words.paragraph }), [1, 2, 3, 4, 5, 6].map((each) => (_jsx("option", { value: each, children: `${words.heading} ${each}` }, each)))] }) }), _jsxs("span", { className: "edit-toolbar-group", children: [button('bold', command((state) => toggleMark(state, '**')), 'Mod-B'), button('italic', command((state) => toggleMark(state, '_')), 'Mod-I'), button('strikethrough', command((state) => toggleMark(state, '~~')), 'Mod-Shift-X'), button('highlight', command((state) => toggleMark(state, '==')), 'Mod-Shift-H'), button('code', command((state) => toggleMark(state, '`')), 'Mod-E')] }), _jsxs("span", { className: "edit-toolbar-group", children: [button('link', command(insertLink), 'Mod-K'), button('wikilink', command(insertWikilink), 'Mod-Shift-K'), button('footnote', command(insertFootnote)), button('comment', command(toggleComment), 'Mod-/'), _jsxs("span", { className: "edit-attach-control", children: [_jsx("button", { ref: attachButton, type: "button", className: "edit-control", title: attachSays(attaching, words), "aria-label": words.attach, "aria-busy": attaching.state === 'busy' ? true : undefined, "data-attach": attaching.state, disabled: disabled, onMouseDown: (event) => event.preventDefault(), onClick: onAttach, children: attaching.state === 'busy' ? ICONS.saving : attaching.state === 'failed' ? ICONS.notSaved : ICONS.attach }), attaching.state === 'busy' && attaching.fraction !== null && (_jsx("span", { className: "edit-attach-progress", role: "progressbar", "aria-label": words.attach, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.round(attaching.fraction * 100), style: { ['--edit-attach-fraction']: attaching.fraction } })), _jsx("span", { className: "visually-hidden", role: "status", "aria-live": "polite", children: attaching.state === 'idle' ? '' : attachSays(attaching, words) })] })] }), _jsxs("span", { className: "edit-toolbar-group", children: [button('bulletList', command((state) => toggleList(state, 'bullet')), 'Mod-Shift-8'), button('orderedList', command((state) => toggleList(state, 'ordered')), 'Mod-Shift-7'), button('taskList', command((state) => toggleList(state, 'task')), 'Mod-Shift-9'), button('quote', command(toggleQuote), 'Mod-Shift-.')] }), _jsxs("span", { className: "edit-toolbar-group", children: [block('callout'), block('table'), block('codeBlock'), block('math'), block('diagram'), block('rule')] })] }), end] }));
124
140
  }
125
141
  /**
126
142
  * Where the save of the author's last change stands, in the toolbar before the switch: an icon for
127
143
  * 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.
144
+ * its title. A failed save's icon is the button that tries again (#66). When the host said why —
145
+ * an alert on a save that succeeded, or the reason for one it refused — the icon is the button that
146
+ * opens what it said, with the host's title as its tooltip, and the author's choices (#86). Whoever
147
+ * hears the page is told the whole of it, why the note stays in edit included.
130
148
  */
131
- function SaveStatus({ state, blocked, retry, host }) {
149
+ function SaveStatus({ saves, blocked, host, onDecided }) {
150
+ const { state, notice } = saves;
132
151
  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;
152
+ const [open, setOpen] = useState(false);
153
+ const button = useRef(null);
154
+ // A notice the save no longer carries has nothing left to show.
155
+ useEffect(() => {
156
+ if (!notice)
157
+ setOpen(false);
158
+ }, [notice]);
159
+ const base = state === 'saving' ? words.saving : state === 'saved' ? host.strings.saved : state === 'failed' ? host.strings.notSaved : '';
160
+ const shown = notice ? notice.title : base;
161
+ const said = state === 'failed' && blocked ? (notice ? `${words.stayInEdit}. ${notice.title}` : words.stayInEdit) : shown;
162
+ const icon = state === 'saving' ? ICONS.saving : state === 'saved' ? ICONS.saved : state === 'alert' ? ICONS.alert : state === 'failed' ? ICONS.notSaved : null;
136
163
  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 })] }));
164
+ return (_jsxs("div", { className: "edit-save", "data-save": state, title: state === 'failed' || notice ? undefined : said || undefined, children: [notice ? (_jsx("button", { ref: button, type: "button", className: "edit-save-icon edit-save-notice", title: notice.title, "aria-label": notice.title, "aria-haspopup": "dialog", onClick: () => setOpen(true), children: icon })) : state === 'failed' ? (_jsx("button", { type: "button", className: "edit-save-icon edit-save-retry", title: retryLabel, "aria-label": retryLabel, onClick: saves.retry, children: icon })) : (_jsx("span", { className: "edit-save-icon", children: icon })), _jsx("span", { className: "visually-hidden", role: "status", "aria-live": "polite", children: said }), open && notice && (_jsx(SaveNoticeDialog, { notice: notice, onClose: () => setOpen(false), onClosed: (chosen) => (chosen ? onDecided() : button.current?.focus()), onAction: (action) => {
165
+ setOpen(false);
166
+ saves.run(action);
167
+ } }))] }));
138
168
  }
139
169
  /**
140
170
  * Edits a note on the page that draws it. Exported at `@memorysmithapp/markdown-renderer/edit`
@@ -162,11 +192,16 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
162
192
  const [blocked, setBlocked] = useState(false);
163
193
  const [refusals, setRefusals] = useState(0);
164
194
  const editorView = useRef(null);
165
- const [attaching, setAttaching] = useState('idle');
195
+ const [attaching, setAttaching] = useState(NOT_ATTACHING);
196
+ // Each run of attaching, so that a run a later one replaced says nothing more.
197
+ const attachRun = useRef(0);
198
+ // The refusal the paperclip said, shown again at the top of the dialog it opens (#88).
199
+ const [refusal, setRefusal] = useState(null);
166
200
  /**
167
201
  * Hands each file to the host's `attach`, one after the other, and embeds it as `![[name]]` on a
168
202
  * 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).
203
+ * keep writes nothing, and the button says so for a moment (#75) — or, when the host said why,
204
+ * until the author opens it again. The host's steps are shown as they come (#88).
170
205
  */
171
206
  const attachFiles = useRef(() => undefined);
172
207
  attachFiles.current = (files, at) => {
@@ -176,20 +211,34 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
176
211
  return;
177
212
  if (at !== undefined)
178
213
  target.dispatch({ selection: { anchor: at } });
179
- setAttaching('busy');
214
+ const run = ++attachRun.current;
215
+ const live = () => run === attachRun.current && editorView.current !== null;
180
216
  void (async () => {
181
217
  let failed = false;
182
- for (const { file, name: wanted, description } of files) {
218
+ let reason = null;
219
+ for (const [index, { file, name: wanted, description }] of files.entries()) {
220
+ const queued = files.length - 1 - index;
221
+ let settled = false;
222
+ setAttaching({ state: 'busy', message: null, fraction: null, queued });
223
+ // A step the host reports after the file settled, or for a run since replaced, says nothing.
224
+ const progress = (report) => {
225
+ if (settled || !live())
226
+ return;
227
+ setAttaching({ state: 'busy', message: typeof report?.message === 'string' && report.message ? report.message : null, fraction: fractionOf(report), queued });
228
+ };
183
229
  try {
184
- const name = await attach(file, { name: wanted, description });
230
+ const name = await attach(file, { name: wanted, description }, progress);
185
231
  insertLine(`![[${name}]]`);
186
232
  }
187
- catch {
233
+ catch (rejection) {
188
234
  failed = true;
235
+ if (isAttachRefusal(rejection))
236
+ reason = rejection.message;
189
237
  }
238
+ settled = true;
190
239
  }
191
- if (editorView.current)
192
- setAttaching(failed ? 'failed' : 'idle');
240
+ if (live())
241
+ setAttaching(failed ? { ...NOT_ATTACHING, state: 'failed', message: reason } : NOT_ATTACHING);
193
242
  })();
194
243
  };
195
244
  /** A line of its own at the cursor: an embed, or an image of the web (#78). */
@@ -277,9 +326,10 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
277
326
  /** A pasted or dropped file, kept under its own name and with no description. */
278
327
  const asChosen = (files) => files.map((file) => ({ file, name: file.name, description: '' }));
279
328
  useEffect(() => {
280
- if (attaching !== 'failed')
329
+ // A failure the host explained stays until the author acts; a bare one is said for a moment.
330
+ if (attaching.state !== 'failed' || attaching.message !== null)
281
331
  return;
282
- const timer = setTimeout(() => setAttaching('idle'), ATTACH_FAILED_MS);
332
+ const timer = setTimeout(() => setAttaching(NOT_ATTACHING), ATTACH_FAILED_MS);
283
333
  return () => clearTimeout(timer);
284
334
  }, [attaching]);
285
335
  // The note as edit mode opened it, as the editor holds its text: lines joined by `\n`.
@@ -453,9 +503,18 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
453
503
  clearTimeout(timer);
454
504
  };
455
505
  }, [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) => {
506
+ return (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: `markdown markdown-theme-${resolved.theme.name} markdown-editing`, "data-theme": resolved.scheme, style: rootStyle(resolved.theme, resolved.scheme), children: [_jsx("div", { ref: bar, className: "edit-bar", "data-leaving": folding ? '' : undefined, children: _jsx(Toolbar, { view: view, host: resolved, opened: opened.current, onAttach: () => {
507
+ // Opening the paperclip is the author acting on a refusal: the dialog says it once more.
508
+ if (attaching.state === 'failed') {
509
+ setRefusal(attaching.message);
510
+ setAttaching(NOT_ATTACHING);
511
+ }
512
+ else
513
+ setRefusal(null);
514
+ setAttachOpen(true);
515
+ }, attaching: attaching, attachButton: attachButton, end: _jsxs("div", { className: "edit-corner", "aria-busy": leaving && saves.state === 'saving' ? true : undefined, "data-blocked": blocked ? '' : undefined, children: [_jsx(SaveStatus, { saves: saves, blocked: blocked, host: resolved, onDecided: () => view?.focus() }), corner ?? _jsx(NoteActions, { text: () => (view ? output(view.state) : given) })] }, blocked ? `blocked-${refusals}` : 'corner') }) }), menu && view && (_jsx(ContextMenu, { x: menu.x, y: menu.y, label: resolved.strings.editor.menu, groups: menuGroups(view), onClose: (back) => {
457
516
  setMenu(null);
458
517
  if (back)
459
518
  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" })] }) }));
519
+ } })), attachOpen && (_jsx(AttachDialog, { refusal: refusal, onClose: () => setAttachOpen(false), onClosed: (inserted) => !inserted && attachButton.current?.focus(), onAttach: (files) => attachFiles.current(files), onEmbed: (name) => insertLine(`![[${name}]]`), onLink: (name, address) => insertLine(webImage(name, address)) })), lineBreak === null && _jsx("p", { className: "edit-unavailable", children: resolved.strings.editUnavailable }), _jsx("div", { ref: parent, className: "markdown-editor" })] }) }));
461
520
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * What the host said of a save, and the author's choices (#86): a modal dialog with the host's
3
+ * title, its explanation and one button per action. It never closes by itself, since its job is to
4
+ * have the author decide; Escape or its × close it and leave the save where it stood, and running
5
+ * an action closes it before the action runs.
6
+ */
7
+ import type { SaveAction, SaveNotice } from './saves.js';
8
+ export declare function SaveNoticeDialog({ notice, onClose, onClosed, onAction, }: {
9
+ notice: SaveNotice;
10
+ /** Escape or the ×: the author did not choose. */
11
+ onClose: () => void;
12
+ /** Once the dialog is closed, whether an action was chosen, so the focus goes where it belongs. */
13
+ onClosed: (chosen: boolean) => void;
14
+ onAction: (action: SaveAction) => void;
15
+ }): import("react").JSX.Element;
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * What the host said of a save, and the author's choices (#86): a modal dialog with the host's
4
+ * title, its explanation and one button per action. It never closes by itself, since its job is to
5
+ * have the author decide; Escape or its × close it and leave the save where it stood, and running
6
+ * an action closes it before the action runs.
7
+ */
8
+ import { useEffect, useId, useRef } from 'react';
9
+ import { useHost } from '../host.js';
10
+ import { ICON_CLOSE } from '../mode-icons.js';
11
+ export function SaveNoticeDialog({ notice, onClose, onClosed, onAction, }) {
12
+ const host = useHost();
13
+ const dialog = useRef(null);
14
+ const chosen = useRef(false);
15
+ const closed = useRef(onClosed);
16
+ closed.current = onClosed;
17
+ const id = useId();
18
+ useEffect(() => {
19
+ const element = dialog.current;
20
+ if (!element)
21
+ return;
22
+ if (typeof element.showModal === 'function')
23
+ element.showModal();
24
+ else
25
+ element.setAttribute('open', '');
26
+ element.querySelector('.edit-notice-action')?.focus();
27
+ return () => {
28
+ if (element.open && typeof element.close === 'function')
29
+ element.close();
30
+ // Only once the dialog is closed may the page behind it take the focus.
31
+ closed.current(chosen.current);
32
+ };
33
+ }, []);
34
+ return (_jsx("dialog", { ref: dialog, className: "edit-source edit-notice", "aria-modal": "true", "aria-labelledby": `${id}-title`, "aria-describedby": `${id}-message`, onCancel: (event) => {
35
+ event.preventDefault();
36
+ onClose();
37
+ }, children: _jsxs("div", { className: "edit-source-frame", children: [_jsxs("header", { className: "edit-source-header", children: [_jsx("h2", { id: `${id}-title`, className: "edit-source-title", children: notice.title }), _jsx("button", { type: "button", className: "edit-control edit-source-close", "aria-label": host.strings.close, title: host.strings.close, onClick: onClose, children: ICON_CLOSE })] }), _jsx("p", { id: `${id}-message`, className: "edit-notice-message", children: notice.message }), _jsx("div", { className: "edit-notice-actions", children: notice.actions.map((action, index) => (_jsx("button", { type: "button", className: "edit-attach-button edit-notice-action", onClick: () => {
38
+ chosen.current = true;
39
+ onAction(action);
40
+ }, children: action.label }, index))) })] }) }));
41
+ }
@@ -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
  };
@@ -2,3 +2,4 @@ export { lineBreakOf, NoteEditor, type NoteEditorProps } from './NoteEditor.js';
2
2
  export { analyze, frontmatterSpan, type Analysis, type Construct, type Span } from './analyze.js';
3
3
  export { readTable, type Alignment, type Table, type TextChange } from './table.js';
4
4
  export { readFrontmatter, type Frontmatter, type FrontmatterEntry } from './frontmatter.js';
5
+ export { isSaveNotice, type SaveAction, type SaveNotice, type SaveOutcome, type SaveRefusal } from './saves.js';
@@ -4,3 +4,4 @@ export { lineBreakOf, NoteEditor } from './NoteEditor.js';
4
4
  export { analyze, frontmatterSpan } from './analyze.js';
5
5
  export { readTable } from './table.js';
6
6
  export { readFrontmatter } from './frontmatter.js';
7
+ export { isSaveNotice } from './saves.js';
@@ -1,18 +1,48 @@
1
1
  /**
2
- * Where the save of the author's edits stands (#64).
2
+ * Where the save of the author's edits stands (#64, #86).
3
3
  *
4
4
  * The host saves each change it is handed. When `onChange` answers with a promise, the editor
5
5
  * follows it: saving while it is pending, saved once it resolves, not saved when it rejects. Only
6
6
  * the latest change counts: a save that settles after a later change was handed says nothing, so
7
7
  * "Saved" always means the last text the author wrote. A host that answers with nothing has no
8
8
  * status, and the editor shows none.
9
+ *
10
+ * A host may also say why (#86): a save that resolves with a `SaveOutcome` succeeded with
11
+ * something the author must decide, the alert; one rejected with a `SaveRefusal` was refused for
12
+ * a reason the host explains, the error. Either carries the actions the author chooses from, and
13
+ * an action the author runs is followed like a save.
9
14
  */
10
- export type EditSave = 'idle' | 'saving' | 'saved' | 'failed';
15
+ /** One choice the host offers the author: its word, and what choosing it does. */
16
+ export interface SaveAction {
17
+ /** The button's word, in the reader's language: the host words it. */
18
+ readonly label: string;
19
+ /** Runs the choice; a promise answered is followed like a save, and may end in another notice. */
20
+ readonly run: () => Promise<void | SaveOutcome> | void;
21
+ }
22
+ /** What the host has to say about a save, and the choices it gives the author. */
23
+ export interface SaveNotice {
24
+ /** The dialog's title, and the status button's tooltip. */
25
+ readonly title: string;
26
+ /** The explanation in the dialog. */
27
+ readonly message: string;
28
+ readonly actions: readonly SaveAction[];
29
+ }
30
+ /** A save that succeeded with something the author must decide: what `onChange`'s promise resolves with. */
31
+ export type SaveOutcome = SaveNotice;
32
+ /** A save the host refused, with its reason and its choices: what `onChange`'s promise rejects with. */
33
+ export type SaveRefusal = SaveNotice;
34
+ export type EditSave = 'idle' | 'saving' | 'saved' | 'alert' | 'failed';
11
35
  export interface EditSaves {
12
36
  readonly state: EditSave;
37
+ /** What the host said of the save: present in `alert`, and in `failed` when it gave a reason. */
38
+ readonly notice: SaveNotice | null;
13
39
  /** Hands a change to the host and follows its save. */
14
40
  readonly hand: (markdown: string) => void;
15
41
  /** Hands the latest change to the host again, after a save failed. */
16
42
  readonly retry: () => void;
43
+ /** Runs one of the notice's actions and follows it like a save. */
44
+ readonly run: (action: SaveAction) => void;
17
45
  }
46
+ /** Whether a value is a notice a host gave: a title, a message and its actions. */
47
+ export declare function isSaveNotice(value: unknown): value is SaveNotice;
18
48
  export declare function useEditSaves(onChange: ((markdown: string) => unknown) | undefined): EditSaves;
@@ -1,16 +1,31 @@
1
1
  /**
2
- * Where the save of the author's edits stands (#64).
2
+ * Where the save of the author's edits stands (#64, #86).
3
3
  *
4
4
  * The host saves each change it is handed. When `onChange` answers with a promise, the editor
5
5
  * follows it: saving while it is pending, saved once it resolves, not saved when it rejects. Only
6
6
  * the latest change counts: a save that settles after a later change was handed says nothing, so
7
7
  * "Saved" always means the last text the author wrote. A host that answers with nothing has no
8
8
  * status, and the editor shows none.
9
+ *
10
+ * A host may also say why (#86): a save that resolves with a `SaveOutcome` succeeded with
11
+ * something the author must decide, the alert; one rejected with a `SaveRefusal` was refused for
12
+ * a reason the host explains, the error. Either carries the actions the author chooses from, and
13
+ * an action the author runs is followed like a save.
9
14
  */
10
15
  import { useCallback, useEffect, useRef, useState } from 'react';
11
16
  import { isThenable } from '../task-saves.js';
17
+ /** Whether a value is a notice a host gave: a title, a message and its actions. */
18
+ export function isSaveNotice(value) {
19
+ if (typeof value !== 'object' || value === null)
20
+ return false;
21
+ const notice = value;
22
+ return (typeof notice.title === 'string' &&
23
+ typeof notice.message === 'string' &&
24
+ Array.isArray(notice.actions) &&
25
+ notice.actions.every((action) => typeof action === 'object' && action !== null && typeof action.label === 'string' && typeof action.run === 'function'));
26
+ }
12
27
  export function useEditSaves(onChange) {
13
- const [state, setState] = useState('idle');
28
+ const [status, setStatus] = useState({ state: 'idle', notice: null });
14
29
  const latest = useRef({ onChange, count: 0, text: null, alive: true });
15
30
  latest.current.onChange = onChange;
16
31
  useEffect(() => {
@@ -20,33 +35,39 @@ export function useEditSaves(onChange) {
20
35
  self.alive = false;
21
36
  };
22
37
  }, []);
23
- const hand = useCallback((markdown) => {
38
+ /** Follows what the host answered, a change's save or an action; `done` is the state of an answer that is not a promise. */
39
+ const follow = useCallback((answer, done) => {
24
40
  const self = latest.current;
25
- self.text = markdown;
26
41
  const count = ++self.count;
27
- const settle = (next) => {
42
+ const settle = (state, notice = null) => {
28
43
  if (self.alive && count === self.count)
29
- setState(next);
44
+ setStatus({ state, notice });
30
45
  };
31
46
  let result;
32
47
  try {
33
- result = self.onChange?.(markdown);
48
+ result = answer();
34
49
  }
35
- catch {
36
- settle('failed');
50
+ catch (reason) {
51
+ settle('failed', isSaveNotice(reason) ? reason : null);
37
52
  return;
38
53
  }
39
54
  if (!isThenable(result)) {
40
- settle('idle');
55
+ settle(done);
41
56
  return;
42
57
  }
43
58
  settle('saving');
44
- result.then(() => settle('saved'), () => settle('failed'));
59
+ result.then((value) => (isSaveNotice(value) ? settle('alert', value) : settle('saved')), (reason) => settle('failed', isSaveNotice(reason) ? reason : null));
45
60
  }, []);
61
+ const hand = useCallback((markdown) => {
62
+ latest.current.text = markdown;
63
+ follow(() => latest.current.onChange?.(markdown), 'idle');
64
+ }, [follow]);
46
65
  const retry = useCallback(() => {
47
66
  const text = latest.current.text;
48
67
  if (text !== null)
49
68
  hand(text);
50
69
  }, [hand]);
51
- return { state, hand, retry };
70
+ // An action that answers nothing has done what it does: the author decided, and nothing is left to say.
71
+ const run = useCallback((action) => follow(() => action.run(), 'saved'), [follow]);
72
+ return { state: status.state, notice: status.notice, hand, retry, run };
52
73
  }
package/dist/host.d.ts CHANGED
@@ -71,8 +71,13 @@ export interface NoteHost {
71
71
  * has its own name and none), and answers the name it is kept under, which the editor embeds as
72
72
  * `![[name]]` (#75, #78). The package never stores a file. Without it, the editor offers no file
73
73
  * from the computer.
74
+ *
75
+ * While it keeps the file, the host may report each step through `progress`, which the paperclip
76
+ * shows and announces; a promise rejected with an `AttachRefusal` has the paperclip say why until
77
+ * the author acts (#88). A host that ignores `progress` and rejects with an error keeps the
78
+ * paperclip's own words.
74
79
  */
75
- readonly attach?: (file: File, attributes: AttachAttributes) => string | PromiseLike<string>;
80
+ readonly attach?: (file: File, attributes: AttachAttributes, progress: (report: AttachProgress) => void) => string | PromiseLike<string>;
76
81
  /**
77
82
  * The files the notebook keeps, with their names, which the attach dialog lists for the author to
78
83
  * embed one (#78). Without it, the dialog offers no file of the notebook.
@@ -155,6 +160,23 @@ export interface KeptFile {
155
160
  export interface KeptFileEntry extends KeptFile {
156
161
  readonly name: string;
157
162
  }
163
+ /** A step of keeping a file, as the host words it (#88). */
164
+ export interface AttachProgress {
165
+ /** What is happening, in the reader's language: "Sending part 12 of 40". */
166
+ readonly message: string;
167
+ /** How much is done, from 0 to 1, when the host knows it: drawn as a determinate indicator. */
168
+ readonly fraction?: number;
169
+ }
170
+ /**
171
+ * Why the host would not keep a file, what `attach` rejects with (#88): a plain object, so that an
172
+ * `Error`, whose message is meant for a developer, keeps the paperclip's own words.
173
+ */
174
+ export interface AttachRefusal {
175
+ /** Why, in the reader's language: "An attached file goes up to 100 MB". */
176
+ readonly message: string;
177
+ }
178
+ /** Whether a rejection is a refusal the host worded for the author, and not an error. */
179
+ export declare function isAttachRefusal(value: unknown): value is AttachRefusal;
158
180
  /** What the author said of a file they attach (#78). */
159
181
  export interface AttachAttributes {
160
182
  /** The name to keep it under; the host may answer another, when that one is taken. */
@@ -188,7 +210,7 @@ export interface ResolvedHost {
188
210
  readonly loadNote: ((name: string) => Promise<EmbeddedNote | null>) | null;
189
211
  readonly keeps: (name: string) => boolean;
190
212
  readonly suggest: ((query: string) => readonly string[] | PromiseLike<readonly string[]>) | null;
191
- readonly attach: ((file: File, attributes: AttachAttributes) => string | PromiseLike<string>) | null;
213
+ readonly attach: ((file: File, attributes: AttachAttributes, progress: (report: AttachProgress) => void) => string | PromiseLike<string>) | null;
192
214
  readonly files: (() => readonly KeptFileEntry[] | PromiseLike<readonly KeptFileEntry[]>) | null;
193
215
  readonly fileOf: ((name: string) => KeptFile | null | PromiseLike<KeptFile | null>) | null;
194
216
  readonly renderAttachment: ((file: AttachmentRequest) => ReactNode) | null;
package/dist/host.js CHANGED
@@ -2,6 +2,14 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useContext } from 'react';
3
3
  import { withDefaults } from './strings.js';
4
4
  import { DEFAULT_THEME, schemeOf } from './theme.js';
5
+ /** Whether a rejection is a refusal the host worded for the author, and not an error. */
6
+ export function isAttachRefusal(value) {
7
+ return (typeof value === 'object' &&
8
+ value !== null &&
9
+ !(value instanceof Error) &&
10
+ typeof value.message === 'string' &&
11
+ value.message.length > 0);
12
+ }
5
13
  /**
6
14
  * Every member of the contract, by name. The `satisfies` is what makes it complete: a member added
7
15
  * to `NoteHost` and not listed here does not compile.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { NoteBody, type NoteBodyProps, type NoteMode } from './NoteBody.js';
2
- export type { AttachAttributes, AttachmentRequest, EmbeddedNote, KeptFile, KeptFileEntry, LinkChoiceNote, LinkChoices, LinkRequest, NoteHost } from './host.js';
2
+ export type { SaveAction, SaveNotice, SaveOutcome, SaveRefusal } from './edit/saves.js';
3
+ export { isAttachRefusal } from './host.js';
4
+ export type { AttachAttributes, AttachmentRequest, AttachProgress, AttachRefusal, EmbeddedNote, KeptFile, KeptFileEntry, LinkChoiceNote, LinkChoices, LinkRequest, NoteHost } from './host.js';
3
5
  export { ENGLISH, type EditorWords, type NoteStrings, type NoteStringsInput } from './strings.js';
4
6
  export { customPropertyOf, DEFAULT_THEME, defineTheme, THEME_PROPERTIES, THEME_SLOTS, type BlockquoteSlotProps, type CalloutSlotProps, type CodeBlockSlotProps, type DiagramSlotProps, type EmbedSlotProps, type FootnotesSlotProps, type HeadingSlotProps, type InlineCodeSlotProps, type ModeSwitchSlotProps, type AttachmentSlotProps, type PropertiesSlotProps, type Scheme, type TableSlotProps, type TaskBoxSlotProps, type Theme, type ThemeComponents, type ThemeProperties, type WikilinkSlotProps, } from './theme.js';
5
7
  export { followable } from './address.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // The entry point of @memorysmithapp/markdown-renderer.
2
2
  export { NoteBody } from './NoteBody.js';
3
+ export { isAttachRefusal } from './host.js';
3
4
  export { ENGLISH } from './strings.js';
4
5
  export { customPropertyOf, DEFAULT_THEME, defineTheme, THEME_PROPERTIES, THEME_SLOTS, } from './theme.js';
5
6
  export { followable } from './address.js';
package/dist/strings.d.ts CHANGED
@@ -107,6 +107,8 @@ export interface EditorWords {
107
107
  readonly attach: string;
108
108
  readonly attaching: string;
109
109
  readonly attachFailed: string;
110
+ /** Said after the host's step while more files wait their turn (#88). */
111
+ readonly attachQueued: (count: number) => string;
110
112
  /** A code block's language, chosen at its top right (#84). */
111
113
  readonly language: string;
112
114
  readonly plainText: string;
package/dist/strings.js CHANGED
@@ -112,6 +112,7 @@ export const ENGLISH = {
112
112
  attach: 'Attach a file',
113
113
  attaching: 'Attaching…',
114
114
  attachFailed: 'The file was not attached',
115
+ attachQueued: (count) => (count === 1 ? '1 more file waiting' : `${count} more files waiting`),
115
116
  language: 'Language',
116
117
  plainText: 'Plain text',
117
118
  mermaidDiagram: 'Mermaid diagram',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorysmithapp/markdown-renderer",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A React component that draws a MemorySmith note exactly as MemorySmith's reading page does.",
5
5
  "keywords": [
6
6
  "markdown",
@@ -79,7 +79,7 @@
79
79
  "unified": "11.0.5"
80
80
  },
81
81
  "devDependencies": {
82
- "@memorysmithapp/markdown-spec": "0.4.0",
82
+ "@memorysmithapp/markdown-spec": "0.5.0",
83
83
  "@types/hast": "3.0.5",
84
84
  "@types/react": "19.2.18",
85
85
  "@types/react-dom": "19.2.4",
package/structure.css CHANGED
@@ -233,6 +233,17 @@
233
233
  /* The attach dialog (#78): its ways in as tabs, a panel that alone scrolls,
234
234
  and the files of the notebook as a list of buttons. */
235
235
  /* One height for every way in, so the dialog does not jump between them. */
236
+ /* The paperclip and, under it, the bar of the host's fraction (#88). */
237
+ .markdown .edit-attach-control {
238
+ position: relative;
239
+ display: inline-flex;
240
+ }
241
+ .markdown .edit-attach-progress {
242
+ position: absolute;
243
+ inset-inline: 0.2rem;
244
+ bottom: 0;
245
+ pointer-events: none;
246
+ }
236
247
  .markdown .edit-attach .edit-source-frame {
237
248
  height: min(34rem, calc(100vh - 4rem));
238
249
  }
@@ -291,6 +302,19 @@
291
302
  display: flex;
292
303
  justify-content: flex-end;
293
304
  }
305
+ /* What the host said of a save (#86): its title, its explanation, and its
306
+ choices in a row that wraps, as narrow as its words. */
307
+ .markdown .edit-notice {
308
+ max-width: min(32rem, calc(100vw - 2rem));
309
+ }
310
+ .markdown .edit-notice-message {
311
+ margin: 0;
312
+ }
313
+ .markdown .edit-notice-actions {
314
+ display: flex;
315
+ flex-wrap: wrap;
316
+ justify-content: flex-end;
317
+ }
294
318
  @media (max-width: 560px) {
295
319
  .markdown .edit-attach-file {
296
320
  grid-template-columns: 1fr auto;
package/theme-default.css CHANGED
@@ -1298,6 +1298,9 @@
1298
1298
  .markdown .edit-save[data-save='failed'] {
1299
1299
  color: rgb(var(--ms-callout-stop));
1300
1300
  }
1301
+ .markdown .edit-save[data-save='alert'] {
1302
+ color: rgb(var(--ms-callout-warn));
1303
+ }
1301
1304
  /* Asked to leave while the last save failed: the corner shakes once. */
1302
1305
  .markdown .edit-corner[data-blocked] {
1303
1306
  animation: markdown-corner-refuse 360ms ease-in-out;
@@ -1322,6 +1325,14 @@
1322
1325
  .markdown .edit-control[data-attach='failed'] {
1323
1326
  color: rgb(var(--ms-callout-stop));
1324
1327
  }
1328
+ .markdown .edit-attach-progress {
1329
+ height: 2px;
1330
+ border-radius: 1px;
1331
+ background: linear-gradient(to right, var(--ms-accent) calc(var(--edit-attach-fraction, 0) * 100%), var(--ms-border) 0);
1332
+ }
1333
+ .markdown .edit-attach-refusal {
1334
+ padding: 0.6rem 1rem 0;
1335
+ }
1325
1336
  @media (prefers-reduced-motion: reduce) {
1326
1337
  .markdown .edit-control[data-attach='busy'] > svg {
1327
1338
  animation: none;
@@ -1332,7 +1343,8 @@
1332
1343
  transform: rotate(360deg);
1333
1344
  }
1334
1345
  }
1335
- .markdown .edit-save-retry {
1346
+ .markdown .edit-save-retry,
1347
+ .markdown .edit-save-notice {
1336
1348
  padding: 0;
1337
1349
  border: 0;
1338
1350
  border-radius: calc(var(--ms-radius) - 2px);
@@ -1340,10 +1352,12 @@
1340
1352
  color: inherit;
1341
1353
  cursor: pointer;
1342
1354
  }
1343
- .markdown .edit-save-retry:hover {
1355
+ .markdown .edit-save-retry:hover,
1356
+ .markdown .edit-save-notice:hover {
1344
1357
  background: var(--ms-code-bg);
1345
1358
  }
1346
- .markdown .edit-save-retry:focus-visible {
1359
+ .markdown .edit-save-retry:focus-visible,
1360
+ .markdown .edit-save-notice:focus-visible {
1347
1361
  outline: 2px solid var(--ms-accent);
1348
1362
  outline-offset: 2px;
1349
1363
  }
@@ -1637,6 +1651,19 @@
1637
1651
  font-size: 1rem;
1638
1652
  font-weight: 600;
1639
1653
  }
1654
+ /* What the host said of a save: the dialog's own size, whatever the toolbar it opens from. */
1655
+ .markdown .edit-notice {
1656
+ font-family: var(--ms-font-sans);
1657
+ font-size: 0.95rem;
1658
+ line-height: 1.55;
1659
+ }
1660
+ .markdown .edit-notice-message {
1661
+ padding: 1rem 1rem 0;
1662
+ }
1663
+ .markdown .edit-notice-actions {
1664
+ gap: 0.5rem;
1665
+ padding: 1rem;
1666
+ }
1640
1667
  .markdown .edit-source-copied {
1641
1668
  color: var(--ms-text-soft);
1642
1669
  font-size: 0.8rem;