@memorysmithapp/markdown-renderer 0.7.0 → 0.7.1

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.
@@ -115,6 +115,8 @@ function insertBlockCommand(name) {
115
115
  const NOT_ATTACHING = { state: 'idle', message: null, fraction: null, queued: 0 };
116
116
  /** How long the attach button says a file was not attached, when the host did not say why, in milliseconds. */
117
117
  const ATTACH_FAILED_MS = 3000;
118
+ /** The keys that write besides a character's own: a note held for the author's choice asks again on them (#101). */
119
+ const WRITING_KEYS = new Set(['Enter', 'Backspace', 'Delete', 'Tab']);
118
120
  /** A host's fraction, kept between 0 and 1, or none when it gave no number. */
119
121
  function fractionOf(report) {
120
122
  const fraction = report?.fraction;
@@ -144,25 +146,21 @@ function Toolbar({ view, host, end, opened, onAttach, attaching, attachButton, }
144
146
  * each state, in a slot of one size, so the toolbar never moves as the author types; its word is
145
147
  * its title. A failed save's icon is the button that tries again (#66). When the host said why —
146
148
  * an alert on a save that succeeded, or the reason for one it refused — the icon is the button that
147
- * opens what it said, with the host's title as its tooltip, and the author's choices (#86). Whoever
148
- * hears the page is told the whole of it, why the note stays in edit included.
149
+ * opens what it said, with the host's title as its tooltip, and the author's choices (#86); a notice
150
+ * with choices opens by itself as it arrives, and a refusal with choices holds the note until the
151
+ * author chooses (#101). Whoever hears the page is told the whole of it, why the note stays in edit
152
+ * included.
149
153
  */
150
- function SaveStatus({ saves, blocked, host, onDecided }) {
154
+ function SaveStatus({ saves, blocked, deciding, open, setOpen, host, onDecided, }) {
151
155
  const { state, notice } = saves;
152
156
  const words = host.strings.editor;
153
- const [open, setOpen] = useState(false);
154
157
  const button = useRef(null);
155
- // A notice the save no longer carries has nothing left to show.
156
- useEffect(() => {
157
- if (!notice)
158
- setOpen(false);
159
- }, [notice]);
160
158
  const base = state === 'saving' ? words.saving : state === 'saved' ? host.strings.saved : state === 'failed' ? host.strings.notSaved : '';
161
- const shown = notice ? notice.title : base;
162
- const said = state === 'failed' && blocked ? (notice ? `${words.stayInEdit}. ${notice.title}` : words.stayInEdit) : shown;
159
+ const shown = notice ? (deciding ? `${notice.title}. ${words.chooseToEdit}` : notice.title) : base;
160
+ const said = state === 'failed' && blocked ? (notice ? `${words.stayInEdit}. ${shown}` : words.stayInEdit) : shown;
163
161
  const icon = state === 'saving' ? ICONS.saving : state === 'saved' ? ICONS.saved : state === 'alert' ? ICONS.alert : state === 'failed' ? ICONS.notSaved : null;
164
162
  const retryLabel = `${said}. ${words.retry}`;
165
- return (_jsxs("div", { className: "edit-save", "data-save": state, title: state === 'failed' || notice ? undefined : said || undefined, children: [notice ? (_jsx("button", { ref: button, type: "button", className: "edit-save-icon edit-save-notice", title: notice.title, "aria-label": notice.title, "aria-haspopup": "dialog", onClick: () => setOpen(true), children: icon })) : state === 'failed' ? (_jsx("button", { type: "button", className: "edit-save-icon edit-save-retry", title: retryLabel, "aria-label": retryLabel, onClick: saves.retry, children: icon })) : (_jsx("span", { className: "edit-save-icon", children: icon })), _jsx("span", { className: "visually-hidden", role: "status", "aria-live": "polite", children: said }), open && notice && (_jsx(SaveNoticeDialog, { notice: notice, onClose: () => setOpen(false), onClosed: (chosen) => (chosen ? onDecided() : button.current?.focus()), onAction: (action) => {
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) => {
166
164
  setOpen(false);
167
165
  saves.run(action);
168
166
  } }))] }));
@@ -181,8 +179,18 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
181
179
  const environment = useRef(new Compartment());
182
180
  const access = useRef(new Compartment());
183
181
  const saves = useEditSaves(onChange);
184
- const latest = useRef({ hand: saves.hand, host: resolved, onLeave });
185
- latest.current = { hand: saves.hand, host: resolved, onLeave };
182
+ /**
183
+ * A refusal that gives the author choices — another person wrote the note, say — is decided
184
+ * before the note is written in again: every save after it would be refused too (#101).
185
+ */
186
+ const deciding = saves.state === 'failed' && (saves.notice?.actions.length ?? 0) > 0;
187
+ const [noticeOpen, setNoticeOpen] = useState(false);
188
+ // A notice with choices opens by itself as it arrives; one the save no longer carries closes.
189
+ useEffect(() => {
190
+ setNoticeOpen(!!saves.notice && saves.notice.actions.length > 0);
191
+ }, [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) };
186
194
  /**
187
195
  * The texts handed to the host that it has not given back yet. A host that saves before it
188
196
  * passes the note again gives back a text the editor has since moved past; that echo is not a
@@ -336,7 +344,9 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
336
344
  // The note as edit mode opened it, as the editor holds its text: lines joined by `\n`.
337
345
  const opened = useRef(given.replace(/\r\n?/g, '\n'));
338
346
  const lineBreak = lineBreakOf(given);
339
- const readOnly = !editable || lineBreak === null;
347
+ const locked = !editable || lineBreak === null;
348
+ // Held while the author decides: the caret still moves, and a key that would write asks again.
349
+ const readOnly = locked || deciding;
340
350
  /**
341
351
  * The editor splits its text on any line break, so a paste or a block written with `\n` is lines
342
352
  * like any other, and writes the note back with the break it was written with: what a host
@@ -353,7 +363,7 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
353
363
  state: EditorState.create({
354
364
  doc: given,
355
365
  extensions: [
356
- access.current.of([EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]),
366
+ access.current.of([EditorState.readOnly.of(readOnly), EditorView.editable.of(!locked)]),
357
367
  environment.current.of(editorEnvironment.of({ host: resolved })),
358
368
  history(),
359
369
  markdown(),
@@ -373,6 +383,16 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
373
383
  return true;
374
384
  }),
375
385
  EditorView.domEventHandlers({
386
+ // A key that would write, while the note waits for the author's choice, shows the choice again (#101).
387
+ keydown: (event) => {
388
+ if (!latest.current.deciding || event.ctrlKey || event.metaKey || event.altKey)
389
+ return false;
390
+ if (event.key.length !== 1 && !WRITING_KEYS.has(event.key))
391
+ return false;
392
+ event.preventDefault();
393
+ latest.current.ask();
394
+ return true;
395
+ },
376
396
  // The editing menu where the pointer is; Shift keeps the browser's, and so do the
377
397
  // drawn blocks' own fields, which are the browser's to edit (#81).
378
398
  contextmenu: (event, target) => {
@@ -436,8 +456,8 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
436
456
  view?.dispatch({ effects: environment.current.reconfigure(editorEnvironment.of({ host: resolved })) });
437
457
  }, [view, resolved]);
438
458
  useEffect(() => {
439
- view?.dispatch({ effects: access.current.reconfigure([EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]) });
440
- }, [view, readOnly]);
459
+ view?.dispatch({ effects: access.current.reconfigure([EditorState.readOnly.of(readOnly), EditorView.editable.of(!locked)]) });
460
+ }, [view, readOnly, locked]);
441
461
  // A note the host changed — a tick saved, another device — is taken in as the change it is.
442
462
  useEffect(() => {
443
463
  if (!view)
@@ -513,7 +533,7 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
513
533
  else
514
534
  setRefusal(null);
515
535
  setAttachOpen(true);
516
- }, attaching: attaching, attachButton: attachButton, end: _jsxs("div", { className: "edit-corner", "aria-busy": leaving && saves.state === 'saving' ? true : undefined, "data-blocked": blocked ? '' : undefined, children: [_jsx(SaveStatus, { saves: saves, blocked: blocked, host: resolved, onDecided: () => view?.focus() }), corner ?? _jsx(NoteActions, { display: buttons, text: () => (view ? output(view.state) : given) })] }, blocked ? `blocked-${refusals}` : 'corner') }) }), menu && view && (_jsx(ContextMenu, { x: menu.x, y: menu.y, label: resolved.strings.editor.menu, groups: menuGroups(view), onClose: (back) => {
536
+ }, attaching: attaching, attachButton: attachButton, end: _jsxs("div", { className: "edit-corner", "aria-busy": leaving && saves.state === 'saving' ? true : undefined, "data-blocked": blocked ? '' : undefined, children: [_jsx(SaveStatus, { saves: saves, blocked: blocked, deciding: deciding, open: noticeOpen, setOpen: setNoticeOpen, host: resolved, onDecided: () => view?.focus() }), corner ?? _jsx(NoteActions, { display: buttons, text: () => (view ? output(view.state) : given) })] }, blocked ? `blocked-${refusals}` : 'corner') }) }), menu && view && (_jsx(ContextMenu, { x: menu.x, y: menu.y, label: resolved.strings.editor.menu, groups: menuGroups(view), onClose: (back) => {
517
537
  setMenu(null);
518
538
  if (back)
519
539
  view.focus();
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Where in a block's source a click on its drawing lands (#102): the text under the pointer is
3
+ * found in the source the block was drawn from, so the caret goes where the author clicked once
4
+ * the source is revealed, rather than to the end of the block's first line.
5
+ */
6
+ /** The text node and offset under a point of the page, where the browser can say. */
7
+ export declare function caretAt(x: number, y: number): {
8
+ node: Node;
9
+ offset: number;
10
+ } | null;
11
+ /**
12
+ * The offset in `source` that draws `offset` in the text node `node` of the drawing `root`, or
13
+ * `null` when the text there is not written in the source as it is drawn — a formula's typesetting,
14
+ * an escaped character, a note an embed draws — and the click cannot be mapped.
15
+ */
16
+ export declare function sourceOffsetOf(root: HTMLElement, source: string, node: Node, offset: number): number | null;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Where in a block's source a click on its drawing lands (#102): the text under the pointer is
3
+ * found in the source the block was drawn from, so the caret goes where the author clicked once
4
+ * the source is revealed, rather than to the end of the block's first line.
5
+ */
6
+ /** How much of the text beside the pointer is looked for when its whole line is not in the source. */
7
+ const WINDOW = 24;
8
+ /** The least of it looked for: a letter or two is found anywhere. */
9
+ const LEAST = 3;
10
+ /** The text node and offset under a point of the page, where the browser can say. */
11
+ export function caretAt(x, y) {
12
+ const position = document.caretPositionFromPoint?.(x, y);
13
+ if (position)
14
+ return { node: position.offsetNode, offset: position.offset };
15
+ const range = document.caretRangeFromPoint?.(x, y);
16
+ return range ? { node: range.startContainer, offset: range.startOffset } : null;
17
+ }
18
+ /**
19
+ * The offset in `source` that draws `offset` in the text node `node` of the drawing `root`, or
20
+ * `null` when the text there is not written in the source as it is drawn — a formula's typesetting,
21
+ * an escaped character, a note an embed draws — and the click cannot be mapped.
22
+ */
23
+ export function sourceOffsetOf(root, source, node, offset) {
24
+ if (node.nodeType !== Node.TEXT_NODE || !root.contains(node))
25
+ return null;
26
+ const data = node.textContent ?? '';
27
+ const at = Math.max(0, Math.min(offset, data.length));
28
+ // A drawn paragraph joins its lines, which the source writes each after its own marker (`> `).
29
+ const lineStart = data.lastIndexOf('\n', at - 1) + 1;
30
+ const lineEnd = data.indexOf('\n', at) === -1 ? data.length : data.indexOf('\n', at);
31
+ const before = textBefore(root, node) + data.slice(0, lineStart);
32
+ const left = data.slice(Math.max(lineStart, at - WINDOW), at);
33
+ const right = data.slice(at, Math.min(lineEnd, at + WINDOW));
34
+ const candidates = [
35
+ { text: data.slice(lineStart, lineEnd), caret: at - lineStart },
36
+ { text: left + right, caret: left.length },
37
+ { text: left, caret: left.length },
38
+ { text: right, caret: 0 },
39
+ ];
40
+ for (const [index, { text, caret }] of candidates.entries()) {
41
+ if (!text.trim() || (index > 0 && text.trim().length < LEAST))
42
+ continue;
43
+ const found = nth(source, text, count(before, text));
44
+ if (found !== -1)
45
+ return found + caret;
46
+ }
47
+ return null;
48
+ }
49
+ /** The text drawn before `node` within `root`. */
50
+ function textBefore(root, node) {
51
+ const range = document.createRange();
52
+ range.setStart(root, 0);
53
+ range.setEnd(node, 0);
54
+ return range.toString();
55
+ }
56
+ function count(text, piece) {
57
+ let found = 0;
58
+ for (let at = text.indexOf(piece); at !== -1; at = text.indexOf(piece, at + 1))
59
+ found++;
60
+ return found;
61
+ }
62
+ /**
63
+ * Where the `n`-th occurrence of `piece` (from 0) starts in `text`: the same text drawn twice is
64
+ * mapped to the occurrence it draws, and to the last one when the drawing shows more than the source.
65
+ */
66
+ function nth(text, piece, n) {
67
+ let last = -1;
68
+ for (let at = text.indexOf(piece), seen = 0; at !== -1; at = text.indexOf(piece, at + 1), seen++) {
69
+ if (seen === n)
70
+ return at;
71
+ last = at;
72
+ }
73
+ return last;
74
+ }
package/dist/edit/live.js CHANGED
@@ -19,6 +19,7 @@ import { highlightLanguage } from '../highlight.js';
19
19
  import { analyze } from './analyze.js';
20
20
  import { DEFAULT_METRICS, estimateHeight, lineHeightOf, measureLines, measuredHeight, rememberHeight, withinReason } from './heights.js';
21
21
  import { AddProperties, CalloutBlock, ImageBlock, PropertiesBlock, reveal, Snippet, TableBlock } from './blocks.js';
22
+ import { caretAt, sourceOffsetOf } from './click-source.js';
22
23
  import { LanguageControl } from './LanguageControl.js';
23
24
  import { toggleTaskBox } from './commands.js';
24
25
  export const editorEnvironment = Facet.define({
@@ -263,8 +264,12 @@ class BlockWidget extends WidgetType {
263
264
  if (!widget.revealsOnClick)
264
265
  return;
265
266
  event.preventDefault();
266
- // At the end of its first line: after a fence's language, a callout's title, a formula's `$$`.
267
- reveal(view, view.state.doc.lineAt(blockStart(view, element, widget.key)).to);
267
+ // Where the text clicked is written in the source (#102); else at the end of its first line,
268
+ // after a callout's title or a diagram's fence.
269
+ const start = blockStart(view, element, widget.key);
270
+ const caret = caretAt(event.clientX, event.clientY);
271
+ const offset = caret ? sourceOffsetOf(element, widget.source, caret.node, caret.offset) : null;
272
+ reveal(view, offset === null ? view.state.doc.lineAt(start).to : Math.min(start + offset, view.state.doc.length));
268
273
  });
269
274
  // A link inside a drawn block is followed with a modifier only, as in the text.
270
275
  element.addEventListener('click', (event) => {
package/dist/strings.d.ts CHANGED
@@ -187,6 +187,8 @@ export interface EditorWords {
187
187
  readonly retry: string;
188
188
  /** Said when the author asked to go back to reading and the last change is not saved. */
189
189
  readonly stayInEdit: string;
190
+ /** Said while a refused save waits for the author's choice before the note is written in again (#101). */
191
+ readonly chooseToEdit: string;
190
192
  }
191
193
  /** The words MemorySmith shows in English, used for every word a host does not give. */
192
194
  export declare const ENGLISH: NoteStrings;
package/dist/strings.js CHANGED
@@ -179,6 +179,7 @@ export const ENGLISH = {
179
179
  saving: 'Saving…',
180
180
  retry: 'Try again',
181
181
  stayInEdit: 'Not saved: the note stays in edit',
182
+ chooseToEdit: 'Choose what to do with it to keep writing',
182
183
  },
183
184
  loading: 'Loading…',
184
185
  failed: 'Something went wrong.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorysmithapp/markdown-renderer",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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.7.0",
82
+ "@memorysmithapp/markdown-spec": "0.7.1",
83
83
  "@types/hast": "3.0.5",
84
84
  "@types/react": "19.2.18",
85
85
  "@types/react-dom": "19.2.4",