@memorysmithapp/markdown-renderer 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ import 'katex/dist/katex.min.css';
18
18
  <NoteBody
19
19
  markdown={note} // the note as you keep it, frontmatter included
20
20
  resolve={(name) => addressOfTheOneNoteNamed(name)} // or null: the link is pending
21
- keeps={(name) => notebookKeepsAFile(name)} // only an embed reaches a file
21
+ keeps={(name) => notebookKeepsAFile(name)} // reached by an embed, and by an image with no host: ![](../assets/x.png)
22
22
  fileOf={(name) => describe(name)} // { mimeType, bytes, url, description }: drawn by its type, as MemorySmith does
23
23
  renderLink={({ href, className, children }) => <a href={href} className={className} onClick={follow(href)}>{children}</a>} // follow: your router's navigate, captured where it is
24
24
  loadNote={(name) => fetchTheBodyOf(name)} // for an embedded note; the package never fetches
@@ -1,4 +1,4 @@
1
- import { type AnchorHTMLAttributes, type ReactNode } from 'react';
1
+ import { type AnchorHTMLAttributes, type ImgHTMLAttributes, type ReactNode } from 'react';
2
2
  import { type FootnoteRef } from './footnotes.js';
3
3
  import type { TaskSave } from './task-saves.js';
4
4
  /**
@@ -40,6 +40,17 @@ export interface MarkdownProps {
40
40
  type WithNode<T> = T & {
41
41
  node?: unknown;
42
42
  };
43
+ /**
44
+ * An image, with the dimensions the specification puts in its alt text
45
+ * (§3.14). What precedes the pipe is the description and is never dropped;
46
+ * what follows it is width, or width and height, in CSS pixels, or a width as
47
+ * a share of the page at most (#108), and never appears as text. A value that is neither stays part of the description,
48
+ * because deleting an accessibility label is the worse failure.
49
+ *
50
+ * A destination that names no host is a file the notebook keeps, by the last segment of its path
51
+ * (§5.8, #104): it is drawn as the embed of that file is, and never asked of a path under the page.
52
+ */
53
+ export declare function MarkdownImage({ alt, node: _node, ...rest }: WithNode<ImgHTMLAttributes<HTMLImageElement>>): import("react").JSX.Element;
43
54
  export declare function MarkdownAnchor({ href, children, node: _node, ...rest }: WithNode<AnchorHTMLAttributes<HTMLAnchorElement>>): string | number | bigint | boolean | import("react").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | null | undefined;
44
55
  export declare function Markdown({ children, source, onToggleTask, taskSave, writable, footnotes }: MarkdownProps): import("react").JSX.Element;
45
56
  export {};
package/dist/Markdown.js CHANGED
@@ -7,9 +7,10 @@ import rehypeKatex from 'rehype-katex';
7
7
  import { followable } from './address.js';
8
8
  import { CopyButton } from './CopyButton.js';
9
9
  import { fileAddress, fileKind, formatBytes, opensInBrowser, rendersAs } from './file-types.js';
10
+ import { keptImageName } from './kept-image.js';
10
11
  import { highlightLanguage, highlightTree, languageToLoad, loadGrammar } from './highlight.js';
11
12
  import { useHost } from './host.js';
12
- import { readDimensions, readImageAlt } from './image-dimensions.js';
13
+ import { readDimensions, readImageAlt, sizedAttributes } from './image-dimensions.js';
13
14
  import { choiceTargetOf, LinkChoice } from './LinkChoice.js';
14
15
  import { toUnixNewlines } from './markdown.js';
15
16
  import { MermaidDiagram } from './MermaidDiagram.js';
@@ -20,13 +21,21 @@ import { ordinalAt } from './tasklist.js';
20
21
  /**
21
22
  * An image, with the dimensions the specification puts in its alt text
22
23
  * (§3.14). What precedes the pipe is the description and is never dropped;
23
- * what follows it is width, or width and height, in CSS pixels, and never
24
- * appears as text. A value that is neither stays part of the description,
24
+ * what follows it is width, or width and height, in CSS pixels, or a width as
25
+ * a share of the page at most (#108), and never appears as text. A value that is neither stays part of the description,
25
26
  * because deleting an accessibility label is the worse failure.
27
+ *
28
+ * A destination that names no host is a file the notebook keeps, by the last segment of its path
29
+ * (§5.8, #104): it is drawn as the embed of that file is, and never asked of a path under the page.
26
30
  */
27
- function MarkdownImage({ alt, node: _node, ...rest }) {
31
+ export function MarkdownImage({ alt, node: _node, ...rest }) {
28
32
  const read = readImageAlt(alt ?? '');
29
- return (_jsx("img", { ...rest, alt: read.description, ...(read.width === null ? {} : { width: read.width }), ...(read.height === null ? {} : { height: read.height }) }));
33
+ const name = typeof rest.src === 'string' ? keptImageName(rest.src) : null;
34
+ if (name !== null)
35
+ return _jsx(Attachment, { name: name, size: read, description: read.description, title: rest.title });
36
+ // The description is the hover hint too, so a person who sees the picture can read it (#109); a title written wins.
37
+ const title = rest.title || read.description || undefined;
38
+ return _jsx("img", { ...rest, alt: read.description, title: title, ...sizedAttributes(read) });
30
39
  }
31
40
  /**
32
41
  * A link to a place in the same note: a footnote and its way back (§7.12),
@@ -56,26 +65,19 @@ function InPageAnchor({ href, children, ...rest }) {
56
65
  };
57
66
  return (_jsx("a", { href: href, onClick: follow, ...rest, children: children }));
58
67
  }
59
- /**
60
- * A file the notebook keeps, where a note embedded it, drawn by its type — an
61
- * image, audio, video, or a card — by the host's `renderAttachment`, else from
62
- * what its `fileOf` says (#77). A name the notebook keeps nothing under is
63
- * reported the way a pending link is: a notebook is read most while it is
64
- * being written (§5.8).
65
- */
66
- function Attachment({ name, width, height }) {
68
+ function Attachment({ name, size, ...written }) {
67
69
  const host = useHost();
68
70
  if (!host.keeps(name))
69
- return _jsx(AttachmentMissing, { name: name });
71
+ return _jsx(AttachmentMissing, { name: name, label: written.description });
70
72
  if (host.renderAttachment)
71
- return _jsx(_Fragment, { children: host.renderAttachment({ name, width, height }) });
73
+ return _jsx(_Fragment, { children: host.renderAttachment({ name, ...size, ...written }) });
72
74
  if (!host.fileOf)
73
- return _jsx("span", { className: "attachment-loading", children: name });
74
- return _jsx(KeptAttachment, { name: name, width: width, height: height });
75
+ return _jsx("span", { className: "attachment-loading", children: written.description || name });
76
+ return _jsx(KeptAttachment, { name: name, size: size, ...written });
75
77
  }
76
- function AttachmentMissing({ name }) {
78
+ function AttachmentMissing({ name, label }) {
77
79
  const host = useHost();
78
- return (_jsx("span", { className: "attachment-missing", title: host.strings.attachmentMissing, children: name }));
80
+ return (_jsx("span", { className: "attachment-missing", title: host.strings.attachmentMissing, "data-name": label ? name : undefined, children: label || name }));
79
81
  }
80
82
  function isThenable(value) {
81
83
  return typeof value === 'object' && value !== null && typeof value.then === 'function';
@@ -129,30 +131,36 @@ function openInTab(event, url) {
129
131
  * included — a card with what it is, its size, and the ways out of the page: Open only for what a
130
132
  * browser shows, Download always. A theme may draw it through the `attachment` slot.
131
133
  */
132
- function KeptAttachment({ name, width, height }) {
134
+ function KeptAttachment({ name, size: declared, ...written }) {
133
135
  const host = useHost();
134
136
  const slot = useSlot('attachment');
135
137
  const described = useKeptFile(name);
136
138
  if (described === undefined)
137
- return _jsx("span", { className: "attachment-loading", children: name });
139
+ return _jsx("span", { className: "attachment-loading", children: written.description || name });
138
140
  const url = described ? fileAddress(described.url) : '';
139
141
  if (!described || url === '')
140
- return _jsx(AttachmentMissing, { name: name });
141
- const file = { ...described, url, downloadUrl: fileAddress(described.downloadUrl ?? described.url) || url };
142
+ return _jsx(AttachmentMissing, { name: name, label: written.description });
143
+ // What the note wrote of the file is what the reader gets of it; the file's own when it wrote nothing.
144
+ const description = written.description || described.description;
145
+ const file = {
146
+ ...described,
147
+ url,
148
+ downloadUrl: fileAddress(described.downloadUrl ?? described.url) || url,
149
+ ...(description ? { description } : {}),
150
+ };
142
151
  const shape = rendersAs(file.mimeType);
143
152
  const kind = fileKind(file.mimeType, host.strings.fileKinds);
144
153
  const size = formatBytes(file.bytes, host.strings.locale);
145
154
  const opens = opensInBrowser(file.mimeType);
146
155
  if (slot)
147
- return _jsx(_Fragment, { children: drawSlot(slot, { name, file, shape, kind, size, opens, width, height }) });
148
- // The attributes reserve the box before the bytes arrive; a height is a style too, since the
149
- // surface gives an image `height: auto`, which two numbers overrule (§3.14).
150
- const sized = {
151
- ...(width === null ? {} : { width }),
152
- ...(height === null ? {} : { height, style: { height: `${height}px` } }),
153
- };
154
- if (shape === 'image')
155
- return _jsx("img", { className: "attachment-image", src: file.url, alt: file.description || name, ...sized });
156
+ return _jsx(_Fragment, { children: drawSlot(slot, { name, file, shape, kind, size, opens, ...declared, title: written.title ?? null }) });
157
+ const sized = sizedAttributes(declared);
158
+ if (shape === 'image') {
159
+ // Described, its description is also the hover hint, a title written winning (#109); a picture
160
+ // with none takes its name as its `alt` and no hint, which would only say the name again.
161
+ const title = written.title || file.description;
162
+ return _jsx("img", { className: "attachment-image", src: file.url, alt: file.description || name, ...(title ? { title } : {}), ...sized });
163
+ }
156
164
  if (shape === 'audio')
157
165
  return _jsx("audio", { className: "attachment-player", controls: true, src: file.url });
158
166
  if (shape === 'video')
@@ -178,7 +186,7 @@ export function MarkdownAnchor({ href, children, node: _node, ...rest }) {
178
186
  const at = address.lastIndexOf('|');
179
187
  const measured = at === -1 ? null : readDimensions(address.slice(at + 1));
180
188
  const name = measured ? address.slice(0, at) : address;
181
- return _jsx(Attachment, { name: name, width: measured?.width ?? null, height: measured?.height ?? null });
189
+ return _jsx(Attachment, { name: name, size: measured ?? { width: null, height: null, percent: null } });
182
190
  }
183
191
  if (href?.startsWith('pending:')) {
184
192
  const pending = (label) => (_jsx("span", { className: "wikilink-pending", title: host.strings.pendingLink, children: label }));
package/dist/diagnose.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * note, or beside an editor. KaTeX checks a formula as `rehype-katex` would typeset it, and Mermaid
12
12
  * reads a diagram as the page would draw it, loaded only when a note carries one.
13
13
  */
14
+ import { keptImageName } from './kept-image.js';
14
15
  import katex from 'katex';
15
16
  import remarkParse from 'remark-parse';
16
17
  import { unified } from 'unified';
@@ -36,7 +37,7 @@ export const NOTICES = {
36
37
  'raw-html': { severity: 'notice', section: '7.9', message: 'Raw HTML is drawn as text, tags and all.' },
37
38
  'strikethrough-single-tilde': { severity: 'notice', section: '4.3', message: 'One tilde strikes nothing: the tildes stay on the page. Strikethrough is ~~two~~.' },
38
39
  'link-pending': { severity: 'hint', section: '7.4', message: 'No note answers this link yet, so it is drawn as pending.' },
39
- 'embed-pending': { severity: 'hint', section: '7.3', message: 'No note and no kept file answers this embed, so it is drawn as a pending link.' },
40
+ 'embed-pending': { severity: 'hint', section: '7.3', message: 'No note and no kept file answers this embed, or no kept file this image, so it is drawn as pending.' },
40
41
  };
41
42
  /** A footnote reference left as text, which is one with no definition (§7.12). */
42
43
  const UNDEFINED_REFERENCE = /\[\^[^\]\s]+\]/g;
@@ -106,6 +107,7 @@ export async function diagnose(markdown, options = {}) {
106
107
  const diagrams = [];
107
108
  const referenced = new Set();
108
109
  const definitions = [];
110
+ const images = [];
109
111
  walk(tree, (node) => {
110
112
  const at = span(node);
111
113
  if (!at)
@@ -136,6 +138,11 @@ export async function diagnose(markdown, options = {}) {
136
138
  else if (node.type === 'footnoteDefinition') {
137
139
  definitions.push(node);
138
140
  }
141
+ else if (node.type === 'image') {
142
+ const name = keptImageName(node.url ?? '');
143
+ if (name !== null)
144
+ images.push({ name, at });
145
+ }
139
146
  });
140
147
  for (const definition of definitions) {
141
148
  const at = span(definition);
@@ -164,6 +171,13 @@ export async function diagnose(markdown, options = {}) {
164
171
  }
165
172
  });
166
173
  pending(hidden, split, options, report);
174
+ // An image whose destination names no host names a kept file, and only a kept file (§5.8, #104).
175
+ if (options.resolve || options.reaches) {
176
+ const keeps = options.keeps ?? (() => false);
177
+ for (const image of images)
178
+ if (!keeps(image.name))
179
+ report('embed-pending', image.at[0], image.at[1], image.name);
180
+ }
167
181
  if (diagrams.length > 0 && options.diagrams !== false) {
168
182
  const mermaid = (await import('mermaid')).default;
169
183
  for (const diagram of diagrams) {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The editor's own menu on a right-click in edit mode (#81): what the toolbar formats and inserts,
3
- * where the author is, with the clipboard's three verbs. It is a `role="menu"` reached by keyboard,
3
+ * where the author is, after the clipboard's three verbs. It is a `role="menu"` reached by keyboard,
4
4
  * kept inside the window, and closed by Escape, a click outside it, a scroll, or an entry run.
5
5
  */
6
6
  import { type ReactNode } from 'react';
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * The editor's own menu on a right-click in edit mode (#81): what the toolbar formats and inserts,
4
- * where the author is, with the clipboard's three verbs. It is a `role="menu"` reached by keyboard,
4
+ * where the author is, after the clipboard's three verbs. It is a `role="menu"` reached by keyboard,
5
5
  * kept inside the window, and closed by Escape, a click outside it, a scroll, or an entry run.
6
6
  */
7
7
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
@@ -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
@@ -253,7 +261,7 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
253
261
  /** Where the editing menu is open, in the window's coordinates (#81). */
254
262
  const [menu, setMenu] = useState(null);
255
263
  const openMenu = useRef((x, y) => setMenu({ x, y }));
256
- /** The menu's groups, from the same commands as the toolbar: Format with a selection, Insert, Edit. */
264
+ /** The menu's groups, from the same commands as the toolbar: Edit first (#110), Format with a selection, Insert. */
257
265
  const menuGroups = (target) => {
258
266
  const words = resolved.strings.editor;
259
267
  const item = (name, action, shortcut) => ({
@@ -300,7 +308,8 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
300
308
  { name: 'attach', label: words.attach, icon: ICONS.attach, run: () => setAttachOpen(true) },
301
309
  ],
302
310
  });
303
- groups.push({
311
+ // The clipboard's verbs open the menu, as they open every other one a person knows (#110).
312
+ groups.unshift({
304
313
  label: words.menuEdit,
305
314
  items: [
306
315
  {
@@ -336,7 +345,9 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
336
345
  // The note as edit mode opened it, as the editor holds its text: lines joined by `\n`.
337
346
  const opened = useRef(given.replace(/\r\n?/g, '\n'));
338
347
  const lineBreak = lineBreakOf(given);
339
- const readOnly = !editable || lineBreak === null;
348
+ const locked = !editable || lineBreak === null;
349
+ // Held while the author decides: the caret still moves, and a key that would write asks again.
350
+ const readOnly = locked || deciding;
340
351
  /**
341
352
  * The editor splits its text on any line break, so a paste or a block written with `\n` is lines
342
353
  * like any other, and writes the note back with the break it was written with: what a host
@@ -353,7 +364,7 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
353
364
  state: EditorState.create({
354
365
  doc: given,
355
366
  extensions: [
356
- access.current.of([EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]),
367
+ access.current.of([EditorState.readOnly.of(readOnly), EditorView.editable.of(!locked)]),
357
368
  environment.current.of(editorEnvironment.of({ host: resolved })),
358
369
  history(),
359
370
  markdown(),
@@ -373,6 +384,16 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
373
384
  return true;
374
385
  }),
375
386
  EditorView.domEventHandlers({
387
+ // A key that would write, while the note waits for the author's choice, shows the choice again (#101).
388
+ keydown: (event) => {
389
+ if (!latest.current.deciding || event.ctrlKey || event.metaKey || event.altKey)
390
+ return false;
391
+ if (event.key.length !== 1 && !WRITING_KEYS.has(event.key))
392
+ return false;
393
+ event.preventDefault();
394
+ latest.current.ask();
395
+ return true;
396
+ },
376
397
  // The editing menu where the pointer is; Shift keeps the browser's, and so do the
377
398
  // drawn blocks' own fields, which are the browser's to edit (#81).
378
399
  contextmenu: (event, target) => {
@@ -436,8 +457,8 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
436
457
  view?.dispatch({ effects: environment.current.reconfigure(editorEnvironment.of({ host: resolved })) });
437
458
  }, [view, resolved]);
438
459
  useEffect(() => {
439
- view?.dispatch({ effects: access.current.reconfigure([EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]) });
440
- }, [view, readOnly]);
460
+ view?.dispatch({ effects: access.current.reconfigure([EditorState.readOnly.of(readOnly), EditorView.editable.of(!locked)]) });
461
+ }, [view, readOnly, locked]);
441
462
  // A note the host changed — a tick saved, another device — is taken in as the change it is.
442
463
  useEffect(() => {
443
464
  if (!view)
@@ -513,7 +534,7 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
513
534
  else
514
535
  setRefusal(null);
515
536
  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) => {
537
+ }, 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
538
  setMenu(null);
518
539
  if (back)
519
540
  view.focus();
@@ -29,12 +29,28 @@ export declare function CalloutBlock({ view, text, marker }: {
29
29
  fold: '+' | '-' | null;
30
30
  };
31
31
  }): import("react").JSX.Element;
32
+ /** The narrowest an image is sized to, by a field or the handle: something a reader still sees (#105). */
33
+ export declare const MIN_IMAGE_SIZE = 16;
32
34
  /**
33
- * An image drawn in edit mode with the size it is drawn at set in place (#98): a width in pixels,
34
- * its height following, a handle at its corner that drags it, and a button back to its own size.
35
- * Each writes the dimensions of its notation — `![description|300](…)` or `![[photo.jpg|300]]` —
36
- * and no other byte. The controls appear once an image is drawn inside it, so an embed of a file
37
- * that is not an image, or a host's own drawing without one, offers none.
35
+ * An image drawn in edit mode with what its notation writes set in place (#98, #106): its width and
36
+ * its height with a lock between them — locked, the height follows and only the width is written,
37
+ * `|300`; open, both are, `|300x180` — the width's unit, pixels or a share of the page (`|50%`,
38
+ * #108), its description for a Markdown image, a button back to its own size, and a handle at its
39
+ * corner that drags it, keeping the proportion or, with the lock open or Shift held, not. Each
40
+ * writes the dimensions or the description of its notation, and no other byte.
41
+ *
42
+ * A field selects what it holds when it takes the focus, so typing replaces it, and writes on Enter
43
+ * or when it is left; Escape leaves the note as it is. A value outside the range a reader can see
44
+ * — from 16 pixels to the column or the image's own width, the wider — is refused where it was
45
+ * typed, never brought into the range and written (#105).
46
+ *
47
+ * The controls stand under the picture, never over it, and nothing that starts on them or in the
48
+ * moment after reaches the click that shows the image's source (#107): they are all marked as
49
+ * controls, gaps included, and the frame is marked busy while one of them has the focus, while the
50
+ * handle is dragged and for a moment after a pointer or a touch on them.
51
+ *
52
+ * The controls appear once an image is drawn inside it, so an embed of a file that is not an image,
53
+ * or a host's own drawing without one, offers none.
38
54
  */
39
55
  export declare function ImageBlock({ view, source, children }: {
40
56
  view: EditorView;