@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.
- package/dist/edit/blocks.d.ts +3 -0
- package/dist/edit/blocks.js +62 -6
- package/dist/edit/live.js +94 -17
- package/package.json +2 -2
package/dist/edit/blocks.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/edit/blocks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
876
|
-
const
|
|
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 (
|
|
896
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
879
897
|
return false;
|
|
880
|
-
|
|
881
|
-
|
|
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
|
|
893
|
-
if (
|
|
894
|
-
|
|
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.
|
|
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.
|
|
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",
|