@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.
@@ -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,67 @@ 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
+ /** 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
+ const CONTROLS_GUARD_MS = 800;
81
+ /** How long after a control of an image writes the image is held where it was on the screen (#107). */
82
+ const SCROLL_HOLD_MS = 600;
83
+ /** A move of the image larger than this, in that moment, is the browser's and not the author's. */
84
+ const SCROLL_JUMP = 120;
79
85
  /**
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.
86
+ * Holds the image where it is on the screen while one of its controls writes (#107). The write draws
87
+ * the image again for its new source, which takes the control that had the focus out of the page,
88
+ * and Chrome on a touch screen then scrolls the page a screen or more away, with no script asking
89
+ * it to: the image and its controls were gone from sight (*observed* in the playground, Pixel 7,
90
+ * 2026-10-16). For a moment after the write, a move of the image larger than a jump is undone, by
91
+ * scrolling what the editor scrolls in — a pane of the host, or the window — by as much.
92
+ */
93
+ function holdInPlace(view, block, run) {
94
+ 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;
98
+ const scrolled = () => (scroller ? scroller.scrollTop : window.scrollY);
99
+ const before = scrolled();
100
+ const back = () => {
101
+ // The editor lets go of a block it scrolled out of its viewport: the page goes back to where it was.
102
+ const moved = block.isConnected ? block.getBoundingClientRect().top - top : before - scrolled();
103
+ if (Math.abs(moved) <= SCROLL_JUMP)
104
+ return;
105
+ if (scroller)
106
+ scroller.scrollBy(0, moved);
107
+ else
108
+ window.scrollBy(0, moved);
109
+ };
110
+ run();
111
+ window.addEventListener('scroll', back, true);
112
+ setTimeout(() => window.removeEventListener('scroll', back, true), SCROLL_HOLD_MS);
113
+ }
114
+ /** A number as a field holds it: digits and nothing else, or `null`. */
115
+ function wholeNumber(text) {
116
+ return /^\d{1,6}$/.test(text.trim()) ? Number(text.trim()) : null;
117
+ }
118
+ /**
119
+ * An image drawn in edit mode with what its notation writes set in place (#98, #106): its width and
120
+ * its height with a lock between them — locked, the height follows and only the width is written,
121
+ * `|300`; open, both are, `|300x180` — the width's unit, pixels or a share of the page (`|50%`,
122
+ * #108), its description for a Markdown image, a button back to its own size, and a handle at its
123
+ * corner that drags it, keeping the proportion or, with the lock open or Shift held, not. Each
124
+ * writes the dimensions or the description of its notation, and no other byte.
125
+ *
126
+ * A field selects what it holds when it takes the focus, so typing replaces it, and writes on Enter
127
+ * or when it is left; Escape leaves the note as it is. A value outside the range a reader can see
128
+ * — from 16 pixels to the column or the image's own width, the wider — is refused where it was
129
+ * typed, never brought into the range and written (#105).
130
+ *
131
+ * The controls stand under the picture, never over it, and nothing that starts on them or in the
132
+ * moment after reaches the click that shows the image's source (#107): they are all marked as
133
+ * controls, gaps included, and the frame is marked busy while one of them has the focus, while the
134
+ * handle is dragged and for a moment after a pointer or a touch on them.
135
+ *
136
+ * The controls appear once an image is drawn inside it, so an embed of a file that is not an image,
137
+ * or a host's own drawing without one, offers none.
85
138
  */
86
139
  export function ImageBlock({ view, source, children }) {
87
140
  const words = useHost().strings.editor;
@@ -89,9 +142,14 @@ export function ImageBlock({ view, source, children }) {
89
142
  const size = readImageSize(source);
90
143
  const [image, setImage] = useState(null);
91
144
  const [natural, setNatural] = useState(null);
92
- const [typed, setTyped] = useState(null);
145
+ const [locked, setLocked] = useState(() => size?.height == null);
146
+ const [unit, setUnit] = useState(() => (size?.percent == null ? 'px' : '%'));
147
+ const [typed, setTyped] = useState({});
148
+ const [refused, setRefused] = useState(null);
93
149
  const [dragged, setDragged] = useState(null);
150
+ const [shift, setShift] = useState(false);
94
151
  const drag = useRef(null);
152
+ const guard = useRef(null);
95
153
  // An embed's image arrives when the host answers; the controls wait for it.
96
154
  useEffect(() => {
97
155
  const element = frame.current;
@@ -108,74 +166,288 @@ export function ImageBlock({ view, source, children }) {
108
166
  useEffect(() => {
109
167
  if (!image)
110
168
  return;
111
- const read = () => setNatural(image.naturalWidth || null);
169
+ const read = () => setNatural(image.naturalWidth && image.naturalHeight ? { width: image.naturalWidth, height: image.naturalHeight } : null);
112
170
  read();
113
171
  image.addEventListener('load', read);
114
172
  return () => image.removeEventListener('load', read);
115
173
  }, [image]);
116
- const resize = (width) => {
174
+ useEffect(() => () => {
175
+ if (guard.current)
176
+ clearTimeout(guard.current);
177
+ }, []);
178
+ // The handle and the controls are placed by the picture itself, not by the frame, which also holds
179
+ // its margins and, in an embed, a paragraph's: its bottom right corner is where the handle goes.
180
+ useEffect(() => {
181
+ const element = frame.current;
182
+ if (!image || !element)
183
+ return;
184
+ const place = () => {
185
+ const outer = element.getBoundingClientRect();
186
+ const inner = image.getBoundingClientRect();
187
+ element.style.setProperty('--edit-image-bottom', `${Math.round(inner.bottom - outer.top)}px`);
188
+ element.style.setProperty('--edit-image-right', `${Math.round(inner.right - outer.left)}px`);
189
+ };
190
+ place();
191
+ image.addEventListener('load', place);
192
+ if (typeof ResizeObserver === 'undefined')
193
+ return () => image.removeEventListener('load', place);
194
+ const observer = new ResizeObserver(place);
195
+ observer.observe(image);
196
+ observer.observe(element);
197
+ return () => {
198
+ image.removeEventListener('load', place);
199
+ observer.disconnect();
200
+ };
201
+ }, [image]);
202
+ /** Marks the frame busy for a moment, so the click a touch or a pointer leaves behind reveals nothing (#107). */
203
+ const hold = () => {
204
+ const element = frame.current;
205
+ if (!element)
206
+ return;
207
+ element.dataset['msBusy'] = '';
208
+ if (guard.current)
209
+ clearTimeout(guard.current);
210
+ guard.current = setTimeout(() => {
211
+ if (!element.matches(':focus-within') && !drag.current)
212
+ delete element.dataset['msBusy'];
213
+ }, CONTROLS_GUARD_MS);
214
+ };
215
+ /** The width the note is read at, where the image stands: what a share of the page is a share of. */
216
+ const column = () => {
217
+ const content = frame.current?.closest('.cm-content') ?? null;
218
+ const width = content ? content.clientWidth - parseFloat(getComputedStyle(content).paddingLeft || '0') - parseFloat(getComputedStyle(content).paddingRight || '0') : 0;
219
+ return width > 0 ? Math.round(width) : null;
220
+ };
221
+ /** The image as it is drawn now, or `null` before it is laid out. */
222
+ const drawn = () => {
223
+ const box = image?.getBoundingClientRect();
224
+ return box && box.width > 0 && box.height > 0 ? { width: Math.round(box.width), height: Math.round(box.height) } : null;
225
+ };
226
+ /** Its proportion, height over width: its own, else as it is drawn. */
227
+ const ratio = () => {
228
+ const shape = natural ?? drawn();
229
+ return shape ? shape.height / shape.width : null;
230
+ };
231
+ /** 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. */
232
+ const rangeOf = (field) => {
233
+ if (field === 'width' && unit === '%')
234
+ return { min: 1, max: 100 };
235
+ const widest = Math.max(column() ?? 0, natural?.width ?? 0);
236
+ const width = widest > 0 ? widest : MAX_IMAGE_WIDTH;
237
+ if (field === 'width')
238
+ return { min: MIN_IMAGE_SIZE, max: width };
239
+ const proportion = ratio();
240
+ const tallest = Math.max(proportion === null ? 0 : Math.ceil(width * proportion), natural?.height ?? 0);
241
+ return { min: MIN_IMAGE_SIZE, max: tallest > 0 && widest > 0 ? tallest : MAX_IMAGE_WIDTH };
242
+ };
243
+ const apply = (changes) => {
117
244
  const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block');
118
- if (!widget)
245
+ if (!widget || changes.length === 0)
119
246
  return;
120
247
  const from = view.posAtDOM(widget);
121
248
  // The widget stands where its source was: anything else means the note moved under it.
122
249
  if (view.state.sliceDoc(from, from + source.length) !== source)
123
250
  return;
124
- write(view, resizeImage(source, from, width));
251
+ holdInPlace(view, widget, () => write(view, changes.map((change) => ({ ...change, from: change.from + from, to: change.to + from }))));
125
252
  };
126
- const commitTyped = () => {
127
- if (typed === null)
253
+ const resize = (dimensions) => apply(resizeImage(source, 0, dimensions));
254
+ /** The width it is written or drawn at, in pixels, for a height written beside it. */
255
+ const widthNow = () => size?.width ?? drawn()?.width ?? natural?.width ?? null;
256
+ /** Writes what a field holds, or refuses it in place; `true` when the field may let go of what was typed. */
257
+ const commit = (field) => {
258
+ const text = typed[field];
259
+ if (text === undefined)
260
+ return true;
261
+ if (text.trim() === '') {
262
+ // Empty is "not set": no width is its own size, no height a width alone (#106).
263
+ setTyped((now) => ({ ...now, [field]: undefined }));
264
+ setRefused(null);
265
+ if (field === 'width')
266
+ resize(null);
267
+ else if (size?.width != null)
268
+ resize({ width: size.width, height: null, percent: null });
269
+ return true;
270
+ }
271
+ const value = wholeNumber(text);
272
+ const range = rangeOf(field);
273
+ if (value === null || value < range.min || value > range.max) {
274
+ setRefused({ field, range });
275
+ return false;
276
+ }
277
+ setRefused(null);
278
+ setTyped((now) => ({ ...now, [field]: undefined }));
279
+ if (field === 'width') {
280
+ if (unit === '%')
281
+ resize({ width: null, height: null, percent: value });
282
+ else if (locked)
283
+ resize({ width: value, height: null, percent: null });
284
+ else
285
+ resize({ width: value, height: size?.height ?? drawn()?.height ?? null, percent: null });
286
+ }
287
+ else if (locked) {
288
+ // A height typed under the lock sets the width that draws it, and only the width is written.
289
+ const proportion = ratio();
290
+ if (proportion !== null)
291
+ resize({ width: Math.max(1, Math.round(value / proportion)), height: null, percent: null });
292
+ }
293
+ else {
294
+ const width = widthNow();
295
+ if (width !== null)
296
+ resize({ width, height: value, percent: null });
297
+ }
298
+ return true;
299
+ };
300
+ const commitDescription = () => {
301
+ const text = typed.description;
302
+ if (text === undefined)
128
303
  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);
304
+ setTyped((now) => ({ ...now, description: undefined }));
305
+ apply(describeImage(source, 0, text));
135
306
  };
136
- const onKeyDown = (event) => {
137
- if (event.key === 'Enter') {
138
- event.preventDefault();
139
- commitTyped();
307
+ const field = (name) => ({
308
+ onChange: (event) => {
309
+ const value = event.target.value;
310
+ setTyped((now) => ({ ...now, [name]: value }));
311
+ if (refused?.field === name)
312
+ setRefused(null);
313
+ },
314
+ // Whatever takes it there — a click, a double click, Tab — the value is selected, so what is typed replaces it (#105).
315
+ onFocus: (event) => event.currentTarget.select(),
316
+ onMouseUp: (event) => {
317
+ const input = event.currentTarget;
318
+ if (document.activeElement === input && input.selectionStart === input.selectionEnd && typed[name] === undefined) {
319
+ event.preventDefault();
320
+ input.select();
321
+ }
322
+ },
323
+ onDoubleClick: (event) => event.currentTarget.select(),
324
+ onKeyDown: (event) => {
325
+ if (event.key === 'Enter') {
326
+ event.preventDefault();
327
+ if (name === 'description')
328
+ commitDescription();
329
+ else if (commit(name))
330
+ event.currentTarget.select();
331
+ }
332
+ else if (event.key === 'Escape') {
333
+ event.preventDefault();
334
+ setTyped((now) => ({ ...now, [name]: undefined }));
335
+ setRefused(null);
336
+ }
337
+ },
338
+ onBlur: () => {
339
+ if (name === 'description')
340
+ commitDescription();
341
+ // A value refused is let go when the field is left, and the note keeps what it held.
342
+ else if (!commit(name)) {
343
+ setTyped((now) => ({ ...now, [name]: undefined }));
344
+ setRefused(null);
345
+ }
346
+ },
347
+ });
348
+ const toggleLock = () => {
349
+ const next = !locked;
350
+ setLocked(next);
351
+ // Locked, the height follows the width: one written beside it goes (#106).
352
+ if (next && size?.height != null && size.width !== null)
353
+ resize({ width: size.width, height: null, percent: null });
354
+ };
355
+ const chooseUnit = (next) => {
356
+ setUnit(next);
357
+ setTyped((now) => ({ ...now, width: undefined }));
358
+ setRefused(null);
359
+ const now = drawn();
360
+ const wide = column();
361
+ // The size it is drawn at, said in the other unit, when it has one.
362
+ 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))) });
140
364
  }
141
- else if (event.key === 'Escape') {
142
- event.preventDefault();
143
- setTyped(null);
365
+ else if (next === 'px' && size?.percent != null && now) {
366
+ resize({ width: now.width, height: null, percent: null });
144
367
  }
368
+ if (next === '%')
369
+ setLocked(true);
370
+ };
371
+ const free = (!locked && unit === 'px') || shift;
372
+ const shownWidth = dragged ? (unit === '%' ? percentOf(dragged.width) : dragged.width) : unit === '%' ? size?.percent ?? null : size?.width ?? null;
373
+ const shownHeight = dragged ? dragged.height : size?.height ?? (locked && size?.width != null && ratio() !== null ? Math.round(size.width * ratio()) : null);
374
+ function percentOf(width) {
375
+ const wide = column();
376
+ return wide ? Math.min(100, Math.max(1, Math.round((width / wide) * 100))) : null;
377
+ }
378
+ const endDrag = (event, cancelled) => {
379
+ const start = drag.current;
380
+ if (!start || start.pointer !== event.pointerId)
381
+ return;
382
+ drag.current = null;
383
+ const end = drawn();
384
+ setDragged(null);
385
+ if (image) {
386
+ image.style.width = '';
387
+ image.style.height = '';
388
+ image.style.maxWidth = '';
389
+ }
390
+ 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;
395
+ if (unit === '%') {
396
+ const percent = percentOf(end.width);
397
+ if (percent !== null)
398
+ resize({ width: null, height: null, percent });
399
+ }
400
+ else if (free || event.shiftKey)
401
+ resize({ width: end.width, height: end.height, percent: null });
402
+ else
403
+ resize({ width: end.width, height: null, percent: null });
145
404
  };
146
- const shown = dragged ?? size?.width ?? null;
147
405
  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) => {
406
+ const unitWord = unit === '%' ? '%' : 'px';
407
+ 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) => {
409
+ // The frame's own area is the space between the picture and its controls: a press there is
410
+ // one meant for them, and shows nothing (#107).
411
+ if (event.target === event.currentTarget) {
412
+ event.preventDefault();
413
+ hold();
414
+ }
415
+ }, children: [children, offered && (_jsxs(_Fragment, { children: [_jsxs("span", { className: "edit-image-controls", "data-ms-control": "", onPointerDown: hold, onTouchStart: hold, onMouseDown: (event) => {
416
+ // A press anywhere but on a field, a choice or a button — a gap between them, the
417
+ // padding, a word beside a field — keeps the focus where it is. Its default would put
418
+ // the editor's caret where the press fell, and the image would show its source (#107).
419
+ if (!event.target.closest('input, select, textarea, button'))
420
+ event.preventDefault();
421
+ }, 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
+ // 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) => {
149
424
  event.preventDefault();
425
+ event.stopPropagation();
426
+ const now = drawn();
427
+ if (!now)
428
+ return;
150
429
  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));
430
+ drag.current = { x: event.clientX, y: event.clientY, ...now, pointer: event.pointerId };
431
+ if (frame.current)
432
+ frame.current.dataset['msBusy'] = '';
433
+ setShift(event.shiftKey);
434
+ setDragged(now);
153
435
  }, onPointerMove: (event) => {
154
436
  const start = drag.current;
155
- if (!start || start.pointer !== event.pointerId)
437
+ if (!image || !start || start.pointer !== event.pointerId)
156
438
  return;
157
- const width = Math.max(MIN_DRAGGED_WIDTH, Math.round(start.width + event.clientX - start.x));
439
+ setShift(event.shiftKey);
440
+ const width = Math.max(MIN_IMAGE_SIZE, Math.round(start.width + event.clientX - start.x));
441
+ image.style.maxWidth = '100%';
158
442
  image.style.width = `${width}px`;
159
- image.style.height = 'auto';
443
+ if ((!locked && unit === 'px') || event.shiftKey) {
444
+ image.style.height = `${Math.max(MIN_IMAGE_SIZE, Math.round(start.height + event.clientY - start.y))}px`;
445
+ }
446
+ else
447
+ image.style.height = 'auto';
160
448
  // 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
- } })] }))] }));
449
+ setDragged(drawn());
450
+ }, onPointerUp: (event) => endDrag(event, false), onPointerCancel: (event) => endDrag(event, true), children: ICON_RESIZE })] }))] }));
179
451
  }
180
452
  // ── Tables ───────────────────────────────────────────────────────────────────
181
453
  /** Where the focus goes back to when a table is drawn again after an edit of one of its cells. */
@@ -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
+ }
@@ -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[];