@memorysmithapp/markdown-renderer 0.8.1 → 0.8.3

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.
@@ -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;
@@ -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
- latest.current.ask();
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
@@ -98,9 +98,7 @@ const SCROLL_JUMP = 120;
98
98
  */
99
99
  function holdInPlace(view, block, run) {
100
100
  const top = block.getBoundingClientRect().top;
101
- let scroller = view.scrollDOM;
102
- while (scroller && !(scroller.scrollHeight > scroller.clientHeight && /(auto|scroll)/.test(getComputedStyle(scroller).overflowY)))
103
- scroller = scroller.parentElement;
101
+ const scroller = scrollerOf(view);
104
102
  const scrolled = () => (scroller ? scroller.scrollTop : window.scrollY);
105
103
  const before = scrolled();
106
104
  const back = () => {
@@ -117,6 +115,83 @@ function holdInPlace(view, block, run) {
117
115
  window.addEventListener('scroll', back, true);
118
116
  setTimeout(() => window.removeEventListener('scroll', back, true), SCROLL_HOLD_MS);
119
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
+ }
120
195
  /** A number as a field holds it: digits and nothing else, or `null`. */
121
196
  function wholeNumber(text) {
122
197
  return /^\d{1,6}$/.test(text.trim()) ? Number(text.trim()) : null;
@@ -150,6 +225,8 @@ export function ImageBlock({ view, source, children }) {
150
225
  const frame = useRef(null);
151
226
  const size = readImageSize(source);
152
227
  const [image, setImage] = useState(null);
228
+ // A kept file that is not a picture — its card, its player — takes the column, as it is read (#122).
229
+ const [file, setFile] = useState(false);
153
230
  const [natural, setNatural] = useState(null);
154
231
  const [locked, setLocked] = useState(() => size?.height == null);
155
232
  const [unit, setUnit] = useState(() => (size?.percent == null ? 'px' : '%'));
@@ -164,7 +241,10 @@ export function ImageBlock({ view, source, children }) {
164
241
  const element = frame.current;
165
242
  if (!element)
166
243
  return;
167
- const look = () => setImage(element.querySelector('img'));
244
+ const look = () => {
245
+ setImage(element.querySelector('img'));
246
+ setFile(element.querySelector('.attachment-card, .attachment-player') !== null);
247
+ };
168
248
  look();
169
249
  if (typeof MutationObserver === 'undefined')
170
250
  return;
@@ -438,10 +518,14 @@ export function ImageBlock({ view, source, children }) {
438
518
  image.style.maxWidth = percent === null ? '' : shareWidth(percent);
439
519
  }
440
520
  hold();
441
- if (cancelled || !end)
442
- return;
443
- if (Math.abs(end.width - start.width) < 1 && Math.abs(end.height - start.height) < 1)
444
- return;
521
+ // The picture keeps its place while it is written and drawn again at its new size (#116).
522
+ const settle = () => {
523
+ start.pin.hold();
524
+ requestAnimationFrame(start.pin.hold);
525
+ setTimeout(start.pin.release, SCROLL_HOLD_MS);
526
+ };
527
+ if (cancelled || !end || (Math.abs(end.width - start.width) < 1 && Math.abs(end.height - start.height) < 1))
528
+ return settle();
445
529
  if (unit === '%') {
446
530
  const percent = percentOf(end.width);
447
531
  if (percent !== null)
@@ -451,6 +535,7 @@ export function ImageBlock({ view, source, children }) {
451
535
  resize({ width: end.width, height: end.height, percent: null });
452
536
  else
453
537
  resize({ width: end.width, height: null, percent: null });
538
+ settle();
454
539
  };
455
540
  /** Shows the image's notation, as a table's button does: the caret goes before it (#112). */
456
541
  const showSource = () => {
@@ -461,7 +546,7 @@ export function ImageBlock({ view, source, children }) {
461
546
  const offered = size !== null && image !== null;
462
547
  const unitWord = unit === '%' ? '%' : 'px';
463
548
  const invalid = (name) => (refused?.field === name ? { 'aria-invalid': true, title: words.imageOutOfRange(refused.range.min, refused.range.max, name === 'width' ? unitWord : 'px') } : {});
464
- 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) => {
549
+ return (_jsxs("span", { ref: frame, className: "edit-image", "data-resizing": dragged === null ? undefined : '', "data-ms-in-place": offered ? '' : undefined, "data-ms-file": file ? '' : undefined, onFocus: () => frame.current && (frame.current.dataset['msBusy'] = ''), onBlur: hold, onMouseDown: (event) => {
465
550
  // The frame's own area is the space between the picture and its controls: a press there is
466
551
  // one meant for them, and shows nothing (#107).
467
552
  if (event.target === event.currentTarget) {
@@ -471,19 +556,23 @@ export function ImageBlock({ view, source, children }) {
471
556
  }, children: [children, offered && (_jsxs(_Fragment, { children: [_jsxs("span", { className: "edit-image-controls", "data-ms-control": "", onPointerDown: hold, onTouchStart: hold, onMouseDown: (event) => {
472
557
  // A press anywhere but on a field, a choice or a button — a gap between them, the
473
558
  // padding, a word beside a field — keeps the focus where it is. Its default would put
474
- // the editor's caret where the press fell, and the image would show its source (#107).
475
- if (!event.target.closest('input, select, textarea, button'))
559
+ // the editor's caret where the press fell, and the image would show its source (#107). A
560
+ // disabled one is a gap too: it takes no focus, and the caret would go where it fell (#121).
561
+ if (!event.target.closest('input:enabled, select:enabled, textarea:enabled, button:enabled'))
476
562
  event.preventDefault();
477
563
  }, 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') })) : (
478
564
  // An embed writes no description (§5.8): the file's own is shown, and is not edited here.
479
- _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) => {
565
+ _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", "data-unit": unit, 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) => {
480
566
  event.preventDefault();
481
567
  event.stopPropagation();
482
568
  const now = drawn();
483
569
  if (!now)
484
570
  return;
485
571
  event.currentTarget.setPointerCapture?.(event.pointerId);
486
- drag.current = { x: event.clientX, y: event.clientY, ...now, pointer: event.pointerId };
572
+ const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block') ?? null;
573
+ // The picture the widget draws, the one drawn anew once it is written included.
574
+ const pin = pinTop(view, () => widget?.querySelector('.edit-image img') ?? null);
575
+ drag.current = { x: event.clientX, y: event.clientY, ...now, pointer: event.pointerId, pin };
487
576
  if (frame.current)
488
577
  frame.current.dataset['msBusy'] = '';
489
578
  setShift(event.shiftKey);
@@ -501,6 +590,7 @@ export function ImageBlock({ view, source, children }) {
501
590
  }
502
591
  else
503
592
  image.style.height = 'auto';
593
+ start.pin.hold();
504
594
  // What the page lets it be: never wider than the column.
505
595
  setDragged(drawn());
506
596
  }, onPointerUp: (event) => endDrag(event, false), onPointerCancel: (event) => endDrag(event, true), children: ICON_RESIZE })] }))] }));
@@ -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;
@@ -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
- /** 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) => {
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
- const run = useCallback((action) => follow(() => action.run(), 'saved'), [follow]);
72
- return { state: status.state, notice: status.notice, hand, retry, run };
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.1",
3
+ "version": "0.8.3",
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.1",
82
+ "@memorysmithapp/markdown-spec": "0.8.3",
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
@@ -469,6 +469,11 @@
469
469
  max-width: 100%;
470
470
  vertical-align: middle;
471
471
  }
472
+ /* A kept file that is not a picture, a card or a player, has no width of its own
473
+ to shrink the frame to: it takes the column, as in reading mode (#122). */
474
+ .markdown .edit-image[data-ms-file] {
475
+ display: block;
476
+ }
472
477
  .markdown .edit-image-controls {
473
478
  position: absolute;
474
479
  top: var(--edit-image-bottom, 100%);
@@ -493,6 +498,11 @@
493
498
  .markdown .edit-image-height {
494
499
  width: 4.2em;
495
500
  }
501
+ /* Under a share of the page, the lock, the height and its unit mean nothing: they
502
+ are not shown, and keep their place so the controls after them stay put (#121). */
503
+ .markdown .edit-image-size[data-unit='%'] > :is(.edit-control:disabled, .edit-image-height, .edit-image-unit-label) {
504
+ visibility: hidden;
505
+ }
496
506
  .markdown .edit-image-description {
497
507
  flex: 1 1 10rem;
498
508
  min-width: 0;
package/theme-default.css CHANGED
@@ -1983,9 +1983,6 @@
1983
1983
  border-color: rgb(var(--ms-callout-stop));
1984
1984
  outline: 1px solid rgb(var(--ms-callout-stop));
1985
1985
  }
1986
- .markdown .edit-image-controls .edit-field:disabled {
1987
- opacity: 0.5;
1988
- }
1989
1986
  .markdown .edit-image-unit {
1990
1987
  height: 1.9rem;
1991
1988
  border: 1px solid var(--ms-border);