@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 +21 -3
- package/dist/NoteBody.d.ts +12 -3
- package/dist/NoteBody.js +9 -8
- package/dist/edit/AttachDialog.d.ts +3 -1
- package/dist/edit/AttachDialog.js +2 -2
- package/dist/edit/NoteEditor.d.ts +2 -1
- package/dist/edit/NoteEditor.js +81 -22
- package/dist/edit/SaveNoticeDialog.d.ts +15 -0
- package/dist/edit/SaveNoticeDialog.js +41 -0
- package/dist/edit/blocks.d.ts +1 -0
- package/dist/edit/blocks.js +1 -0
- package/dist/edit/index.d.ts +1 -0
- package/dist/edit/index.js +1 -0
- package/dist/edit/saves.d.ts +32 -2
- package/dist/edit/saves.js +33 -12
- package/dist/host.d.ts +24 -2
- package/dist/host.js +8 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/strings.d.ts +2 -0
- package/dist/strings.js +1 -0
- package/package.json +2 -2
- package/structure.css +24 -0
- package/theme-default.css +30 -3
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.
|
|
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
|
|
package/dist/NoteBody.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
71
|
-
|
|
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 ``;
|
|
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`. */
|
package/dist/edit/NoteEditor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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).
|
|
129
|
-
*
|
|
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({
|
|
149
|
+
function SaveStatus({ saves, blocked, host, onDecided }) {
|
|
150
|
+
const { state, notice } = saves;
|
|
132
151
|
const words = host.strings.editor;
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
|
|
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(
|
|
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
|
-
|
|
214
|
+
const run = ++attachRun.current;
|
|
215
|
+
const live = () => run === attachRun.current && editorView.current !== null;
|
|
180
216
|
void (async () => {
|
|
181
217
|
let failed = false;
|
|
182
|
-
|
|
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 (
|
|
192
|
-
setAttaching(failed ? 'failed' :
|
|
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
|
-
|
|
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(
|
|
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: () =>
|
|
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
|
+
}
|
package/dist/edit/blocks.d.ts
CHANGED
package/dist/edit/blocks.js
CHANGED
|
@@ -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
|
};
|
package/dist/edit/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/edit/index.js
CHANGED
package/dist/edit/saves.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/edit/saves.js
CHANGED
|
@@ -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 [
|
|
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
|
-
|
|
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 = (
|
|
42
|
+
const settle = (state, notice = null) => {
|
|
28
43
|
if (self.alive && count === self.count)
|
|
29
|
-
|
|
44
|
+
setStatus({ state, notice });
|
|
30
45
|
};
|
|
31
46
|
let result;
|
|
32
47
|
try {
|
|
33
|
-
result =
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
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;
|