@memorysmithapp/markdown-renderer 0.7.1 → 0.8.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.
@@ -17,8 +17,8 @@ import { isTrue, readProperties } from '../properties.js';
17
17
  import { Transclusion } from '../Transclusion.js';
18
18
  import { splitEmbeds } from '../transclusion.js';
19
19
  import { wikilinkAddress } from '../wikilink-address.js';
20
- import { ICON_OWN_SIZE } from '../mode-icons.js';
21
- import { MAX_IMAGE_WIDTH, readImageSize, resizeImage } from './image-size.js';
20
+ import { ICON_OWN_SIZE, ICON_RATIO_FREE, ICON_RATIO_LOCKED, ICON_RESIZE } from '../mode-icons.js';
21
+ import { describeImage, MAX_IMAGE_WIDTH, readImageSize, resizeImage } from './image-size.js';
22
22
  import { addEntry, readFrontmatter, removeEntry, renameKey, setList, setScalar, } from './frontmatter.js';
23
23
  import { addColumn, addRow, alignColumn, columnCount, readTable, removeColumn, removeRow, setCell, } from './table.js';
24
24
  /** Whether a block's edit is being applied: the block is drawn anew, and the field it held lets the focus go. */
@@ -54,8 +54,8 @@ export function Snippet({ text }) {
54
54
  return (_jsx(_Fragment, { children: splitEmbeds(body, host.keeps).map((segment, index) => segment.kind === 'text' ? (_jsx(Markdown, { children: resolveWikilinks(segment.text, (name) => wikilinkAddress(host, name), host.keeps) }, index)) : (_jsx(Transclusion, { target: segment.target, anchor: segment.anchor }, index))) }));
55
55
  }
56
56
  /** A small button of the editor's own, outside the note's notation. */
57
- function Control({ label, onClick, children, pressed }) {
58
- return (_jsx("button", { type: "button", className: "edit-control", title: label, "aria-label": label, "aria-pressed": pressed, "data-ms-control": "", onMouseDown: (event) => event.preventDefault(), onClick: onClick, children: children }));
57
+ function Control({ label, onClick, children, pressed, disabled, focusable = false, }) {
58
+ return (_jsx("button", { type: "button", className: "edit-control", title: label, "aria-label": label, "aria-pressed": pressed, disabled: disabled, "data-ms-control": "", onMouseDown: focusable ? undefined : (event) => event.preventDefault(), onClick: onClick, children: children }));
59
59
  }
60
60
  // ── Callouts ─────────────────────────────────────────────────────────────────
61
61
  /** The types a callout may take from the toolbar: the families the default theme draws. */
@@ -74,14 +74,76 @@ export function CalloutBlock({ view, text, marker }) {
74
74
  return (_jsxs("div", { className: "edit-block edit-callout", children: [_jsxs("div", { className: "edit-block-controls", "data-ms-control": "", children: [_jsx("select", { "aria-label": words.calloutType, title: words.calloutType, value: marker.type, onChange: (event) => rewrite(event.target.value, marker.fold), children: types.map((type) => (_jsx("option", { value: type, children: strings.callouts[type] ?? type }, type))) }), _jsxs("select", { "aria-label": words.fold, title: words.fold, value: marker.fold ?? '', onChange: (event) => rewrite(marker.type, (event.target.value || null)), children: [_jsx("option", { value: "", children: words.foldNone }), _jsx("option", { value: "+", children: words.foldOpen }), _jsx("option", { value: "-", children: words.foldClosed })] })] }), _jsx(Snippet, { text: text })] }));
75
75
  }
76
76
  // ── Images ───────────────────────────────────────────────────────────────────
77
- /** The narrowest an image is dragged to, so its handle stays within reach. */
78
- const MIN_DRAGGED_WIDTH = 24;
77
+ /** The narrowest an image is sized to, by a field or the handle: something a reader still sees (#105). */
78
+ export const MIN_IMAGE_SIZE = 16;
79
79
  /**
80
- * An image drawn in edit mode with the size it is drawn at set in place (#98): a width in pixels,
81
- * its height following, a handle at its corner that drags it, and a button back to its own size.
82
- * Each writes the dimensions of its notation — `![description|300](…)` or `![[photo.jpg|300]]` —
83
- * and no other byte. The controls appear once an image is drawn inside it, so an embed of a file
84
- * that is not an image, or a host's own drawing without one, offers none.
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();
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). */
86
+ const CONTROLS_GUARD_MS = 800;
87
+ /** How long after a control of an image writes the image is held where it was on the screen (#107). */
88
+ const SCROLL_HOLD_MS = 600;
89
+ /** A move of the image larger than this, in that moment, is the browser's and not the author's. */
90
+ const SCROLL_JUMP = 120;
91
+ /**
92
+ * Holds the image where it is on the screen while one of its controls writes (#107). The write draws
93
+ * the image again for its new source, which takes the control that had the focus out of the page,
94
+ * and Chrome on a touch screen then scrolls the page a screen or more away, with no script asking
95
+ * it to: the image and its controls were gone from sight (*observed* in the playground, Pixel 7,
96
+ * 2026-10-16). For a moment after the write, a move of the image larger than a jump is undone, by
97
+ * scrolling what the editor scrolls in — a pane of the host, or the window — by as much.
98
+ */
99
+ function holdInPlace(view, block, run) {
100
+ const top = block.getBoundingClientRect().top;
101
+ let scroller = view.scrollDOM;
102
+ while (scroller && !(scroller.scrollHeight > scroller.clientHeight && /(auto|scroll)/.test(getComputedStyle(scroller).overflowY)))
103
+ scroller = scroller.parentElement;
104
+ const scrolled = () => (scroller ? scroller.scrollTop : window.scrollY);
105
+ const before = scrolled();
106
+ const back = () => {
107
+ // The editor lets go of a block it scrolled out of its viewport: the page goes back to where it was.
108
+ const moved = block.isConnected ? block.getBoundingClientRect().top - top : before - scrolled();
109
+ if (Math.abs(moved) <= SCROLL_JUMP)
110
+ return;
111
+ if (scroller)
112
+ scroller.scrollBy(0, moved);
113
+ else
114
+ window.scrollBy(0, moved);
115
+ };
116
+ run();
117
+ window.addEventListener('scroll', back, true);
118
+ setTimeout(() => window.removeEventListener('scroll', back, true), SCROLL_HOLD_MS);
119
+ }
120
+ /** A number as a field holds it: digits and nothing else, or `null`. */
121
+ function wholeNumber(text) {
122
+ return /^\d{1,6}$/.test(text.trim()) ? Number(text.trim()) : null;
123
+ }
124
+ /**
125
+ * An image drawn in edit mode with what its notation writes set in place (#98, #106): its width and
126
+ * its height with a lock between them — locked, the height follows and only the width is written,
127
+ * `|300`; open, both are, `|300x180` — the width's unit, pixels or a share of the page (`|50%`,
128
+ * #108), its description for a Markdown image, a button back to its own size, and a handle at its
129
+ * corner that drags it, keeping the proportion or, with the lock open or Shift held, not. Each
130
+ * writes the dimensions or the description of its notation, and no other byte.
131
+ *
132
+ * A field selects what it holds when it takes the focus, so typing replaces it, and writes on Enter
133
+ * or when it is left; Escape leaves the note as it is. A value outside the range a reader can see
134
+ * — from 16 pixels to the column or the image's own width, the wider — is refused where it was
135
+ * typed, never brought into the range and written (#105).
136
+ *
137
+ * The controls stand under the picture, never over it, and nothing that starts on them or in the
138
+ * moment after reaches the click that shows the image's source (#107): they are all marked as
139
+ * controls, gaps included, and the frame is marked busy while one of them has the focus, while the
140
+ * handle is dragged and for a moment after a pointer or a touch on them.
141
+ *
142
+ * Sized in place, as a table is edited in place, the image shows its source only from a button among
143
+ * its controls or when the caret reaches it from the keyboard; a click on the picture shows nothing (#112).
144
+ *
145
+ * The controls appear once an image is drawn inside it, so an embed of a file that is not an image,
146
+ * or a host's own drawing without one, offers none.
85
147
  */
86
148
  export function ImageBlock({ view, source, children }) {
87
149
  const words = useHost().strings.editor;
@@ -89,9 +151,14 @@ export function ImageBlock({ view, source, children }) {
89
151
  const size = readImageSize(source);
90
152
  const [image, setImage] = useState(null);
91
153
  const [natural, setNatural] = useState(null);
92
- const [typed, setTyped] = useState(null);
154
+ const [locked, setLocked] = useState(() => size?.height == null);
155
+ const [unit, setUnit] = useState(() => (size?.percent == null ? 'px' : '%'));
156
+ const [typed, setTyped] = useState({});
157
+ const [refused, setRefused] = useState(null);
93
158
  const [dragged, setDragged] = useState(null);
159
+ const [shift, setShift] = useState(false);
94
160
  const drag = useRef(null);
161
+ const guard = useRef(null);
95
162
  // An embed's image arrives when the host answers; the controls wait for it.
96
163
  useEffect(() => {
97
164
  const element = frame.current;
@@ -108,74 +175,335 @@ export function ImageBlock({ view, source, children }) {
108
175
  useEffect(() => {
109
176
  if (!image)
110
177
  return;
111
- const read = () => setNatural(image.naturalWidth || null);
178
+ const read = () => setNatural(image.naturalWidth && image.naturalHeight ? { width: image.naturalWidth, height: image.naturalHeight } : null);
112
179
  read();
113
180
  image.addEventListener('load', read);
114
181
  return () => image.removeEventListener('load', read);
115
182
  }, [image]);
116
- const resize = (width) => {
183
+ useEffect(() => () => {
184
+ if (guard.current)
185
+ clearTimeout(guard.current);
186
+ }, []);
187
+ // The handle and the controls are placed by the picture itself, not by the frame, which also holds
188
+ // its margins and, in an embed, a paragraph's: its bottom right corner is where the handle goes.
189
+ useEffect(() => {
190
+ const element = frame.current;
191
+ if (!image || !element)
192
+ return;
193
+ const place = () => {
194
+ const outer = element.getBoundingClientRect();
195
+ const inner = image.getBoundingClientRect();
196
+ element.style.setProperty('--edit-image-bottom', `${Math.round(inner.bottom - outer.top)}px`);
197
+ element.style.setProperty('--edit-image-right', `${Math.round(inner.right - outer.left)}px`);
198
+ };
199
+ place();
200
+ image.addEventListener('load', place);
201
+ if (typeof ResizeObserver === 'undefined')
202
+ return () => image.removeEventListener('load', place);
203
+ const observer = new ResizeObserver(place);
204
+ observer.observe(image);
205
+ observer.observe(element);
206
+ return () => {
207
+ image.removeEventListener('load', place);
208
+ observer.disconnect();
209
+ };
210
+ }, [image]);
211
+ /** Marks the frame busy for a moment, so the click a touch or a pointer leaves behind reveals nothing (#107). */
212
+ const hold = () => {
213
+ const element = frame.current;
214
+ if (!element)
215
+ return;
216
+ element.dataset['msBusy'] = '';
217
+ if (guard.current)
218
+ clearTimeout(guard.current);
219
+ guard.current = setTimeout(() => {
220
+ if (!element.matches(':focus-within') && !drag.current)
221
+ delete element.dataset['msBusy'];
222
+ }, CONTROLS_GUARD_MS);
223
+ };
224
+ /** The width the note is read at, where the image stands: what a share of the page is a share of. */
225
+ const column = () => {
226
+ const content = frame.current?.closest('.cm-content') ?? null;
227
+ const width = content ? content.clientWidth - parseFloat(getComputedStyle(content).paddingLeft || '0') - parseFloat(getComputedStyle(content).paddingRight || '0') : 0;
228
+ return width > 0 ? Math.round(width) : null;
229
+ };
230
+ /** The widest a share of the page draws the picture: that share of the column, in pixels. */
231
+ const shareWidth = (share) => {
232
+ const wide = column();
233
+ return wide === null ? `${share}%` : `${Math.round((wide * share) / 100)}px`;
234
+ };
235
+ // A share of the page is a share of the column the note is read at (§3.14), as reading mode draws
236
+ // it. Written as a percentage it would resolve against the frame, which shrinks to the picture, and
237
+ // the picture would be drawn at that share of itself (#113): it is given in pixels of the column.
238
+ const percent = size?.percent ?? null;
239
+ useEffect(() => {
240
+ const content = frame.current?.closest('.cm-content') ?? null;
241
+ if (!image || percent === null || !content)
242
+ return;
243
+ const place = () => {
244
+ if (!drag.current)
245
+ image.style.maxWidth = shareWidth(percent);
246
+ };
247
+ place();
248
+ if (typeof ResizeObserver === 'undefined')
249
+ return;
250
+ const observer = new ResizeObserver(place);
251
+ observer.observe(content);
252
+ return () => observer.disconnect();
253
+ }, [image, percent]);
254
+ /** The image as it is drawn now, or `null` before it is laid out. */
255
+ const drawn = () => {
256
+ const box = image?.getBoundingClientRect();
257
+ return box && box.width > 0 && box.height > 0 ? { width: Math.round(box.width), height: Math.round(box.height) } : null;
258
+ };
259
+ /** Its proportion, height over width: its own, else as it is drawn. */
260
+ const ratio = () => {
261
+ const shape = natural ?? drawn();
262
+ return shape ? shape.height / shape.width : null;
263
+ };
264
+ /** What a field accepts (#105): a share from 1% to 100%, or pixels a reader sees, up to the column or the image's own size. */
265
+ const rangeOf = (field) => {
266
+ if (field === 'width' && unit === '%')
267
+ return { min: 1, max: 100 };
268
+ const widest = Math.max(column() ?? 0, natural?.width ?? 0);
269
+ const width = widest > 0 ? widest : MAX_IMAGE_WIDTH;
270
+ if (field === 'width')
271
+ return { min: MIN_IMAGE_SIZE, max: width };
272
+ const proportion = ratio();
273
+ const tallest = Math.max(proportion === null ? 0 : Math.ceil(width * proportion), natural?.height ?? 0);
274
+ return { min: MIN_IMAGE_SIZE, max: tallest > 0 && widest > 0 ? tallest : MAX_IMAGE_WIDTH };
275
+ };
276
+ /** Where the image's source starts in the note, or `null` when it is not in the page. */
277
+ const place = () => {
117
278
  const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block');
118
- if (!widget)
279
+ return widget ? view.posAtDOM(widget) : null;
280
+ };
281
+ const apply = (changes) => {
282
+ const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block');
283
+ if (!widget || changes.length === 0)
119
284
  return;
120
285
  const from = view.posAtDOM(widget);
121
286
  // The widget stands where its source was: anything else means the note moved under it.
122
287
  if (view.state.sliceDoc(from, from + source.length) !== source)
123
288
  return;
124
- write(view, resizeImage(source, from, width));
289
+ holdInPlace(view, widget, () => write(view, changes.map((change) => ({ ...change, from: change.from + from, to: change.to + from }))));
290
+ };
291
+ const resize = (dimensions) => apply(resizeImage(source, 0, dimensions));
292
+ /** The width it is written or drawn at, in pixels, for a height written beside it. */
293
+ const widthNow = () => size?.width ?? drawn()?.width ?? natural?.width ?? null;
294
+ /** Writes what a field holds, or refuses it in place; `true` when the field may let go of what was typed. */
295
+ const commit = (field) => {
296
+ const text = typed[field];
297
+ if (text === undefined)
298
+ return true;
299
+ if (text.trim() === '') {
300
+ // Empty is "not set": no width is its own size, no height a width alone (#106).
301
+ setTyped((now) => ({ ...now, [field]: undefined }));
302
+ setRefused(null);
303
+ if (field === 'width')
304
+ resize(null);
305
+ else if (size?.width != null)
306
+ resize({ width: size.width, height: null, percent: null });
307
+ return true;
308
+ }
309
+ const value = wholeNumber(text);
310
+ const range = rangeOf(field);
311
+ if (value === null || value < range.min || value > range.max) {
312
+ setRefused({ field, range });
313
+ return false;
314
+ }
315
+ setRefused(null);
316
+ setTyped((now) => ({ ...now, [field]: undefined }));
317
+ if (field === 'width') {
318
+ if (unit === '%')
319
+ resize({ width: null, height: null, percent: value });
320
+ else if (locked)
321
+ resize({ width: value, height: null, percent: null });
322
+ else
323
+ resize({ width: value, height: size?.height ?? drawn()?.height ?? null, percent: null });
324
+ }
325
+ else if (locked) {
326
+ // A height typed under the lock sets the width that draws it, and only the width is written.
327
+ const proportion = ratio();
328
+ if (proportion !== null)
329
+ resize({ width: Math.max(1, Math.round(value / proportion)), height: null, percent: null });
330
+ }
331
+ else {
332
+ const width = widthNow();
333
+ if (width !== null)
334
+ resize({ width, height: value, percent: null });
335
+ }
336
+ return true;
125
337
  };
126
- const commitTyped = () => {
127
- if (typed === null)
338
+ const commitDescription = () => {
339
+ const text = typed.description;
340
+ if (text === undefined)
128
341
  return;
129
- const width = Number(typed.trim());
130
- setTyped(null);
131
- if (typed.trim() === '')
132
- resize(null);
133
- else if (Number.isFinite(width) && width >= 1)
134
- resize(width);
342
+ setTyped((now) => ({ ...now, description: undefined }));
343
+ apply(describeImage(source, 0, text));
135
344
  };
136
- const onKeyDown = (event) => {
137
- if (event.key === 'Enter') {
138
- event.preventDefault();
139
- commitTyped();
345
+ const field = (name) => ({
346
+ onChange: (event) => {
347
+ const value = event.target.value;
348
+ setTyped((now) => ({ ...now, [name]: value }));
349
+ if (refused?.field === name)
350
+ setRefused(null);
351
+ },
352
+ // Whatever takes it there — a click, a double click, Tab — the value is selected, so what is typed replaces it (#105).
353
+ onFocus: (event) => event.currentTarget.select(),
354
+ onMouseUp: (event) => {
355
+ const input = event.currentTarget;
356
+ if (document.activeElement === input && input.selectionStart === input.selectionEnd && typed[name] === undefined) {
357
+ event.preventDefault();
358
+ input.select();
359
+ }
360
+ },
361
+ onDoubleClick: (event) => event.currentTarget.select(),
362
+ onKeyDown: (event) => {
363
+ if (event.key === 'Enter') {
364
+ event.preventDefault();
365
+ if (name === 'description')
366
+ commitDescription();
367
+ else if (commit(name))
368
+ event.currentTarget.select();
369
+ }
370
+ else if (event.key === 'Escape') {
371
+ event.preventDefault();
372
+ setTyped((now) => ({ ...now, [name]: undefined }));
373
+ setRefused(null);
374
+ }
375
+ },
376
+ onBlur: () => {
377
+ if (name === 'description')
378
+ commitDescription();
379
+ // A value refused is let go when the field is left, and the note keeps what it held.
380
+ else if (!commit(name)) {
381
+ setTyped((now) => ({ ...now, [name]: undefined }));
382
+ setRefused(null);
383
+ }
384
+ },
385
+ });
386
+ const toggleLock = () => {
387
+ const next = !locked;
388
+ setLocked(next);
389
+ // Locked, the height follows the width: one written beside it goes (#106).
390
+ if (next && size?.height != null && size.width !== null)
391
+ resize({ width: size.width, height: null, percent: null });
392
+ };
393
+ const chooseUnit = (next) => {
394
+ setUnit(next);
395
+ setTyped((now) => ({ ...now, width: undefined }));
396
+ setRefused(null);
397
+ const now = drawn();
398
+ const wide = column();
399
+ // The size it is drawn at, said in the other unit, when it has one.
400
+ if (next === '%' && size?.percent == null && size?.width != null && now && wide) {
401
+ const share = Math.min(100, Math.max(1, Math.round((now.width / wide) * 100)));
402
+ const at = place();
403
+ if (at !== null)
404
+ switchedUnits.set(view, { at, percent: share, width: size.width, height: size.height });
405
+ resize({ width: null, height: null, percent: share });
140
406
  }
141
- else if (event.key === 'Escape') {
142
- event.preventDefault();
143
- setTyped(null);
407
+ else if (next === 'px' && size?.percent != null) {
408
+ const left = switchedUnits.get(view);
409
+ if (left && left.at === place() && left.percent === size.percent) {
410
+ resize({ width: left.width, height: left.height, percent: null });
411
+ setLocked(left.height === null);
412
+ }
413
+ else if (now)
414
+ resize({ width: now.width, height: null, percent: null });
144
415
  }
416
+ if (next === 'px')
417
+ switchedUnits.delete(view);
418
+ if (next === '%')
419
+ setLocked(true);
420
+ };
421
+ const free = (!locked && unit === 'px') || shift;
422
+ const shownWidth = dragged ? (unit === '%' ? percentOf(dragged.width) : dragged.width) : unit === '%' ? size?.percent ?? null : size?.width ?? null;
423
+ const shownHeight = dragged ? dragged.height : size?.height ?? (locked && size?.width != null && ratio() !== null ? Math.round(size.width * ratio()) : null);
424
+ function percentOf(width) {
425
+ const wide = column();
426
+ return wide ? Math.min(100, Math.max(1, Math.round((width / wide) * 100))) : null;
427
+ }
428
+ const endDrag = (event, cancelled) => {
429
+ const start = drag.current;
430
+ if (!start || start.pointer !== event.pointerId)
431
+ return;
432
+ drag.current = null;
433
+ const end = drawn();
434
+ setDragged(null);
435
+ if (image) {
436
+ image.style.width = '';
437
+ image.style.height = '';
438
+ image.style.maxWidth = percent === null ? '' : shareWidth(percent);
439
+ }
440
+ hold();
441
+ if (cancelled || !end)
442
+ return;
443
+ if (Math.abs(end.width - start.width) < 1 && Math.abs(end.height - start.height) < 1)
444
+ return;
445
+ if (unit === '%') {
446
+ const percent = percentOf(end.width);
447
+ if (percent !== null)
448
+ resize({ width: null, height: null, percent });
449
+ }
450
+ else if (free || event.shiftKey)
451
+ resize({ width: end.width, height: end.height, percent: null });
452
+ else
453
+ resize({ width: end.width, height: null, percent: null });
454
+ };
455
+ /** Shows the image's notation, as a table's button does: the caret goes before it (#112). */
456
+ const showSource = () => {
457
+ const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block');
458
+ if (widget)
459
+ reveal(view, view.posAtDOM(widget));
145
460
  };
146
- const shown = dragged ?? size?.width ?? null;
147
461
  const offered = size !== null && image !== null;
148
- return (_jsxs("span", { ref: frame, className: "edit-image", "data-resizing": dragged === null ? undefined : '', children: [children, offered && (_jsxs(_Fragment, { children: [_jsxs("span", { className: "edit-block-controls edit-image-controls", "data-ms-control": "", children: [_jsx("input", { className: "edit-field edit-image-width", type: "number", inputMode: "numeric", min: 1, max: MAX_IMAGE_WIDTH, "aria-label": words.imageWidth, title: words.imageWidth, placeholder: natural === null ? '' : String(natural), value: typed ?? (shown === null ? '' : String(shown)), onChange: (event) => setTyped(event.target.value), onKeyDown: onKeyDown, onBlur: commitTyped }), _jsx("span", { className: "edit-image-unit", "aria-hidden": "true", children: "px" }), _jsx(Control, { label: words.imageOwnSize, onClick: () => resize(null), pressed: size.width === null, children: ICON_OWN_SIZE })] }), _jsx("span", { className: "edit-image-handle", "data-ms-control": "", title: words.imageResize, "aria-hidden": "true", onPointerDown: (event) => {
462
+ const unitWord = unit === '%' ? '%' : 'px';
463
+ const invalid = (name) => (refused?.field === name ? { 'aria-invalid': true, title: words.imageOutOfRange(refused.range.min, refused.range.max, name === 'width' ? unitWord : 'px') } : {});
464
+ return (_jsxs("span", { ref: frame, className: "edit-image", "data-resizing": dragged === null ? undefined : '', "data-ms-in-place": offered ? '' : undefined, onFocus: () => frame.current && (frame.current.dataset['msBusy'] = ''), onBlur: hold, onMouseDown: (event) => {
465
+ // The frame's own area is the space between the picture and its controls: a press there is
466
+ // one meant for them, and shows nothing (#107).
467
+ if (event.target === event.currentTarget) {
468
+ event.preventDefault();
469
+ hold();
470
+ }
471
+ }, children: [children, offered && (_jsxs(_Fragment, { children: [_jsxs("span", { className: "edit-image-controls", "data-ms-control": "", onPointerDown: hold, onTouchStart: hold, onMouseDown: (event) => {
472
+ // A press anywhere but on a field, a choice or a button — a gap between them, the
473
+ // padding, a word beside a field — keeps the focus where it is. Its default would put
474
+ // the editor's caret where the press fell, and the image would show its source (#107).
475
+ if (!event.target.closest('input, select, textarea, button'))
476
+ event.preventDefault();
477
+ }, children: [size.description ? (_jsx("input", { className: "edit-field edit-image-description", type: "text", "aria-label": words.imageDescription, title: words.imageDescription, placeholder: image.alt, value: typed.description ?? size.description.text, ...field('description') })) : (
478
+ // An embed writes no description (§5.8): the file's own is shown, and is not edited here.
479
+ _jsx("input", { className: "edit-field edit-image-description", type: "text", readOnly: true, "aria-label": words.imageDescription, title: words.imageDescription, placeholder: image.alt, value: "" })), _jsxs("span", { className: "edit-image-size", children: [_jsx("input", { className: "edit-field edit-image-width", type: "text", inputMode: "numeric", autoComplete: "off", "aria-label": words.imageWidth, title: words.imageWidth, placeholder: unit === '%' ? '' : natural === null ? '' : String(natural.width), value: typed.width ?? (shownWidth === null ? '' : String(shownWidth)), ...field('width'), ...invalid('width') }), _jsxs("select", { className: "edit-image-unit", "aria-label": words.imageUnit, title: words.imageUnit, value: unit, onChange: (event) => chooseUnit(event.target.value), children: [_jsx("option", { value: "px", children: "px" }), _jsx("option", { value: "%", children: "%" })] }), _jsx(Control, { label: locked ? words.imageKeepRatio : words.imageFreeRatio, onClick: toggleLock, pressed: locked, disabled: unit === '%', focusable: true, children: locked ? ICON_RATIO_LOCKED : ICON_RATIO_FREE }), _jsx("input", { className: "edit-field edit-image-height", type: "text", inputMode: "numeric", autoComplete: "off", "aria-label": words.imageHeight, title: words.imageHeight, disabled: unit === '%', placeholder: natural === null || unit === '%' ? '' : String(natural.height), value: unit === '%' ? '' : (typed.height ?? (shownHeight === null ? '' : String(shownHeight))), ...field('height'), ...invalid('height') }), _jsx("span", { className: "edit-image-unit-label", "aria-hidden": "true", children: "px" }), _jsx(Control, { label: words.imageOwnSize, onClick: () => resize(null), pressed: size.width === null && size.percent === null, focusable: true, children: ICON_OWN_SIZE }), _jsx(Control, { label: words.source, onClick: showSource, children: ICONS.source })] }), refused && (_jsx("span", { className: "edit-image-refused", role: "status", children: words.imageOutOfRange(refused.range.min, refused.range.max, refused.field === 'width' ? unitWord : 'px') }))] }), _jsx("span", { className: "edit-image-handle", "data-ms-control": "", "data-free": free ? '' : undefined, title: free ? words.imageResizeFree : words.imageResizeKeep, "aria-hidden": "true", onPointerDown: (event) => {
149
480
  event.preventDefault();
481
+ event.stopPropagation();
482
+ const now = drawn();
483
+ if (!now)
484
+ return;
150
485
  event.currentTarget.setPointerCapture?.(event.pointerId);
151
- drag.current = { x: event.clientX, width: image.getBoundingClientRect().width, pointer: event.pointerId };
152
- setDragged(Math.round(drag.current.width));
486
+ drag.current = { x: event.clientX, y: event.clientY, ...now, pointer: event.pointerId };
487
+ if (frame.current)
488
+ frame.current.dataset['msBusy'] = '';
489
+ setShift(event.shiftKey);
490
+ setDragged(now);
153
491
  }, onPointerMove: (event) => {
154
492
  const start = drag.current;
155
- if (!start || start.pointer !== event.pointerId)
493
+ if (!image || !start || start.pointer !== event.pointerId)
156
494
  return;
157
- const width = Math.max(MIN_DRAGGED_WIDTH, Math.round(start.width + event.clientX - start.x));
495
+ setShift(event.shiftKey);
496
+ const width = Math.max(MIN_IMAGE_SIZE, Math.round(start.width + event.clientX - start.x));
497
+ image.style.maxWidth = '100%';
158
498
  image.style.width = `${width}px`;
159
- image.style.height = 'auto';
499
+ if ((!locked && unit === 'px') || event.shiftKey) {
500
+ image.style.height = `${Math.max(MIN_IMAGE_SIZE, Math.round(start.height + event.clientY - start.y))}px`;
501
+ }
502
+ else
503
+ image.style.height = 'auto';
160
504
  // What the page lets it be: never wider than the column.
161
- setDragged(Math.round(image.getBoundingClientRect().width));
162
- }, onPointerUp: (event) => {
163
- const start = drag.current;
164
- if (!start || start.pointer !== event.pointerId)
165
- return;
166
- drag.current = null;
167
- const width = Math.round(image.getBoundingClientRect().width);
168
- setDragged(null);
169
- image.style.width = '';
170
- image.style.height = '';
171
- if (Math.abs(width - start.width) >= 1)
172
- resize(width);
173
- }, onPointerCancel: () => {
174
- drag.current = null;
175
- setDragged(null);
176
- image.style.width = '';
177
- image.style.height = '';
178
- } })] }))] }));
505
+ setDragged(drawn());
506
+ }, onPointerUp: (event) => endDrag(event, false), onPointerCancel: (event) => endDrag(event, true), children: ICON_RESIZE })] }))] }));
179
507
  }
180
508
  // ── Tables ───────────────────────────────────────────────────────────────────
181
509
  /** Where the focus goes back to when a table is drawn again after an edit of one of its cells. */
@@ -138,6 +138,8 @@ export function estimateHeight(kind, source, metrics) {
138
138
  const size = readImageSize(source.trim());
139
139
  if (size?.height != null)
140
140
  return size.height;
141
+ if (size?.percent != null)
142
+ return Math.round(((metrics.width * size.percent) / 100) * 0.75);
141
143
  return size?.width != null ? Math.round(size.width * 0.75) : UNDECLARED_IMAGE_HEIGHT;
142
144
  }
143
145
  }
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * The size an image is drawn at, as its notation writes it (#98): the dimensions after the pipe
3
3
  * of a Markdown image's description, `![description|300](address)` (`SPEC.md` §3.14), or of the
4
- * embed of a file the notebook keeps, `![[photo.jpg|300]]` (§5.7). The editor reads them and
5
- * rewrites them, and no other byte.
4
+ * embed of a file the notebook keeps, `![[photo.jpg|300]]` (§5.7) — pixels, a width and a height,
5
+ * or a share of the page (#108). The editor reads them and rewrites them, and the description
6
+ * before the pipe of a Markdown image (#106), and no other byte.
6
7
  */
8
+ import { type Dimensions } from '../image-dimensions.js';
7
9
  import type { TextChange } from './table.js';
8
- export interface ImageSize {
9
- /** The width it declares, or `null` when it is drawn at its own size. */
10
- readonly width: number | null;
11
- readonly height: number | null;
10
+ export interface ImageSize extends Dimensions {
12
11
  /**
13
12
  * Where in the source the dimensions are written, their pipe included; an empty span where they
14
13
  * would be written when there are none.
@@ -19,6 +18,15 @@ export interface ImageSize {
19
18
  };
20
19
  /** What separates them from the rest: `|`, or `\|` in an embed whose pipe is escaped. */
21
20
  readonly pipe: string;
21
+ /**
22
+ * The description of a Markdown image, as it reads, and where it is written, before its pipe;
23
+ * `null` for an embed, whose pipe is the only thing it writes (§5.8).
24
+ */
25
+ readonly description: {
26
+ readonly text: string;
27
+ readonly from: number;
28
+ readonly to: number;
29
+ } | null;
22
30
  }
23
31
  /** The largest width the notation writes: five digits (`SPEC.md` §3.14). */
24
32
  export declare const MAX_IMAGE_WIDTH = 99999;
@@ -29,8 +37,16 @@ export declare const MAX_IMAGE_WIDTH = 99999;
29
37
  */
30
38
  export declare function readImageSize(source: string): ImageSize | null;
31
39
  /**
32
- * The change that draws the image whose source starts at `from` at `width`, its height following,
33
- * or at its own size for `null`: the dimensions written, replaced or taken away, and nothing else.
34
- * Empty when the source is not an image's notation or already says so.
40
+ * The change that draws the image whose source starts at `from` at the dimensions asked — a width
41
+ * as a number, its height following, or whole `Dimensions` — or at its own size for `null`: the
42
+ * dimensions written, replaced or taken away, and nothing else. Empty when the source is not an
43
+ * image's notation, already says so, or the dimensions asked are none the notation writes: a
44
+ * value out of its range is refused, never brought into it.
45
+ */
46
+ export declare function resizeImage(source: string, from: number, asked: number | Dimensions | null): TextChange[];
47
+ /**
48
+ * The change that writes `text` as the description of the Markdown image whose source starts at
49
+ * `from`, before its pipe, which it keeps (§3.14): a bracket and a backslash escaped, so the image
50
+ * stays one. Empty for an embed, which has no description to write (§5.8), and when nothing changes.
35
51
  */
36
- export declare function resizeImage(source: string, from: number, width: number | null): TextChange[];
52
+ export declare function describeImage(source: string, from: number, text: string): TextChange[];