@memorysmithapp/markdown-renderer 0.8.0 → 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.
@@ -49,6 +49,9 @@ export declare const MIN_IMAGE_SIZE = 16;
49
49
  * controls, gaps included, and the frame is marked busy while one of them has the focus, while the
50
50
  * handle is dragged and for a moment after a pointer or a touch on them.
51
51
  *
52
+ * Sized in place, as a table is edited in place, the image shows its source only from a button among
53
+ * its controls or when the caret reaches it from the keyboard; a click on the picture shows nothing (#112).
54
+ *
52
55
  * The controls appear once an image is drawn inside it, so an embed of a file that is not an image,
53
56
  * or a host's own drawing without one, offers none.
54
57
  */
@@ -76,6 +76,12 @@ export function CalloutBlock({ view, text, marker }) {
76
76
  // ── Images ───────────────────────────────────────────────────────────────────
77
77
  /** The narrowest an image is sized to, by a field or the handle: something a reader still sees (#105). */
78
78
  export const MIN_IMAGE_SIZE = 16;
79
+ /**
80
+ * The size in pixels an image had when its unit was switched to a share of the page, where its
81
+ * source starts: switched back with nothing changed between, it is written again, not the width the
82
+ * share rounds to (#113). Kept by the editor, since the image is drawn anew for its new source.
83
+ */
84
+ const switchedUnits = new WeakMap();
79
85
  /** How long after a pointer or a touch on its controls a click on the image is not taken as one to edit its source (#107). */
80
86
  const CONTROLS_GUARD_MS = 800;
81
87
  /** How long after a control of an image writes the image is held where it was on the screen (#107). */
@@ -133,6 +139,9 @@ function wholeNumber(text) {
133
139
  * controls, gaps included, and the frame is marked busy while one of them has the focus, while the
134
140
  * handle is dragged and for a moment after a pointer or a touch on them.
135
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
+ *
136
145
  * The controls appear once an image is drawn inside it, so an embed of a file that is not an image,
137
146
  * or a host's own drawing without one, offers none.
138
147
  */
@@ -218,6 +227,30 @@ export function ImageBlock({ view, source, children }) {
218
227
  const width = content ? content.clientWidth - parseFloat(getComputedStyle(content).paddingLeft || '0') - parseFloat(getComputedStyle(content).paddingRight || '0') : 0;
219
228
  return width > 0 ? Math.round(width) : null;
220
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]);
221
254
  /** The image as it is drawn now, or `null` before it is laid out. */
222
255
  const drawn = () => {
223
256
  const box = image?.getBoundingClientRect();
@@ -240,6 +273,11 @@ export function ImageBlock({ view, source, children }) {
240
273
  const tallest = Math.max(proportion === null ? 0 : Math.ceil(width * proportion), natural?.height ?? 0);
241
274
  return { min: MIN_IMAGE_SIZE, max: tallest > 0 && widest > 0 ? tallest : MAX_IMAGE_WIDTH };
242
275
  };
276
+ /** Where the image's source starts in the note, or `null` when it is not in the page. */
277
+ const place = () => {
278
+ const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block');
279
+ return widget ? view.posAtDOM(widget) : null;
280
+ };
243
281
  const apply = (changes) => {
244
282
  const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block');
245
283
  if (!widget || changes.length === 0)
@@ -360,11 +398,23 @@ export function ImageBlock({ view, source, children }) {
360
398
  const wide = column();
361
399
  // The size it is drawn at, said in the other unit, when it has one.
362
400
  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))) });
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 });
364
406
  }
365
- else if (next === 'px' && size?.percent != null && now) {
366
- resize({ width: now.width, height: null, percent: 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 });
367
415
  }
416
+ if (next === 'px')
417
+ switchedUnits.delete(view);
368
418
  if (next === '%')
369
419
  setLocked(true);
370
420
  };
@@ -385,7 +435,7 @@ export function ImageBlock({ view, source, children }) {
385
435
  if (image) {
386
436
  image.style.width = '';
387
437
  image.style.height = '';
388
- image.style.maxWidth = '';
438
+ image.style.maxWidth = percent === null ? '' : shareWidth(percent);
389
439
  }
390
440
  hold();
391
441
  if (cancelled || !end)
@@ -402,10 +452,16 @@ export function ImageBlock({ view, source, children }) {
402
452
  else
403
453
  resize({ width: end.width, height: null, percent: null });
404
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));
460
+ };
405
461
  const offered = size !== null && image !== null;
406
462
  const unitWord = unit === '%' ? '%' : 'px';
407
463
  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) => {
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) => {
409
465
  // The frame's own area is the space between the picture and its controls: a press there is
410
466
  // one meant for them, and shows nothing (#107).
411
467
  if (event.target === event.currentTarget) {
@@ -420,7 +476,7 @@ export function ImageBlock({ view, source, children }) {
420
476
  event.preventDefault();
421
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') })) : (
422
478
  // 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) => {
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) => {
424
480
  event.preventDefault();
425
481
  event.stopPropagation();
426
482
  const now = drawn();
package/dist/edit/live.js CHANGED
@@ -260,8 +260,17 @@ class BlockWidget extends WidgetType {
260
260
  // dragged, the click a touch on them leaves behind — never shows the source (#107).
261
261
  if (target.closest('[data-ms-control], [data-ms-busy]'))
262
262
  return;
263
- if (event.metaKey || event.ctrlKey)
263
+ // CodeMirror hands the editor no event from inside a drawn block: a link around it is followed here (#112).
264
+ if (event.metaKey || event.ctrlKey) {
265
+ followLinkAround(event, view, element);
264
266
  return;
267
+ }
268
+ // A picture sized in place, as a table is edited in place, shows its source from its button or
269
+ // the keyboard, never from a click (#112); kept from placing the caret beside it, which would.
270
+ if (target.closest('[data-ms-in-place]')) {
271
+ event.preventDefault();
272
+ return;
273
+ }
265
274
  const widget = drawing.get(element) ?? this;
266
275
  if (!widget.revealsOnClick)
267
276
  return;
@@ -872,29 +881,96 @@ const blockKeys = Prec.high(keymap.of([
872
881
  { key: 'ArrowDown', run: enterBlock(true) },
873
882
  { key: 'ArrowUp', run: enterBlock(false) },
874
883
  ]));
875
- /** Follows a link with a modifier held: a wikilink through the host, an address the page follows in a new tab. */
876
- const follow = EditorView.domEventHandlers({
884
+ /** Where the first press of a gesture fell in the note, read before the press moved the caret. */
885
+ const firstPress = new WeakMap();
886
+ /** The longest a browser counts two presses as one gesture: Windows' default double-click time. */
887
+ const MULTI_CLICK_MS = 500;
888
+ /**
889
+ * Selects the word, or the line, a double or a triple click starts on (#114). The first press puts
890
+ * the caret where it fell, which reveals the markers beside it, or hides the ones it left, and moves
891
+ * the rest of the line under the pointer by their width: the second press, read where it lands,
892
+ * would select the marker or the next word. It is read where the first one fell instead.
893
+ */
894
+ const multiClick = EditorView.domEventHandlers({
877
895
  mousedown(event, view) {
878
- if (!(event.metaKey || event.ctrlKey))
896
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
879
897
  return false;
880
- const target = event.target.closest('[data-target], [data-href]');
881
- if (!target)
898
+ if (event.detail <= 1) {
899
+ const at = view.posAtCoords({ x: event.clientX, y: event.clientY });
900
+ if (at === null)
901
+ firstPress.delete(view);
902
+ else
903
+ firstPress.set(view, { at, time: event.timeStamp });
882
904
  return false;
883
- event.preventDefault();
884
- const { host } = view.state.facet(editorEnvironment);
885
- const name = target.dataset['target'];
886
- if (name !== undefined) {
887
- const address = host.resolve(name);
888
- if (address !== null)
889
- host.navigate(address);
890
- return true;
891
905
  }
892
- const address = followable(target.dataset['href'] ?? '');
893
- if (address)
894
- window.open(address, '_blank', 'noreferrer');
906
+ const first = firstPress.get(view);
907
+ if (!first || event.timeStamp - first.time > MULTI_CLICK_MS * (event.detail - 1))
908
+ return false;
909
+ const { state } = view;
910
+ const line = state.doc.lineAt(first.at);
911
+ const range = event.detail === 2 ? (state.wordAt(first.at) ?? EditorSelection.cursor(first.at)) : EditorSelection.range(line.from, line.to);
912
+ event.preventDefault();
913
+ view.dispatch({ selection: EditorSelection.single(range.from, range.to), userEvent: 'select.pointer' });
895
914
  return true;
896
915
  },
897
916
  });
917
+ /** Follows a link with a modifier held: a wikilink through the host, an address the page follows in a new tab. */
918
+ const follow = EditorView.domEventHandlers({
919
+ mousedown(event, view) {
920
+ if (!(event.metaKey || event.ctrlKey))
921
+ return false;
922
+ return followLink(event, view);
923
+ },
924
+ });
925
+ /** Follows the link a press with a modifier fell in, if any: `true` when it did. */
926
+ function followLink(event, view) {
927
+ const target = event.target.closest('[data-target], [data-href]');
928
+ if (!target)
929
+ return false;
930
+ event.preventDefault();
931
+ followAddress(view, target.dataset['target'], target.dataset['href']);
932
+ return true;
933
+ }
934
+ /**
935
+ * Follows the link a drawn block stands in, pressed with a modifier: CodeMirror draws no mark around
936
+ * a widget, so the link around an image is found among the marks over its source (#112).
937
+ */
938
+ function followLinkAround(event, view, element) {
939
+ if (followLink(event, view))
940
+ return true;
941
+ const widget = drawing.get(element);
942
+ if (!widget)
943
+ return false;
944
+ const from = view.posAtDOM(element);
945
+ const to = from + widget.source.length;
946
+ const links = [];
947
+ view.state.field(decorationsField).between(from, to, (start, end, decoration) => {
948
+ const attributes = decoration.spec.attributes;
949
+ if (!attributes || start > from || end < to)
950
+ return;
951
+ if (attributes['data-target'] !== undefined || attributes['data-href'] !== undefined)
952
+ links.push({ target: attributes['data-target'], href: attributes['data-href'] });
953
+ });
954
+ const link = links[0];
955
+ if (!link)
956
+ return false;
957
+ event.preventDefault();
958
+ followAddress(view, link.target, link.href);
959
+ return true;
960
+ }
961
+ /** Goes to a wikilink's note through the host, or opens an address the page follows in a new tab. */
962
+ function followAddress(view, name, href) {
963
+ const { host } = view.state.facet(editorEnvironment);
964
+ if (name !== undefined) {
965
+ const address = host.resolve(name);
966
+ if (address !== null)
967
+ host.navigate(address);
968
+ return;
969
+ }
970
+ const address = followable(href ?? '');
971
+ if (address)
972
+ window.open(address, '_blank', 'noreferrer');
973
+ }
898
974
  /** How long an embed measured ahead of being drawn may take to load before it is let go (#94). */
899
975
  const PREMEASURE_TIMEOUT = 8000;
900
976
  /**
@@ -1036,5 +1112,6 @@ export function livePage(metrics) {
1036
1112
  embedPremeasure,
1037
1113
  blockKeys,
1038
1114
  follow,
1115
+ multiClick,
1039
1116
  ];
1040
1117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorysmithapp/markdown-renderer",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "A React component that draws a MemorySmith note exactly as MemorySmith's reading page does.",
5
5
  "keywords": [
6
6
  "markdown",
@@ -79,7 +79,7 @@
79
79
  "unified": "11.0.5"
80
80
  },
81
81
  "devDependencies": {
82
- "@memorysmithapp/markdown-spec": "0.8.0",
82
+ "@memorysmithapp/markdown-spec": "0.8.1",
83
83
  "@types/hast": "3.0.5",
84
84
  "@types/react": "19.2.18",
85
85
  "@types/react-dom": "19.2.4",