@memorysmithapp/markdown-renderer 0.8.0 → 0.8.2
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/dist/edit/NoteEditor.d.ts +0 -4
- package/dist/edit/NoteEditor.js +16 -8
- package/dist/edit/blocks.d.ts +3 -0
- package/dist/edit/blocks.js +154 -14
- package/dist/edit/live.js +94 -17
- package/dist/edit/saves.d.ts +5 -0
- package/dist/edit/saves.js +16 -4
- package/package.json +2 -2
|
@@ -43,8 +43,4 @@ export interface NoteEditorProps extends Omit<NoteHost, 'writable' | 'onToggleTa
|
|
|
43
43
|
* for a note that mixes them, or has a lone `\r`, which the editor cannot keep byte for byte.
|
|
44
44
|
*/
|
|
45
45
|
export declare function lineBreakOf(text: string): '\n' | '\r\n' | null;
|
|
46
|
-
/**
|
|
47
|
-
* Edits a note on the page that draws it. Exported at `@memorysmithapp/markdown-renderer/edit`
|
|
48
|
-
* for a host that edits without the switch of `NoteBody`; `NoteBody` loads it on demand.
|
|
49
|
-
*/
|
|
50
46
|
export declare function NoteEditor({ markdown: given, onChange, editable, corner, buttons, autoFocus, leaving, onLeave, ...host }: NoteEditorProps): import("react").JSX.Element;
|
package/dist/edit/NoteEditor.js
CHANGED
|
@@ -139,7 +139,7 @@ function Toolbar({ view, host, end, opened, onAttach, attaching, attachButton, }
|
|
|
139
139
|
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));
|
|
140
140
|
const block = (name) => button(name, insertBlockCommand(name));
|
|
141
141
|
const level = view ? headingLevel(view.state) : 0;
|
|
142
|
-
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] }));
|
|
142
|
+
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, Mod-Y')] }), _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] }));
|
|
143
143
|
}
|
|
144
144
|
/**
|
|
145
145
|
* Where the save of the author's last change stands, in the toolbar before the switch: an icon for
|
|
@@ -155,12 +155,12 @@ function SaveStatus({ saves, blocked, deciding, open, setOpen, host, onDecided,
|
|
|
155
155
|
const { state, notice } = saves;
|
|
156
156
|
const words = host.strings.editor;
|
|
157
157
|
const button = useRef(null);
|
|
158
|
-
const base = state === 'saving' ? words.saving : state === 'saved' ? host.strings.saved : state === 'failed' ? host.strings.notSaved : '';
|
|
158
|
+
const base = saves.running ? host.strings.updating : state === 'saving' ? words.saving : state === 'saved' ? host.strings.saved : state === 'failed' ? host.strings.notSaved : '';
|
|
159
159
|
const shown = notice ? (deciding ? `${notice.title}. ${words.chooseToEdit}` : notice.title) : base;
|
|
160
160
|
const said = state === 'failed' && blocked ? (notice ? `${words.stayInEdit}. ${shown}` : words.stayInEdit) : shown;
|
|
161
161
|
const icon = state === 'saving' ? ICONS.saving : state === 'saved' ? ICONS.saved : state === 'alert' ? ICONS.alert : state === 'failed' ? ICONS.notSaved : null;
|
|
162
162
|
const retryLabel = `${said}. ${words.retry}`;
|
|
163
|
-
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: shown, "aria-label": shown, "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) => {
|
|
163
|
+
return (_jsxs("div", { className: "edit-save", "data-save": state, "aria-busy": saves.running ? true : undefined, title: state === 'failed' || notice ? undefined : said || undefined, children: [notice ? (_jsx("button", { ref: button, type: "button", className: "edit-save-icon edit-save-notice", title: shown, "aria-label": shown, "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) => {
|
|
164
164
|
setOpen(false);
|
|
165
165
|
saves.run(action);
|
|
166
166
|
} }))] }));
|
|
@@ -169,6 +169,12 @@ function SaveStatus({ saves, blocked, deciding, open, setOpen, host, onDecided,
|
|
|
169
169
|
* Edits a note on the page that draws it. Exported at `@memorysmithapp/markdown-renderer/edit`
|
|
170
170
|
* for a host that edits without the switch of `NoteBody`; `NoteBody` loads it on demand.
|
|
171
171
|
*/
|
|
172
|
+
/**
|
|
173
|
+
* Redo on Ctrl+Shift+Z, Cmd+Shift+Z on macOS, on every platform, as well as on Ctrl+Y (#120):
|
|
174
|
+
* CodeMirror binds Ctrl+Shift+Z to redo on Linux only, and elsewhere drops the Shift of the key
|
|
175
|
+
* and undoes, so the key most people redo with lost them one more step.
|
|
176
|
+
*/
|
|
177
|
+
const redoKeys = [{ key: 'Mod-Shift-z', run: redo, preventDefault: true }];
|
|
172
178
|
export function NoteEditor({ markdown: given, onChange, editable = true, corner, buttons = 'icon', autoFocus = false, leaving = false, onLeave, ...host }) {
|
|
173
179
|
const resolved = useMemo(() => resolveHost(host),
|
|
174
180
|
// Every member of the contract, so a host that passes the same functions keeps the same host.
|
|
@@ -183,14 +189,14 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
|
|
|
183
189
|
* A refusal that gives the author choices — another person wrote the note, say — is decided
|
|
184
190
|
* before the note is written in again: every save after it would be refused too (#101).
|
|
185
191
|
*/
|
|
186
|
-
const deciding = saves.state === 'failed' && (saves.notice?.actions.length ?? 0) > 0;
|
|
192
|
+
const deciding = (saves.state === 'failed' && (saves.notice?.actions.length ?? 0) > 0) || saves.running;
|
|
187
193
|
const [noticeOpen, setNoticeOpen] = useState(false);
|
|
188
194
|
// A notice with choices opens by itself as it arrives; one the save no longer carries closes.
|
|
189
195
|
useEffect(() => {
|
|
190
196
|
setNoticeOpen(!!saves.notice && saves.notice.actions.length > 0);
|
|
191
197
|
}, [saves.notice]);
|
|
192
|
-
const latest = useRef({ hand: saves.hand, host: resolved, onLeave, deciding, ask: () => setNoticeOpen(true) });
|
|
193
|
-
latest.current = { hand: saves.hand, host: resolved, onLeave, deciding, ask: () => setNoticeOpen(true) };
|
|
198
|
+
const latest = useRef({ hand: saves.hand, host: resolved, onLeave, deciding, running: saves.running, ask: () => setNoticeOpen(true) });
|
|
199
|
+
latest.current = { hand: saves.hand, host: resolved, onLeave, deciding, running: saves.running, ask: () => setNoticeOpen(true) };
|
|
194
200
|
/**
|
|
195
201
|
* The texts handed to the host that it has not given back yet. A host that saves before it
|
|
196
202
|
* passes the note again gives back a text the editor has since moved past; that echo is not a
|
|
@@ -373,7 +379,7 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
|
|
|
373
379
|
livePage(measureMetrics(parent.current)),
|
|
374
380
|
autocompletion({ override: [wikilinkCompletion(() => latest.current.host)], icons: false }),
|
|
375
381
|
shortcuts(() => created),
|
|
376
|
-
keymap.of([...markdownKeymap, ...historyKeymap, indentWithTab, ...defaultKeymap]),
|
|
382
|
+
keymap.of([...markdownKeymap, ...redoKeys, ...historyKeymap, indentWithTab, ...defaultKeymap]),
|
|
377
383
|
// A file pasted or dropped is attached, where the host keeps files (#75).
|
|
378
384
|
menuKeys((target) => {
|
|
379
385
|
if (target.state.readOnly)
|
|
@@ -391,7 +397,9 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
|
|
|
391
397
|
if (event.key.length !== 1 && !WRITING_KEYS.has(event.key))
|
|
392
398
|
return false;
|
|
393
399
|
event.preventDefault();
|
|
394
|
-
|
|
400
|
+
// While the choice runs there is nothing to choose again: the key writes nothing (#119).
|
|
401
|
+
if (!latest.current.running)
|
|
402
|
+
latest.current.ask();
|
|
395
403
|
return true;
|
|
396
404
|
},
|
|
397
405
|
// The editing menu where the pointer is; Shift keeps the browser's, and so do the
|
package/dist/edit/blocks.d.ts
CHANGED
|
@@ -49,6 +49,9 @@ export declare const MIN_IMAGE_SIZE = 16;
|
|
|
49
49
|
* controls, gaps included, and the frame is marked busy while one of them has the focus, while the
|
|
50
50
|
* handle is dragged and for a moment after a pointer or a touch on them.
|
|
51
51
|
*
|
|
52
|
+
* Sized in place, as a table is edited in place, the image shows its source only from a button among
|
|
53
|
+
* its controls or when the caret reaches it from the keyboard; a click on the picture shows nothing (#112).
|
|
54
|
+
*
|
|
52
55
|
* The controls appear once an image is drawn inside it, so an embed of a file that is not an image,
|
|
53
56
|
* or a host's own drawing without one, offers none.
|
|
54
57
|
*/
|
package/dist/edit/blocks.js
CHANGED
|
@@ -76,6 +76,12 @@ export function CalloutBlock({ view, text, marker }) {
|
|
|
76
76
|
// ── Images ───────────────────────────────────────────────────────────────────
|
|
77
77
|
/** The narrowest an image is sized to, by a field or the handle: something a reader still sees (#105). */
|
|
78
78
|
export const MIN_IMAGE_SIZE = 16;
|
|
79
|
+
/**
|
|
80
|
+
* The size in pixels an image had when its unit was switched to a share of the page, where its
|
|
81
|
+
* source starts: switched back with nothing changed between, it is written again, not the width the
|
|
82
|
+
* share rounds to (#113). Kept by the editor, since the image is drawn anew for its new source.
|
|
83
|
+
*/
|
|
84
|
+
const switchedUnits = new WeakMap();
|
|
79
85
|
/** How long after a pointer or a touch on its controls a click on the image is not taken as one to edit its source (#107). */
|
|
80
86
|
const CONTROLS_GUARD_MS = 800;
|
|
81
87
|
/** How long after a control of an image writes the image is held where it was on the screen (#107). */
|
|
@@ -92,9 +98,7 @@ const SCROLL_JUMP = 120;
|
|
|
92
98
|
*/
|
|
93
99
|
function holdInPlace(view, block, run) {
|
|
94
100
|
const top = block.getBoundingClientRect().top;
|
|
95
|
-
|
|
96
|
-
while (scroller && !(scroller.scrollHeight > scroller.clientHeight && /(auto|scroll)/.test(getComputedStyle(scroller).overflowY)))
|
|
97
|
-
scroller = scroller.parentElement;
|
|
101
|
+
const scroller = scrollerOf(view);
|
|
98
102
|
const scrolled = () => (scroller ? scroller.scrollTop : window.scrollY);
|
|
99
103
|
const before = scrolled();
|
|
100
104
|
const back = () => {
|
|
@@ -111,6 +115,83 @@ function holdInPlace(view, block, run) {
|
|
|
111
115
|
window.addEventListener('scroll', back, true);
|
|
112
116
|
setTimeout(() => window.removeEventListener('scroll', back, true), SCROLL_HOLD_MS);
|
|
113
117
|
}
|
|
118
|
+
/** What the editor scrolls in: its own scroller, a pane of the host that scrolls, or `null` for the window. */
|
|
119
|
+
function scrollerOf(view) {
|
|
120
|
+
let scroller = view.scrollDOM;
|
|
121
|
+
while (scroller && !(scroller.scrollHeight > scroller.clientHeight && /(auto|scroll)/.test(getComputedStyle(scroller).overflowY)))
|
|
122
|
+
scroller = scroller.parentElement;
|
|
123
|
+
return scroller;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Room left after the last line of a note while an image is resized near its end (#116): the
|
|
127
|
+
* pixels of padding added under the text, and its padding before. Kept once the drag ends, so the
|
|
128
|
+
* page does not jump back when the image is written smaller, and given back as the author scrolls
|
|
129
|
+
* up, a pixel of it for a pixel of scroll, where it is out of sight.
|
|
130
|
+
*/
|
|
131
|
+
const roomBelow = new WeakMap();
|
|
132
|
+
function setRoom(view, added) {
|
|
133
|
+
const room = roomBelow.get(view) ?? { added: 0, base: parseFloat(getComputedStyle(view.contentDOM).paddingBottom || '0') || 0, dragging: false, trim: null };
|
|
134
|
+
room.added = Math.max(0, Math.round(added));
|
|
135
|
+
view.contentDOM.style.paddingBottom = room.added > 0 ? `${room.base + room.added}px` : '';
|
|
136
|
+
roomBelow.set(view, room);
|
|
137
|
+
const scroller = scrollerOf(view);
|
|
138
|
+
const target = scroller ?? window;
|
|
139
|
+
if (room.added > 0 && !room.trim) {
|
|
140
|
+
room.trim = () => {
|
|
141
|
+
if (room.dragging)
|
|
142
|
+
return;
|
|
143
|
+
const max = scroller ? scroller.scrollHeight - scroller.clientHeight : document.documentElement.scrollHeight - window.innerHeight;
|
|
144
|
+
const slack = max - (scroller ? scroller.scrollTop : window.scrollY);
|
|
145
|
+
if (slack > 0)
|
|
146
|
+
setRoom(view, room.added - slack);
|
|
147
|
+
};
|
|
148
|
+
target.addEventListener('scroll', room.trim, { passive: true });
|
|
149
|
+
}
|
|
150
|
+
else if (room.added === 0 && room.trim) {
|
|
151
|
+
target.removeEventListener('scroll', room.trim);
|
|
152
|
+
room.trim = null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Holds the top of a picture where it is on the screen while its handle is dragged and once it is
|
|
157
|
+
* written (#116): only the text below it moves. The page could not stay put by itself: shrinking
|
|
158
|
+
* a picture near the end of a note made the page shorter than where it was scrolled to, and the
|
|
159
|
+
* browser brought everything above down; growing it after that, the browser's scroll anchoring
|
|
160
|
+
* kept the text below in place and the picture grew upward, away from the pointer. So the note is
|
|
161
|
+
* given room after its last line for as much as the picture may shrink, the anchoring is turned off
|
|
162
|
+
* on what scrolls for the drag, and any move of the picture's top left is scrolled back.
|
|
163
|
+
*/
|
|
164
|
+
function pinTop(view, picture) {
|
|
165
|
+
const scroller = scrollerOf(view);
|
|
166
|
+
const anchored = scroller ?? document.documentElement;
|
|
167
|
+
const anchoring = anchored.style.overflowAnchor;
|
|
168
|
+
anchored.style.overflowAnchor = 'none';
|
|
169
|
+
const top = picture()?.getBoundingClientRect().top ?? null;
|
|
170
|
+
const height = picture()?.getBoundingClientRect().height ?? 0;
|
|
171
|
+
const room = roomBelow.get(view);
|
|
172
|
+
setRoom(view, Math.max(room?.added ?? 0, height));
|
|
173
|
+
roomBelow.get(view).dragging = true;
|
|
174
|
+
const hold = () => {
|
|
175
|
+
const now = picture()?.getBoundingClientRect().top;
|
|
176
|
+
if (top === null || now === undefined)
|
|
177
|
+
return;
|
|
178
|
+
const moved = now - top;
|
|
179
|
+
if (Math.abs(moved) < 1)
|
|
180
|
+
return;
|
|
181
|
+
if (scroller)
|
|
182
|
+
scroller.scrollBy(0, moved);
|
|
183
|
+
else
|
|
184
|
+
window.scrollBy(0, moved);
|
|
185
|
+
};
|
|
186
|
+
const release = () => {
|
|
187
|
+
hold();
|
|
188
|
+
anchored.style.overflowAnchor = anchoring;
|
|
189
|
+
const left = roomBelow.get(view);
|
|
190
|
+
if (left)
|
|
191
|
+
left.dragging = false;
|
|
192
|
+
};
|
|
193
|
+
return { hold, release };
|
|
194
|
+
}
|
|
114
195
|
/** A number as a field holds it: digits and nothing else, or `null`. */
|
|
115
196
|
function wholeNumber(text) {
|
|
116
197
|
return /^\d{1,6}$/.test(text.trim()) ? Number(text.trim()) : null;
|
|
@@ -133,6 +214,9 @@ function wholeNumber(text) {
|
|
|
133
214
|
* controls, gaps included, and the frame is marked busy while one of them has the focus, while the
|
|
134
215
|
* handle is dragged and for a moment after a pointer or a touch on them.
|
|
135
216
|
*
|
|
217
|
+
* Sized in place, as a table is edited in place, the image shows its source only from a button among
|
|
218
|
+
* its controls or when the caret reaches it from the keyboard; a click on the picture shows nothing (#112).
|
|
219
|
+
*
|
|
136
220
|
* The controls appear once an image is drawn inside it, so an embed of a file that is not an image,
|
|
137
221
|
* or a host's own drawing without one, offers none.
|
|
138
222
|
*/
|
|
@@ -218,6 +302,30 @@ export function ImageBlock({ view, source, children }) {
|
|
|
218
302
|
const width = content ? content.clientWidth - parseFloat(getComputedStyle(content).paddingLeft || '0') - parseFloat(getComputedStyle(content).paddingRight || '0') : 0;
|
|
219
303
|
return width > 0 ? Math.round(width) : null;
|
|
220
304
|
};
|
|
305
|
+
/** The widest a share of the page draws the picture: that share of the column, in pixels. */
|
|
306
|
+
const shareWidth = (share) => {
|
|
307
|
+
const wide = column();
|
|
308
|
+
return wide === null ? `${share}%` : `${Math.round((wide * share) / 100)}px`;
|
|
309
|
+
};
|
|
310
|
+
// A share of the page is a share of the column the note is read at (§3.14), as reading mode draws
|
|
311
|
+
// it. Written as a percentage it would resolve against the frame, which shrinks to the picture, and
|
|
312
|
+
// the picture would be drawn at that share of itself (#113): it is given in pixels of the column.
|
|
313
|
+
const percent = size?.percent ?? null;
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
const content = frame.current?.closest('.cm-content') ?? null;
|
|
316
|
+
if (!image || percent === null || !content)
|
|
317
|
+
return;
|
|
318
|
+
const place = () => {
|
|
319
|
+
if (!drag.current)
|
|
320
|
+
image.style.maxWidth = shareWidth(percent);
|
|
321
|
+
};
|
|
322
|
+
place();
|
|
323
|
+
if (typeof ResizeObserver === 'undefined')
|
|
324
|
+
return;
|
|
325
|
+
const observer = new ResizeObserver(place);
|
|
326
|
+
observer.observe(content);
|
|
327
|
+
return () => observer.disconnect();
|
|
328
|
+
}, [image, percent]);
|
|
221
329
|
/** The image as it is drawn now, or `null` before it is laid out. */
|
|
222
330
|
const drawn = () => {
|
|
223
331
|
const box = image?.getBoundingClientRect();
|
|
@@ -240,6 +348,11 @@ export function ImageBlock({ view, source, children }) {
|
|
|
240
348
|
const tallest = Math.max(proportion === null ? 0 : Math.ceil(width * proportion), natural?.height ?? 0);
|
|
241
349
|
return { min: MIN_IMAGE_SIZE, max: tallest > 0 && widest > 0 ? tallest : MAX_IMAGE_WIDTH };
|
|
242
350
|
};
|
|
351
|
+
/** Where the image's source starts in the note, or `null` when it is not in the page. */
|
|
352
|
+
const place = () => {
|
|
353
|
+
const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block');
|
|
354
|
+
return widget ? view.posAtDOM(widget) : null;
|
|
355
|
+
};
|
|
243
356
|
const apply = (changes) => {
|
|
244
357
|
const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block');
|
|
245
358
|
if (!widget || changes.length === 0)
|
|
@@ -360,11 +473,23 @@ export function ImageBlock({ view, source, children }) {
|
|
|
360
473
|
const wide = column();
|
|
361
474
|
// The size it is drawn at, said in the other unit, when it has one.
|
|
362
475
|
if (next === '%' && size?.percent == null && size?.width != null && now && wide) {
|
|
363
|
-
|
|
476
|
+
const share = Math.min(100, Math.max(1, Math.round((now.width / wide) * 100)));
|
|
477
|
+
const at = place();
|
|
478
|
+
if (at !== null)
|
|
479
|
+
switchedUnits.set(view, { at, percent: share, width: size.width, height: size.height });
|
|
480
|
+
resize({ width: null, height: null, percent: share });
|
|
364
481
|
}
|
|
365
|
-
else if (next === 'px' && size?.percent != null
|
|
366
|
-
|
|
482
|
+
else if (next === 'px' && size?.percent != null) {
|
|
483
|
+
const left = switchedUnits.get(view);
|
|
484
|
+
if (left && left.at === place() && left.percent === size.percent) {
|
|
485
|
+
resize({ width: left.width, height: left.height, percent: null });
|
|
486
|
+
setLocked(left.height === null);
|
|
487
|
+
}
|
|
488
|
+
else if (now)
|
|
489
|
+
resize({ width: now.width, height: null, percent: null });
|
|
367
490
|
}
|
|
491
|
+
if (next === 'px')
|
|
492
|
+
switchedUnits.delete(view);
|
|
368
493
|
if (next === '%')
|
|
369
494
|
setLocked(true);
|
|
370
495
|
};
|
|
@@ -385,13 +510,17 @@ export function ImageBlock({ view, source, children }) {
|
|
|
385
510
|
if (image) {
|
|
386
511
|
image.style.width = '';
|
|
387
512
|
image.style.height = '';
|
|
388
|
-
image.style.maxWidth = '';
|
|
513
|
+
image.style.maxWidth = percent === null ? '' : shareWidth(percent);
|
|
389
514
|
}
|
|
390
515
|
hold();
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
516
|
+
// The picture keeps its place while it is written and drawn again at its new size (#116).
|
|
517
|
+
const settle = () => {
|
|
518
|
+
start.pin.hold();
|
|
519
|
+
requestAnimationFrame(start.pin.hold);
|
|
520
|
+
setTimeout(start.pin.release, SCROLL_HOLD_MS);
|
|
521
|
+
};
|
|
522
|
+
if (cancelled || !end || (Math.abs(end.width - start.width) < 1 && Math.abs(end.height - start.height) < 1))
|
|
523
|
+
return settle();
|
|
395
524
|
if (unit === '%') {
|
|
396
525
|
const percent = percentOf(end.width);
|
|
397
526
|
if (percent !== null)
|
|
@@ -401,11 +530,18 @@ export function ImageBlock({ view, source, children }) {
|
|
|
401
530
|
resize({ width: end.width, height: end.height, percent: null });
|
|
402
531
|
else
|
|
403
532
|
resize({ width: end.width, height: null, percent: null });
|
|
533
|
+
settle();
|
|
534
|
+
};
|
|
535
|
+
/** Shows the image's notation, as a table's button does: the caret goes before it (#112). */
|
|
536
|
+
const showSource = () => {
|
|
537
|
+
const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block');
|
|
538
|
+
if (widget)
|
|
539
|
+
reveal(view, view.posAtDOM(widget));
|
|
404
540
|
};
|
|
405
541
|
const offered = size !== null && image !== null;
|
|
406
542
|
const unitWord = unit === '%' ? '%' : 'px';
|
|
407
543
|
const invalid = (name) => (refused?.field === name ? { 'aria-invalid': true, title: words.imageOutOfRange(refused.range.min, refused.range.max, name === 'width' ? unitWord : 'px') } : {});
|
|
408
|
-
return (_jsxs("span", { ref: frame, className: "edit-image", "data-resizing": dragged === null ? undefined : '', onFocus: () => frame.current && (frame.current.dataset['msBusy'] = ''), onBlur: hold, onMouseDown: (event) => {
|
|
544
|
+
return (_jsxs("span", { ref: frame, className: "edit-image", "data-resizing": dragged === null ? undefined : '', "data-ms-in-place": offered ? '' : undefined, onFocus: () => frame.current && (frame.current.dataset['msBusy'] = ''), onBlur: hold, onMouseDown: (event) => {
|
|
409
545
|
// The frame's own area is the space between the picture and its controls: a press there is
|
|
410
546
|
// one meant for them, and shows nothing (#107).
|
|
411
547
|
if (event.target === event.currentTarget) {
|
|
@@ -420,14 +556,17 @@ export function ImageBlock({ view, source, children }) {
|
|
|
420
556
|
event.preventDefault();
|
|
421
557
|
}, children: [size.description ? (_jsx("input", { className: "edit-field edit-image-description", type: "text", "aria-label": words.imageDescription, title: words.imageDescription, placeholder: image.alt, value: typed.description ?? size.description.text, ...field('description') })) : (
|
|
422
558
|
// An embed writes no description (§5.8): the file's own is shown, and is not edited here.
|
|
423
|
-
_jsx("input", { className: "edit-field edit-image-description", type: "text", readOnly: true, "aria-label": words.imageDescription, title: words.imageDescription, placeholder: image.alt, value: "" })), _jsxs("span", { className: "edit-image-size", children: [_jsx("input", { className: "edit-field edit-image-width", type: "text", inputMode: "numeric", autoComplete: "off", "aria-label": words.imageWidth, title: words.imageWidth, placeholder: unit === '%' ? '' : natural === null ? '' : String(natural.width), value: typed.width ?? (shownWidth === null ? '' : String(shownWidth)), ...field('width'), ...invalid('width') }), _jsxs("select", { className: "edit-image-unit", "aria-label": words.imageUnit, title: words.imageUnit, value: unit, onChange: (event) => chooseUnit(event.target.value), children: [_jsx("option", { value: "px", children: "px" }), _jsx("option", { value: "%", children: "%" })] }), _jsx(Control, { label: locked ? words.imageKeepRatio : words.imageFreeRatio, onClick: toggleLock, pressed: locked, disabled: unit === '%', focusable: true, children: locked ? ICON_RATIO_LOCKED : ICON_RATIO_FREE }), _jsx("input", { className: "edit-field edit-image-height", type: "text", inputMode: "numeric", autoComplete: "off", "aria-label": words.imageHeight, title: words.imageHeight, disabled: unit === '%', placeholder: natural === null || unit === '%' ? '' : String(natural.height), value: unit === '%' ? '' : (typed.height ?? (shownHeight === null ? '' : String(shownHeight))), ...field('height'), ...invalid('height') }), _jsx("span", { className: "edit-image-unit-label", "aria-hidden": "true", children: "px" }), _jsx(Control, { label: words.imageOwnSize, onClick: () => resize(null), pressed: size.width === null && size.percent === null, focusable: true, children: ICON_OWN_SIZE })] }), refused && (_jsx("span", { className: "edit-image-refused", role: "status", children: words.imageOutOfRange(refused.range.min, refused.range.max, refused.field === 'width' ? unitWord : 'px') }))] }), _jsx("span", { className: "edit-image-handle", "data-ms-control": "", "data-free": free ? '' : undefined, title: free ? words.imageResizeFree : words.imageResizeKeep, "aria-hidden": "true", onPointerDown: (event) => {
|
|
559
|
+
_jsx("input", { className: "edit-field edit-image-description", type: "text", readOnly: true, "aria-label": words.imageDescription, title: words.imageDescription, placeholder: image.alt, value: "" })), _jsxs("span", { className: "edit-image-size", children: [_jsx("input", { className: "edit-field edit-image-width", type: "text", inputMode: "numeric", autoComplete: "off", "aria-label": words.imageWidth, title: words.imageWidth, placeholder: unit === '%' ? '' : natural === null ? '' : String(natural.width), value: typed.width ?? (shownWidth === null ? '' : String(shownWidth)), ...field('width'), ...invalid('width') }), _jsxs("select", { className: "edit-image-unit", "aria-label": words.imageUnit, title: words.imageUnit, value: unit, onChange: (event) => chooseUnit(event.target.value), children: [_jsx("option", { value: "px", children: "px" }), _jsx("option", { value: "%", children: "%" })] }), _jsx(Control, { label: locked ? words.imageKeepRatio : words.imageFreeRatio, onClick: toggleLock, pressed: locked, disabled: unit === '%', focusable: true, children: locked ? ICON_RATIO_LOCKED : ICON_RATIO_FREE }), _jsx("input", { className: "edit-field edit-image-height", type: "text", inputMode: "numeric", autoComplete: "off", "aria-label": words.imageHeight, title: words.imageHeight, disabled: unit === '%', placeholder: natural === null || unit === '%' ? '' : String(natural.height), value: unit === '%' ? '' : (typed.height ?? (shownHeight === null ? '' : String(shownHeight))), ...field('height'), ...invalid('height') }), _jsx("span", { className: "edit-image-unit-label", "aria-hidden": "true", children: "px" }), _jsx(Control, { label: words.imageOwnSize, onClick: () => resize(null), pressed: size.width === null && size.percent === null, focusable: true, children: ICON_OWN_SIZE }), _jsx(Control, { label: words.source, onClick: showSource, children: ICONS.source })] }), refused && (_jsx("span", { className: "edit-image-refused", role: "status", children: words.imageOutOfRange(refused.range.min, refused.range.max, refused.field === 'width' ? unitWord : 'px') }))] }), _jsx("span", { className: "edit-image-handle", "data-ms-control": "", "data-free": free ? '' : undefined, title: free ? words.imageResizeFree : words.imageResizeKeep, "aria-hidden": "true", onPointerDown: (event) => {
|
|
424
560
|
event.preventDefault();
|
|
425
561
|
event.stopPropagation();
|
|
426
562
|
const now = drawn();
|
|
427
563
|
if (!now)
|
|
428
564
|
return;
|
|
429
565
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
430
|
-
|
|
566
|
+
const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block') ?? null;
|
|
567
|
+
// The picture the widget draws, the one drawn anew once it is written included.
|
|
568
|
+
const pin = pinTop(view, () => widget?.querySelector('.edit-image img') ?? null);
|
|
569
|
+
drag.current = { x: event.clientX, y: event.clientY, ...now, pointer: event.pointerId, pin };
|
|
431
570
|
if (frame.current)
|
|
432
571
|
frame.current.dataset['msBusy'] = '';
|
|
433
572
|
setShift(event.shiftKey);
|
|
@@ -445,6 +584,7 @@ export function ImageBlock({ view, source, children }) {
|
|
|
445
584
|
}
|
|
446
585
|
else
|
|
447
586
|
image.style.height = 'auto';
|
|
587
|
+
start.pin.hold();
|
|
448
588
|
// What the page lets it be: never wider than the column.
|
|
449
589
|
setDragged(drawn());
|
|
450
590
|
}, onPointerUp: (event) => endDrag(event, false), onPointerCancel: (event) => endDrag(event, true), children: ICON_RESIZE })] }))] }));
|
package/dist/edit/live.js
CHANGED
|
@@ -260,8 +260,17 @@ class BlockWidget extends WidgetType {
|
|
|
260
260
|
// dragged, the click a touch on them leaves behind — never shows the source (#107).
|
|
261
261
|
if (target.closest('[data-ms-control], [data-ms-busy]'))
|
|
262
262
|
return;
|
|
263
|
-
|
|
263
|
+
// CodeMirror hands the editor no event from inside a drawn block: a link around it is followed here (#112).
|
|
264
|
+
if (event.metaKey || event.ctrlKey) {
|
|
265
|
+
followLinkAround(event, view, element);
|
|
264
266
|
return;
|
|
267
|
+
}
|
|
268
|
+
// A picture sized in place, as a table is edited in place, shows its source from its button or
|
|
269
|
+
// the keyboard, never from a click (#112); kept from placing the caret beside it, which would.
|
|
270
|
+
if (target.closest('[data-ms-in-place]')) {
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
265
274
|
const widget = drawing.get(element) ?? this;
|
|
266
275
|
if (!widget.revealsOnClick)
|
|
267
276
|
return;
|
|
@@ -872,29 +881,96 @@ const blockKeys = Prec.high(keymap.of([
|
|
|
872
881
|
{ key: 'ArrowDown', run: enterBlock(true) },
|
|
873
882
|
{ key: 'ArrowUp', run: enterBlock(false) },
|
|
874
883
|
]));
|
|
875
|
-
/**
|
|
876
|
-
const
|
|
884
|
+
/** Where the first press of a gesture fell in the note, read before the press moved the caret. */
|
|
885
|
+
const firstPress = new WeakMap();
|
|
886
|
+
/** The longest a browser counts two presses as one gesture: Windows' default double-click time. */
|
|
887
|
+
const MULTI_CLICK_MS = 500;
|
|
888
|
+
/**
|
|
889
|
+
* Selects the word, or the line, a double or a triple click starts on (#114). The first press puts
|
|
890
|
+
* the caret where it fell, which reveals the markers beside it, or hides the ones it left, and moves
|
|
891
|
+
* the rest of the line under the pointer by their width: the second press, read where it lands,
|
|
892
|
+
* would select the marker or the next word. It is read where the first one fell instead.
|
|
893
|
+
*/
|
|
894
|
+
const multiClick = EditorView.domEventHandlers({
|
|
877
895
|
mousedown(event, view) {
|
|
878
|
-
if (
|
|
896
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
879
897
|
return false;
|
|
880
|
-
|
|
881
|
-
|
|
898
|
+
if (event.detail <= 1) {
|
|
899
|
+
const at = view.posAtCoords({ x: event.clientX, y: event.clientY });
|
|
900
|
+
if (at === null)
|
|
901
|
+
firstPress.delete(view);
|
|
902
|
+
else
|
|
903
|
+
firstPress.set(view, { at, time: event.timeStamp });
|
|
882
904
|
return false;
|
|
883
|
-
event.preventDefault();
|
|
884
|
-
const { host } = view.state.facet(editorEnvironment);
|
|
885
|
-
const name = target.dataset['target'];
|
|
886
|
-
if (name !== undefined) {
|
|
887
|
-
const address = host.resolve(name);
|
|
888
|
-
if (address !== null)
|
|
889
|
-
host.navigate(address);
|
|
890
|
-
return true;
|
|
891
905
|
}
|
|
892
|
-
const
|
|
893
|
-
if (
|
|
894
|
-
|
|
906
|
+
const first = firstPress.get(view);
|
|
907
|
+
if (!first || event.timeStamp - first.time > MULTI_CLICK_MS * (event.detail - 1))
|
|
908
|
+
return false;
|
|
909
|
+
const { state } = view;
|
|
910
|
+
const line = state.doc.lineAt(first.at);
|
|
911
|
+
const range = event.detail === 2 ? (state.wordAt(first.at) ?? EditorSelection.cursor(first.at)) : EditorSelection.range(line.from, line.to);
|
|
912
|
+
event.preventDefault();
|
|
913
|
+
view.dispatch({ selection: EditorSelection.single(range.from, range.to), userEvent: 'select.pointer' });
|
|
895
914
|
return true;
|
|
896
915
|
},
|
|
897
916
|
});
|
|
917
|
+
/** Follows a link with a modifier held: a wikilink through the host, an address the page follows in a new tab. */
|
|
918
|
+
const follow = EditorView.domEventHandlers({
|
|
919
|
+
mousedown(event, view) {
|
|
920
|
+
if (!(event.metaKey || event.ctrlKey))
|
|
921
|
+
return false;
|
|
922
|
+
return followLink(event, view);
|
|
923
|
+
},
|
|
924
|
+
});
|
|
925
|
+
/** Follows the link a press with a modifier fell in, if any: `true` when it did. */
|
|
926
|
+
function followLink(event, view) {
|
|
927
|
+
const target = event.target.closest('[data-target], [data-href]');
|
|
928
|
+
if (!target)
|
|
929
|
+
return false;
|
|
930
|
+
event.preventDefault();
|
|
931
|
+
followAddress(view, target.dataset['target'], target.dataset['href']);
|
|
932
|
+
return true;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Follows the link a drawn block stands in, pressed with a modifier: CodeMirror draws no mark around
|
|
936
|
+
* a widget, so the link around an image is found among the marks over its source (#112).
|
|
937
|
+
*/
|
|
938
|
+
function followLinkAround(event, view, element) {
|
|
939
|
+
if (followLink(event, view))
|
|
940
|
+
return true;
|
|
941
|
+
const widget = drawing.get(element);
|
|
942
|
+
if (!widget)
|
|
943
|
+
return false;
|
|
944
|
+
const from = view.posAtDOM(element);
|
|
945
|
+
const to = from + widget.source.length;
|
|
946
|
+
const links = [];
|
|
947
|
+
view.state.field(decorationsField).between(from, to, (start, end, decoration) => {
|
|
948
|
+
const attributes = decoration.spec.attributes;
|
|
949
|
+
if (!attributes || start > from || end < to)
|
|
950
|
+
return;
|
|
951
|
+
if (attributes['data-target'] !== undefined || attributes['data-href'] !== undefined)
|
|
952
|
+
links.push({ target: attributes['data-target'], href: attributes['data-href'] });
|
|
953
|
+
});
|
|
954
|
+
const link = links[0];
|
|
955
|
+
if (!link)
|
|
956
|
+
return false;
|
|
957
|
+
event.preventDefault();
|
|
958
|
+
followAddress(view, link.target, link.href);
|
|
959
|
+
return true;
|
|
960
|
+
}
|
|
961
|
+
/** Goes to a wikilink's note through the host, or opens an address the page follows in a new tab. */
|
|
962
|
+
function followAddress(view, name, href) {
|
|
963
|
+
const { host } = view.state.facet(editorEnvironment);
|
|
964
|
+
if (name !== undefined) {
|
|
965
|
+
const address = host.resolve(name);
|
|
966
|
+
if (address !== null)
|
|
967
|
+
host.navigate(address);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const address = followable(href ?? '');
|
|
971
|
+
if (address)
|
|
972
|
+
window.open(address, '_blank', 'noreferrer');
|
|
973
|
+
}
|
|
898
974
|
/** How long an embed measured ahead of being drawn may take to load before it is let go (#94). */
|
|
899
975
|
const PREMEASURE_TIMEOUT = 8000;
|
|
900
976
|
/**
|
|
@@ -1036,5 +1112,6 @@ export function livePage(metrics) {
|
|
|
1036
1112
|
embedPremeasure,
|
|
1037
1113
|
blockKeys,
|
|
1038
1114
|
follow,
|
|
1115
|
+
multiClick,
|
|
1039
1116
|
];
|
|
1040
1117
|
}
|
package/dist/edit/saves.d.ts
CHANGED
|
@@ -42,6 +42,11 @@ export interface EditSaves {
|
|
|
42
42
|
readonly retry: () => void;
|
|
43
43
|
/** Runs one of the notice's actions and follows it like a save. */
|
|
44
44
|
readonly run: (action: SaveAction) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Whether an action the author chose is still running (#119): the note stays held until it
|
|
47
|
+
* settles, and a moment after, so the text the host gives back is in before the author writes.
|
|
48
|
+
*/
|
|
49
|
+
readonly running: boolean;
|
|
45
50
|
}
|
|
46
51
|
/** Whether a value is a notice a host gave: a title, a message and its actions. */
|
|
47
52
|
export declare function isSaveNotice(value: unknown): value is SaveNotice;
|
package/dist/edit/saves.js
CHANGED
|
@@ -26,6 +26,7 @@ export function isSaveNotice(value) {
|
|
|
26
26
|
}
|
|
27
27
|
export function useEditSaves(onChange) {
|
|
28
28
|
const [status, setStatus] = useState({ state: 'idle', notice: null });
|
|
29
|
+
const [running, setRunning] = useState(false);
|
|
29
30
|
const latest = useRef({ onChange, count: 0, text: null, alive: true });
|
|
30
31
|
latest.current.onChange = onChange;
|
|
31
32
|
useEffect(() => {
|
|
@@ -35,13 +36,18 @@ export function useEditSaves(onChange) {
|
|
|
35
36
|
self.alive = false;
|
|
36
37
|
};
|
|
37
38
|
}, []);
|
|
38
|
-
/**
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Follows what the host answered, a change's save or an action; `done` is the state of an answer
|
|
41
|
+
* that is not a promise, and `settled` is told once it is over.
|
|
42
|
+
*/
|
|
43
|
+
const follow = useCallback((answer, done, settled) => {
|
|
40
44
|
const self = latest.current;
|
|
41
45
|
const count = ++self.count;
|
|
42
46
|
const settle = (state, notice = null) => {
|
|
43
47
|
if (self.alive && count === self.count)
|
|
44
48
|
setStatus({ state, notice });
|
|
49
|
+
if (state !== 'saving')
|
|
50
|
+
settled?.();
|
|
45
51
|
};
|
|
46
52
|
let result;
|
|
47
53
|
try {
|
|
@@ -68,6 +74,12 @@ export function useEditSaves(onChange) {
|
|
|
68
74
|
hand(text);
|
|
69
75
|
}, [hand]);
|
|
70
76
|
// An action that answers nothing has done what it does: the author decided, and nothing is left to say.
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
// The note is held from the choice until the action settles, and for a turn of the event loop after
|
|
78
|
+
// it, so a text the host gives back as it settles is drawn before a key reaches it (#119).
|
|
79
|
+
const run = useCallback((action) => {
|
|
80
|
+
const self = latest.current;
|
|
81
|
+
setRunning(true);
|
|
82
|
+
follow(() => action.run(), 'saved', () => setTimeout(() => self.alive && setRunning(false)));
|
|
83
|
+
}, [follow]);
|
|
84
|
+
return { state: status.state, notice: status.notice, hand, retry, run, running };
|
|
73
85
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@memorysmithapp/markdown-renderer",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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.8.
|
|
82
|
+
"@memorysmithapp/markdown-spec": "0.8.2",
|
|
83
83
|
"@types/hast": "3.0.5",
|
|
84
84
|
"@types/react": "19.2.18",
|
|
85
85
|
"@types/react-dom": "19.2.4",
|