@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.
@@ -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
@@ -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
  */
@@ -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
- let scroller = view.scrollDOM;
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
- resize({ width: null, height: null, percent: Math.min(100, Math.max(1, Math.round((now.width / wide) * 100))) });
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 && now) {
366
- resize({ width: now.width, height: null, percent: null });
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
- if (cancelled || !end)
392
- return;
393
- if (Math.abs(end.width - start.width) < 1 && Math.abs(end.height - start.height) < 1)
394
- return;
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
- drag.current = { x: event.clientX, y: event.clientY, ...now, pointer: event.pointerId };
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
- if (event.metaKey || event.ctrlKey)
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
- /** Follows a link with a modifier held: a wikilink through the host, an address the page follows in a new tab. */
876
- const follow = EditorView.domEventHandlers({
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 (!(event.metaKey || event.ctrlKey))
896
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
879
897
  return false;
880
- const target = event.target.closest('[data-target], [data-href]');
881
- if (!target)
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 address = followable(target.dataset['href'] ?? '');
893
- if (address)
894
- window.open(address, '_blank', 'noreferrer');
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
  }
@@ -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.0",
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.0",
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",