@memorysmithapp/markdown-renderer 0.6.0 → 0.7.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/README.md +1 -1
- package/dist/NoteActions.d.ts +21 -2
- package/dist/NoteActions.js +14 -7
- package/dist/NoteBody.d.ts +15 -3
- package/dist/NoteBody.js +9 -8
- package/dist/edit/NoteEditor.d.ts +7 -1
- package/dist/edit/NoteEditor.js +41 -21
- package/dist/edit/blocks.d.ts +12 -0
- package/dist/edit/blocks.js +106 -0
- package/dist/edit/click-source.d.ts +16 -0
- package/dist/edit/click-source.js +74 -0
- package/dist/edit/heights.d.ts +35 -1
- package/dist/edit/heights.js +64 -3
- package/dist/edit/image-size.d.ts +36 -0
- package/dist/edit/image-size.js +61 -0
- package/dist/edit/live.d.ts +2 -0
- package/dist/edit/live.js +304 -71
- package/dist/host.d.ts +3 -1
- package/dist/index.d.ts +1 -0
- package/dist/mode-icons.d.ts +1 -0
- package/dist/mode-icons.js +2 -0
- package/dist/strings.d.ts +8 -0
- package/dist/strings.js +4 -0
- package/dist/theme.d.ts +7 -2
- package/dist/theme.js +1 -0
- package/package.json +2 -2
- package/structure.css +56 -2
- package/theme-default.css +51 -2
package/README.md
CHANGED
|
@@ -86,7 +86,7 @@ A note the host makes `editable` carries a button at its top right that switches
|
|
|
86
86
|
suggest={(typed) => namesOfNotesLike(typed)} // what [[ offers
|
|
87
87
|
attach={(file, { name, description }, progress) => keepInNotebook(file, name, description, progress)} // the name it is kept under
|
|
88
88
|
files={() => filesOfTheNotebook()} // what the attach dialog lists: { name, mimeType, bytes, url, description }
|
|
89
|
-
|
|
89
|
+
buttons="responsive" // the corner's buttons and the dialog's Copy: their word on a wide note; "label" always; "icon", the default, never
|
|
90
90
|
/>
|
|
91
91
|
```
|
|
92
92
|
|
package/dist/NoteActions.d.ts
CHANGED
|
@@ -3,7 +3,26 @@
|
|
|
3
3
|
* shows the note's Markdown in a dialog (#63), and one that copies it. Apart from the editor, so a
|
|
4
4
|
* note that is only read offers them without loading it.
|
|
5
5
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import { type ReactNode } from 'react';
|
|
7
|
+
/**
|
|
8
|
+
* How the buttons a note draws around its text show their word (#96): `icon`, the icon alone;
|
|
9
|
+
* `label`, the icon and the word; `responsive`, the word beside the icon on a note at least
|
|
10
|
+
* `--markdown-button-words-from` wide and hidden on a narrower one. Wherever the word may be
|
|
11
|
+
* hidden it is the button's name.
|
|
12
|
+
*/
|
|
13
|
+
export type NoteButtons = 'icon' | 'label' | 'responsive';
|
|
14
|
+
/** A button's icon and, unless it is drawn alone, its word, which the stylesheet may hide. */
|
|
15
|
+
export declare function ButtonFace({ icon, word, display, wordClass }: {
|
|
16
|
+
icon: ReactNode;
|
|
17
|
+
word: string;
|
|
18
|
+
display: NoteButtons;
|
|
19
|
+
wordClass?: string;
|
|
20
|
+
}): import("react").JSX.Element;
|
|
21
|
+
/**
|
|
22
|
+
* Show Markdown and Copy Markdown; `text` answers the bytes when a button is used, and `display`
|
|
23
|
+
* how they and the dialog's Copy show their word.
|
|
24
|
+
*/
|
|
25
|
+
export declare function NoteActions({ text, display }: {
|
|
8
26
|
text: () => string;
|
|
27
|
+
display?: NoteButtons;
|
|
9
28
|
}): import("react").JSX.Element;
|
package/dist/NoteActions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* What stands beside the switch between reading and editing, in both modes (#76): a button that
|
|
4
4
|
* shows the note's Markdown in a dialog (#63), and one that copies it. Apart from the editor, so a
|
|
@@ -7,6 +7,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
7
7
|
import { useEffect, useRef, useState } from 'react';
|
|
8
8
|
import { useHost } from './host.js';
|
|
9
9
|
import { ICON_CHECK, ICON_CLOSE, ICON_COPY, ICON_MARKDOWN } from './mode-icons.js';
|
|
10
|
+
/** A button's icon and, unless it is drawn alone, its word, which the stylesheet may hide. */
|
|
11
|
+
export function ButtonFace({ icon, word, display, wordClass }) {
|
|
12
|
+
return (_jsxs(_Fragment, { children: [icon, display !== 'icon' && _jsx("span", { className: wordClass ? `note-button-word ${wordClass}` : 'note-button-word', children: word })] }));
|
|
13
|
+
}
|
|
10
14
|
/** Puts the text on the clipboard; answers whether it got there. */
|
|
11
15
|
function copyText(text) {
|
|
12
16
|
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
|
|
@@ -19,7 +23,7 @@ function copyText(text) {
|
|
|
19
23
|
* closed by Escape, its button or a click outside it, the focus then back on the button that
|
|
20
24
|
* opened it.
|
|
21
25
|
*/
|
|
22
|
-
function SourceDialog({ text, onClose, onClosed }) {
|
|
26
|
+
function SourceDialog({ text, display, onClose, onClosed }) {
|
|
23
27
|
const host = useHost();
|
|
24
28
|
const words = host.strings.editor;
|
|
25
29
|
const dialog = useRef(null);
|
|
@@ -42,17 +46,20 @@ function SourceDialog({ text, onClose, onClosed }) {
|
|
|
42
46
|
closed.current();
|
|
43
47
|
};
|
|
44
48
|
}, []);
|
|
45
|
-
return (_jsx("dialog", { ref: dialog, className:
|
|
49
|
+
return (_jsx("dialog", { ref: dialog, className: `edit-source${display === 'responsive' ? ' has-responsive-buttons' : ''}`, "aria-modal": "true", "aria-labelledby": "edit-source-title", onCancel: (event) => {
|
|
46
50
|
event.preventDefault();
|
|
47
51
|
onClose();
|
|
48
52
|
},
|
|
49
53
|
// The dialog has no padding of its own, so a click on it and not on its content is outside.
|
|
50
|
-
onClick: (event) => event.target === dialog.current && onClose(), children: _jsxs("div", { className: "edit-source-frame", children: [_jsxs("header", { className: "edit-source-header", children: [_jsx("h2", { id: "edit-source-title", className: "edit-source-title", children: words.sourceTitle }), _jsx("span", { className: "edit-source-copied", role: "status", children: copied === 'copied' ? words.copied : copied === 'failed' ? words.copyFailed : '' }), _jsx("button", { type: "button", className:
|
|
54
|
+
onClick: (event) => event.target === dialog.current && onClose(), children: _jsxs("div", { className: "edit-source-frame", children: [_jsxs("header", { className: "edit-source-header", children: [_jsx("h2", { id: "edit-source-title", className: "edit-source-title", children: words.sourceTitle }), _jsx("span", { className: "edit-source-copied", role: "status", children: copied === 'copied' ? words.copied : copied === 'failed' ? words.copyFailed : '' }), _jsx("button", { type: "button", className: `edit-control edit-source-copy${display === 'label' ? ' edit-control-labelled' : ''}`, "data-buttons": display, title: words.copy, "aria-label": display === 'label' ? undefined : words.copy, onClick: () => void copyText(text).then((done) => setCopied(done ? 'copied' : 'failed')), children: _jsx(ButtonFace, { icon: copied === 'copied' ? ICON_CHECK : ICON_COPY, word: words.copy, display: display }) }), _jsx("button", { type: "button", className: "edit-control edit-source-close", "aria-label": host.strings.close, title: host.strings.close, onClick: onClose, children: ICON_CLOSE })] }), _jsx("pre", { className: "edit-source-text", tabIndex: 0, children: text })] }) }));
|
|
51
55
|
}
|
|
52
56
|
/** How long Copy Markdown shows its check, in milliseconds. */
|
|
53
57
|
const COPIED_MS = 1600;
|
|
54
|
-
/**
|
|
55
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Show Markdown and Copy Markdown; `text` answers the bytes when a button is used, and `display`
|
|
60
|
+
* how they and the dialog's Copy show their word.
|
|
61
|
+
*/
|
|
62
|
+
export function NoteActions({ text, display = 'icon' }) {
|
|
56
63
|
const words = useHost().strings.editor;
|
|
57
64
|
const show = useRef(null);
|
|
58
65
|
const [source, setSource] = useState(null);
|
|
@@ -64,5 +71,5 @@ export function NoteActions({ text }) {
|
|
|
64
71
|
return () => clearTimeout(timer);
|
|
65
72
|
}, [copied]);
|
|
66
73
|
const copyLabel = copied === 'copied' ? words.copied : copied === 'failed' ? words.copyFailed : words.copyMarkdown;
|
|
67
|
-
return (_jsxs(_Fragment, { children: [_jsx("button", { ref: show, type: "button", className:
|
|
74
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { ref: show, type: "button", className: `note-action note-action-${display}`, "data-buttons": display, title: words.showMarkdown, "aria-label": display === 'label' ? undefined : words.showMarkdown, "aria-haspopup": "dialog", onClick: () => setSource(text()), children: _jsx(ButtonFace, { icon: ICON_MARKDOWN, word: words.showMarkdown, display: display }) }), _jsx("button", { type: "button", className: `note-action note-action-${display}`, "data-buttons": display, "data-copied": copied ?? undefined, title: copyLabel, "aria-label": words.copyMarkdown, onClick: () => void copyText(text()).then((done) => setCopied(done ? 'copied' : 'failed')), children: _jsx(ButtonFace, { icon: copied === 'copied' ? ICON_CHECK : ICON_COPY, word: words.copyMarkdown, display: display }) }), _jsx("span", { className: "visually-hidden", role: "status", children: copied === 'copied' ? words.copied : copied === 'failed' ? words.copyFailed : '' }), source !== null && _jsx(SourceDialog, { text: source, display: display, onClose: () => setSource(null), onClosed: () => show.current?.focus() })] }));
|
|
68
75
|
}
|
package/dist/NoteBody.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type NoteButtons } from './NoteActions.js';
|
|
1
2
|
import { type NoteHost } from './host.js';
|
|
2
3
|
export interface NoteBodyProps extends NoteHost {
|
|
3
4
|
/**
|
|
@@ -26,8 +27,19 @@ export interface NoteBodyProps extends NoteHost {
|
|
|
26
27
|
*/
|
|
27
28
|
readonly onChange?: (markdown: string) => unknown;
|
|
28
29
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
30
|
+
* How every button the note draws around its text shows its word — Show Markdown, Copy Markdown,
|
|
31
|
+
* the switch, and Copy in the dialog of Show Markdown (#96): `icon`, the icon alone; `label`, the
|
|
32
|
+
* icon and the word; `responsive`, the word beside the icon on a note at least
|
|
33
|
+
* `--markdown-button-words-from` wide (560px by default) and hidden on a narrower one. Wherever
|
|
34
|
+
* the word is not drawn, or may be hidden, it is the button's name and its title. Defaults to
|
|
35
|
+
* `icon`, or to `modeButton` when only that is given.
|
|
36
|
+
*/
|
|
37
|
+
readonly buttons?: NoteButtons;
|
|
38
|
+
/**
|
|
39
|
+
* The switch alone as its icon or its icon and its word (#92).
|
|
40
|
+
*
|
|
41
|
+
* @deprecated Use `buttons`, which governs the switch and the buttons beside it; this is read
|
|
42
|
+
* only when `buttons` is left out.
|
|
31
43
|
*/
|
|
32
44
|
readonly modeButton?: 'icon' | 'label';
|
|
33
45
|
/**
|
|
@@ -45,4 +57,4 @@ export type NoteMode = 'view' | 'edit';
|
|
|
45
57
|
* It renders inside `.markdown`, so the stylesheet of the package applies, and asks the host for
|
|
46
58
|
* every piece of notebook data through the props of `NoteHost`.
|
|
47
59
|
*/
|
|
48
|
-
export declare function NoteBody({ editable, mode, defaultMode, onModeChange, onChange, modeButton, actions, ...rest }: NoteBodyProps): import("react").JSX.Element;
|
|
60
|
+
export declare function NoteBody({ editable, mode, defaultMode, onModeChange, onChange, buttons, modeButton, actions, ...rest }: NoteBodyProps): import("react").JSX.Element;
|
package/dist/NoteBody.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { lazy, Suspense, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { ICON_EDIT, ICON_VIEW } from './mode-icons.js';
|
|
4
|
-
import { NoteActions } from './NoteActions.js';
|
|
4
|
+
import { ButtonFace, NoteActions } from './NoteActions.js';
|
|
5
5
|
import { drawSlot } from './slots.js';
|
|
6
6
|
import { HOST_KEYS, HostProvider, resolveHost, useHost } from './host.js';
|
|
7
7
|
import { planFootnotes } from './footnotes.js';
|
|
@@ -26,7 +26,7 @@ function ModeSwitch({ mode, display, toggle }) {
|
|
|
26
26
|
const icon = mode === 'view' ? ICON_EDIT : ICON_VIEW;
|
|
27
27
|
if (slot)
|
|
28
28
|
return _jsx(_Fragment, { children: drawSlot(slot, { mode, label, icon, display, toggle }) });
|
|
29
|
-
return (
|
|
29
|
+
return (_jsx("button", { type: "button", className: `mode-switch mode-switch-${display}`, "data-mode": mode, "data-buttons": display, title: label, "aria-label": display === 'label' ? undefined : label, onClick: toggle, children: _jsx(ButtonFace, { icon: icon, word: label, display: display, wordClass: "mode-switch-label" }) }));
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
32
|
* The body of a note, drawn as MemorySmith's reading page draws it.
|
|
@@ -34,13 +34,14 @@ function ModeSwitch({ mode, display, toggle }) {
|
|
|
34
34
|
* It renders inside `.markdown`, so the stylesheet of the package applies, and asks the host for
|
|
35
35
|
* every piece of notebook data through the props of `NoteHost`.
|
|
36
36
|
*/
|
|
37
|
-
export function NoteBody({ editable = false, mode, defaultMode = 'view', onModeChange, onChange, modeButton = 'icon', actions = true, ...rest }) {
|
|
37
|
+
export function NoteBody({ editable = false, mode, defaultMode = 'view', onModeChange, onChange, buttons, modeButton = 'icon', actions = true, ...rest }) {
|
|
38
38
|
const [own, setOwn] = useState(defaultMode);
|
|
39
39
|
// The author asked to go back to reading, and the editor is finishing: a save, the toolbar folding.
|
|
40
40
|
const [leaving, setLeaving] = useState(false);
|
|
41
41
|
// The last text handed to the host in edit mode, which Show and Copy Markdown give there (#76).
|
|
42
42
|
const handed = useRef(null);
|
|
43
43
|
const current = editable ? (mode ?? own) : 'view';
|
|
44
|
+
const display = buttons ?? modeButton;
|
|
44
45
|
if (!editable && !actions)
|
|
45
46
|
return _jsx(NoteView, { ...rest });
|
|
46
47
|
const go = (next) => {
|
|
@@ -69,12 +70,12 @@ export function NoteBody({ editable = false, mode, defaultMode = 'view', onModeC
|
|
|
69
70
|
return (_jsx(HostOnly, { host: rest, children: (resolved) => {
|
|
70
71
|
// Show and Copy Markdown for whoever reads the note, in both modes (#76, #87), and the
|
|
71
72
|
// switch only where the note is editable.
|
|
72
|
-
const corner = (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: "note-corner", children: [actions && _jsx(NoteActions, { text: () => (current === 'edit' ? (handed.current ?? rest.markdown) : rest.markdown) }), editable && _jsx(ModeSwitch, { mode: current, display:
|
|
73
|
+
const corner = (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: "note-corner", children: [actions && _jsx(NoteActions, { display: display, text: () => (current === 'edit' ? (handed.current ?? rest.markdown) : rest.markdown) }), editable && _jsx(ModeSwitch, { mode: current, display: display, toggle: toggle })] }) }));
|
|
73
74
|
if (current === 'view')
|
|
74
|
-
return _jsx(NoteView, { ...rest, corner: corner, switched: editable });
|
|
75
|
+
return _jsx(NoteView, { ...rest, corner: corner, switched: editable, buttons: display });
|
|
75
76
|
return (
|
|
76
77
|
// While the editor loads, the note stays on the page as it was.
|
|
77
|
-
_jsx(Suspense, { fallback: _jsx(NoteView, { ...rest, corner: corner, switched: true }), children: _jsx(NoteEditor, { ...host, onChange: hand, corner: corner, autoFocus: true, leaving: leaving, onLeave: onLeave }) }));
|
|
78
|
+
_jsx(Suspense, { fallback: _jsx(NoteView, { ...rest, corner: corner, switched: true, buttons: display }), children: _jsx(NoteEditor, { ...host, onChange: hand, corner: corner, buttons: display, autoFocus: true, leaving: leaving, onLeave: onLeave }) }));
|
|
78
79
|
} }));
|
|
79
80
|
}
|
|
80
81
|
/** The host resolved once, for what is drawn around the note. */
|
|
@@ -83,7 +84,7 @@ function HostOnly({ host, children }) {
|
|
|
83
84
|
return _jsx(_Fragment, { children: children(resolved) });
|
|
84
85
|
}
|
|
85
86
|
/** The note drawn for reading: the page MemorySmith's reading page draws. */
|
|
86
|
-
function NoteView({ markdown, writable = false, onToggleTask, corner, switched = false, ...host }) {
|
|
87
|
+
function NoteView({ markdown, writable = false, onToggleTask, corner, switched = false, buttons = 'icon', ...host }) {
|
|
87
88
|
const resolved = useMemo(() => resolveHost(host),
|
|
88
89
|
// Every member of the contract, so a host that passes the same functions keeps the same host.
|
|
89
90
|
HOST_KEYS.map((key) => host[key]));
|
|
@@ -147,7 +148,7 @@ function NoteView({ markdown, writable = false, onToggleTask, corner, switched =
|
|
|
147
148
|
// The runs and whether an embed cut them are the whole input.
|
|
148
149
|
[runs.join('\u0000'), runs.length < segments.length]);
|
|
149
150
|
let run = 0;
|
|
150
|
-
return (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: `markdown markdown-theme-${resolved.theme.name}${corner ? ' has-note-corner' : ''}${switched ? ' has-mode-switch' : ''}`, "data-theme": resolved.scheme, style: rootStyle(resolved.theme, resolved.scheme), children: [corner, _jsx(Properties, { properties: properties }), segments.map((segment, index) => {
|
|
151
|
+
return (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: `markdown markdown-theme-${resolved.theme.name}${corner ? ' has-note-corner' : ''}${switched ? ' has-mode-switch' : ''}${corner && buttons === 'responsive' ? ' has-responsive-buttons' : ''}`, "data-theme": resolved.scheme, style: rootStyle(resolved.theme, resolved.scheme), children: [corner, _jsx(Properties, { properties: properties }), segments.map((segment, index) => {
|
|
151
152
|
if (!('rendered' in segment)) {
|
|
152
153
|
return _jsx(Transclusion, { target: segment.target, anchor: segment.anchor }, index);
|
|
153
154
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { type ReactNode } from 'react';
|
|
11
11
|
import { type NoteHost } from '../host.js';
|
|
12
|
+
import { type NoteButtons } from '../NoteActions.js';
|
|
12
13
|
export interface NoteEditorProps extends Omit<NoteHost, 'writable' | 'onToggleTask'> {
|
|
13
14
|
/** The note as the host keeps it, frontmatter included. A new value that differs from the editor's is taken in. */
|
|
14
15
|
readonly markdown: string;
|
|
@@ -22,6 +23,11 @@ export interface NoteEditorProps extends Omit<NoteHost, 'writable' | 'onToggleTa
|
|
|
22
23
|
readonly editable?: boolean;
|
|
23
24
|
/** What sits at the top right of the note, such as the switch back to reading. */
|
|
24
25
|
readonly corner?: ReactNode;
|
|
26
|
+
/**
|
|
27
|
+
* How the buttons at the top right show their word, as `NoteBody`'s `buttons` (#96): the bar is
|
|
28
|
+
* what a `responsive` button measures. Defaults to `icon`.
|
|
29
|
+
*/
|
|
30
|
+
readonly buttons?: NoteButtons;
|
|
25
31
|
/** Whether the editor takes the focus when it opens. */
|
|
26
32
|
readonly autoFocus?: boolean;
|
|
27
33
|
/**
|
|
@@ -41,4 +47,4 @@ export declare function lineBreakOf(text: string): '\n' | '\r\n' | null;
|
|
|
41
47
|
* Edits a note on the page that draws it. Exported at `@memorysmithapp/markdown-renderer/edit`
|
|
42
48
|
* for a host that edits without the switch of `NoteBody`; `NoteBody` loads it on demand.
|
|
43
49
|
*/
|
|
44
|
-
export declare function NoteEditor({ markdown: given, onChange, editable, corner, autoFocus, leaving, onLeave, ...host }: NoteEditorProps): import("react").JSX.Element;
|
|
50
|
+
export declare function NoteEditor({ markdown: given, onChange, editable, corner, buttons, autoFocus, leaving, onLeave, ...host }: NoteEditorProps): import("react").JSX.Element;
|
package/dist/edit/NoteEditor.js
CHANGED
|
@@ -115,6 +115,8 @@ function insertBlockCommand(name) {
|
|
|
115
115
|
const NOT_ATTACHING = { state: 'idle', message: null, fraction: null, queued: 0 };
|
|
116
116
|
/** How long the attach button says a file was not attached, when the host did not say why, in milliseconds. */
|
|
117
117
|
const ATTACH_FAILED_MS = 3000;
|
|
118
|
+
/** The keys that write besides a character's own: a note held for the author's choice asks again on them (#101). */
|
|
119
|
+
const WRITING_KEYS = new Set(['Enter', 'Backspace', 'Delete', 'Tab']);
|
|
118
120
|
/** A host's fraction, kept between 0 and 1, or none when it gave no number. */
|
|
119
121
|
function fractionOf(report) {
|
|
120
122
|
const fraction = report?.fraction;
|
|
@@ -144,25 +146,21 @@ function Toolbar({ view, host, end, opened, onAttach, attaching, attachButton, }
|
|
|
144
146
|
* each state, in a slot of one size, so the toolbar never moves as the author types; its word is
|
|
145
147
|
* its title. A failed save's icon is the button that tries again (#66). When the host said why —
|
|
146
148
|
* an alert on a save that succeeded, or the reason for one it refused — the icon is the button that
|
|
147
|
-
* opens what it said, with the host's title as its tooltip, and the author's choices (#86)
|
|
148
|
-
*
|
|
149
|
+
* opens what it said, with the host's title as its tooltip, and the author's choices (#86); a notice
|
|
150
|
+
* with choices opens by itself as it arrives, and a refusal with choices holds the note until the
|
|
151
|
+
* author chooses (#101). Whoever hears the page is told the whole of it, why the note stays in edit
|
|
152
|
+
* included.
|
|
149
153
|
*/
|
|
150
|
-
function SaveStatus({ saves, blocked, host, onDecided }) {
|
|
154
|
+
function SaveStatus({ saves, blocked, deciding, open, setOpen, host, onDecided, }) {
|
|
151
155
|
const { state, notice } = saves;
|
|
152
156
|
const words = host.strings.editor;
|
|
153
|
-
const [open, setOpen] = useState(false);
|
|
154
157
|
const button = useRef(null);
|
|
155
|
-
// A notice the save no longer carries has nothing left to show.
|
|
156
|
-
useEffect(() => {
|
|
157
|
-
if (!notice)
|
|
158
|
-
setOpen(false);
|
|
159
|
-
}, [notice]);
|
|
160
158
|
const base = state === 'saving' ? words.saving : state === 'saved' ? host.strings.saved : state === 'failed' ? host.strings.notSaved : '';
|
|
161
|
-
const shown = notice ? notice.title : base;
|
|
162
|
-
const said = state === 'failed' && blocked ? (notice ? `${words.stayInEdit}. ${
|
|
159
|
+
const shown = notice ? (deciding ? `${notice.title}. ${words.chooseToEdit}` : notice.title) : base;
|
|
160
|
+
const said = state === 'failed' && blocked ? (notice ? `${words.stayInEdit}. ${shown}` : words.stayInEdit) : shown;
|
|
163
161
|
const icon = state === 'saving' ? ICONS.saving : state === 'saved' ? ICONS.saved : state === 'alert' ? ICONS.alert : state === 'failed' ? ICONS.notSaved : null;
|
|
164
162
|
const retryLabel = `${said}. ${words.retry}`;
|
|
165
|
-
return (_jsxs("div", { className: "edit-save", "data-save": state, title: state === 'failed' || notice ? undefined : said || undefined, children: [notice ? (_jsx("button", { ref: button, type: "button", className: "edit-save-icon edit-save-notice", title:
|
|
163
|
+
return (_jsxs("div", { className: "edit-save", "data-save": state, title: state === 'failed' || notice ? undefined : said || undefined, children: [notice ? (_jsx("button", { ref: button, type: "button", className: "edit-save-icon edit-save-notice", title: shown, "aria-label": shown, "aria-haspopup": "dialog", onClick: () => setOpen(true), children: icon })) : state === 'failed' ? (_jsx("button", { type: "button", className: "edit-save-icon edit-save-retry", title: retryLabel, "aria-label": retryLabel, onClick: saves.retry, children: icon })) : (_jsx("span", { className: "edit-save-icon", children: icon })), _jsx("span", { className: "visually-hidden", role: "status", "aria-live": "polite", children: said }), open && notice && (_jsx(SaveNoticeDialog, { notice: notice, onClose: () => setOpen(false), onClosed: (chosen) => (chosen ? onDecided() : button.current?.focus()), onAction: (action) => {
|
|
166
164
|
setOpen(false);
|
|
167
165
|
saves.run(action);
|
|
168
166
|
} }))] }));
|
|
@@ -171,7 +169,7 @@ function SaveStatus({ saves, blocked, host, onDecided }) {
|
|
|
171
169
|
* Edits a note on the page that draws it. Exported at `@memorysmithapp/markdown-renderer/edit`
|
|
172
170
|
* for a host that edits without the switch of `NoteBody`; `NoteBody` loads it on demand.
|
|
173
171
|
*/
|
|
174
|
-
export function NoteEditor({ markdown: given, onChange, editable = true, corner, autoFocus = false, leaving = false, onLeave, ...host }) {
|
|
172
|
+
export function NoteEditor({ markdown: given, onChange, editable = true, corner, buttons = 'icon', autoFocus = false, leaving = false, onLeave, ...host }) {
|
|
175
173
|
const resolved = useMemo(() => resolveHost(host),
|
|
176
174
|
// Every member of the contract, so a host that passes the same functions keeps the same host.
|
|
177
175
|
HOST_KEYS.map((key) => host[key]));
|
|
@@ -181,8 +179,18 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
|
|
|
181
179
|
const environment = useRef(new Compartment());
|
|
182
180
|
const access = useRef(new Compartment());
|
|
183
181
|
const saves = useEditSaves(onChange);
|
|
184
|
-
|
|
185
|
-
|
|
182
|
+
/**
|
|
183
|
+
* A refusal that gives the author choices — another person wrote the note, say — is decided
|
|
184
|
+
* before the note is written in again: every save after it would be refused too (#101).
|
|
185
|
+
*/
|
|
186
|
+
const deciding = saves.state === 'failed' && (saves.notice?.actions.length ?? 0) > 0;
|
|
187
|
+
const [noticeOpen, setNoticeOpen] = useState(false);
|
|
188
|
+
// A notice with choices opens by itself as it arrives; one the save no longer carries closes.
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
setNoticeOpen(!!saves.notice && saves.notice.actions.length > 0);
|
|
191
|
+
}, [saves.notice]);
|
|
192
|
+
const latest = useRef({ hand: saves.hand, host: resolved, onLeave, deciding, ask: () => setNoticeOpen(true) });
|
|
193
|
+
latest.current = { hand: saves.hand, host: resolved, onLeave, deciding, ask: () => setNoticeOpen(true) };
|
|
186
194
|
/**
|
|
187
195
|
* The texts handed to the host that it has not given back yet. A host that saves before it
|
|
188
196
|
* passes the note again gives back a text the editor has since moved past; that echo is not a
|
|
@@ -336,7 +344,9 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
|
|
|
336
344
|
// The note as edit mode opened it, as the editor holds its text: lines joined by `\n`.
|
|
337
345
|
const opened = useRef(given.replace(/\r\n?/g, '\n'));
|
|
338
346
|
const lineBreak = lineBreakOf(given);
|
|
339
|
-
const
|
|
347
|
+
const locked = !editable || lineBreak === null;
|
|
348
|
+
// Held while the author decides: the caret still moves, and a key that would write asks again.
|
|
349
|
+
const readOnly = locked || deciding;
|
|
340
350
|
/**
|
|
341
351
|
* The editor splits its text on any line break, so a paste or a block written with `\n` is lines
|
|
342
352
|
* like any other, and writes the note back with the break it was written with: what a host
|
|
@@ -353,7 +363,7 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
|
|
|
353
363
|
state: EditorState.create({
|
|
354
364
|
doc: given,
|
|
355
365
|
extensions: [
|
|
356
|
-
access.current.of([EditorState.readOnly.of(readOnly), EditorView.editable.of(!
|
|
366
|
+
access.current.of([EditorState.readOnly.of(readOnly), EditorView.editable.of(!locked)]),
|
|
357
367
|
environment.current.of(editorEnvironment.of({ host: resolved })),
|
|
358
368
|
history(),
|
|
359
369
|
markdown(),
|
|
@@ -373,6 +383,16 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
|
|
|
373
383
|
return true;
|
|
374
384
|
}),
|
|
375
385
|
EditorView.domEventHandlers({
|
|
386
|
+
// A key that would write, while the note waits for the author's choice, shows the choice again (#101).
|
|
387
|
+
keydown: (event) => {
|
|
388
|
+
if (!latest.current.deciding || event.ctrlKey || event.metaKey || event.altKey)
|
|
389
|
+
return false;
|
|
390
|
+
if (event.key.length !== 1 && !WRITING_KEYS.has(event.key))
|
|
391
|
+
return false;
|
|
392
|
+
event.preventDefault();
|
|
393
|
+
latest.current.ask();
|
|
394
|
+
return true;
|
|
395
|
+
},
|
|
376
396
|
// The editing menu where the pointer is; Shift keeps the browser's, and so do the
|
|
377
397
|
// drawn blocks' own fields, which are the browser's to edit (#81).
|
|
378
398
|
contextmenu: (event, target) => {
|
|
@@ -436,8 +456,8 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
|
|
|
436
456
|
view?.dispatch({ effects: environment.current.reconfigure(editorEnvironment.of({ host: resolved })) });
|
|
437
457
|
}, [view, resolved]);
|
|
438
458
|
useEffect(() => {
|
|
439
|
-
view?.dispatch({ effects: access.current.reconfigure([EditorState.readOnly.of(readOnly), EditorView.editable.of(!
|
|
440
|
-
}, [view, readOnly]);
|
|
459
|
+
view?.dispatch({ effects: access.current.reconfigure([EditorState.readOnly.of(readOnly), EditorView.editable.of(!locked)]) });
|
|
460
|
+
}, [view, readOnly, locked]);
|
|
441
461
|
// A note the host changed — a tick saved, another device — is taken in as the change it is.
|
|
442
462
|
useEffect(() => {
|
|
443
463
|
if (!view)
|
|
@@ -504,7 +524,7 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
|
|
|
504
524
|
clearTimeout(timer);
|
|
505
525
|
};
|
|
506
526
|
}, [leaving, saves.state]);
|
|
507
|
-
return (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: `markdown markdown-theme-${resolved.theme.name} markdown-editing`, "data-theme": resolved.scheme, style: rootStyle(resolved.theme, resolved.scheme), children: [_jsx("div", { ref: bar, className:
|
|
527
|
+
return (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: `markdown markdown-theme-${resolved.theme.name} markdown-editing`, "data-theme": resolved.scheme, style: rootStyle(resolved.theme, resolved.scheme), children: [_jsx("div", { ref: bar, className: `edit-bar${buttons === 'responsive' ? ' has-responsive-buttons' : ''}`, "data-leaving": folding ? '' : undefined, children: _jsx(Toolbar, { view: view, host: resolved, opened: opened.current, onAttach: () => {
|
|
508
528
|
// Opening the paperclip is the author acting on a refusal: the dialog says it once more.
|
|
509
529
|
if (attaching.state === 'failed') {
|
|
510
530
|
setRefusal(attaching.message);
|
|
@@ -513,7 +533,7 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
|
|
|
513
533
|
else
|
|
514
534
|
setRefusal(null);
|
|
515
535
|
setAttachOpen(true);
|
|
516
|
-
}, attaching: attaching, attachButton: attachButton, end: _jsxs("div", { className: "edit-corner", "aria-busy": leaving && saves.state === 'saving' ? true : undefined, "data-blocked": blocked ? '' : undefined, children: [_jsx(SaveStatus, { saves: saves, blocked: blocked, host: resolved, onDecided: () => view?.focus() }), corner ?? _jsx(NoteActions, { text: () => (view ? output(view.state) : given) })] }, blocked ? `blocked-${refusals}` : 'corner') }) }), menu && view && (_jsx(ContextMenu, { x: menu.x, y: menu.y, label: resolved.strings.editor.menu, groups: menuGroups(view), onClose: (back) => {
|
|
536
|
+
}, attaching: attaching, attachButton: attachButton, end: _jsxs("div", { className: "edit-corner", "aria-busy": leaving && saves.state === 'saving' ? true : undefined, "data-blocked": blocked ? '' : undefined, children: [_jsx(SaveStatus, { saves: saves, blocked: blocked, deciding: deciding, open: noticeOpen, setOpen: setNoticeOpen, host: resolved, onDecided: () => view?.focus() }), corner ?? _jsx(NoteActions, { display: buttons, text: () => (view ? output(view.state) : given) })] }, blocked ? `blocked-${refusals}` : 'corner') }) }), menu && view && (_jsx(ContextMenu, { x: menu.x, y: menu.y, label: resolved.strings.editor.menu, groups: menuGroups(view), onClose: (back) => {
|
|
517
537
|
setMenu(null);
|
|
518
538
|
if (back)
|
|
519
539
|
view.focus();
|
package/dist/edit/blocks.d.ts
CHANGED
|
@@ -29,6 +29,18 @@ export declare function CalloutBlock({ view, text, marker }: {
|
|
|
29
29
|
fold: '+' | '-' | null;
|
|
30
30
|
};
|
|
31
31
|
}): import("react").JSX.Element;
|
|
32
|
+
/**
|
|
33
|
+
* An image drawn in edit mode with the size it is drawn at set in place (#98): a width in pixels,
|
|
34
|
+
* its height following, a handle at its corner that drags it, and a button back to its own size.
|
|
35
|
+
* Each writes the dimensions of its notation — `` or `![[photo.jpg|300]]` —
|
|
36
|
+
* and no other byte. The controls appear once an image is drawn inside it, so an embed of a file
|
|
37
|
+
* that is not an image, or a host's own drawing without one, offers none.
|
|
38
|
+
*/
|
|
39
|
+
export declare function ImageBlock({ view, source, children }: {
|
|
40
|
+
view: EditorView;
|
|
41
|
+
source: string;
|
|
42
|
+
children?: ReactNode;
|
|
43
|
+
}): import("react").JSX.Element;
|
|
32
44
|
/**
|
|
33
45
|
* A table whose cells are edited where they stand. A cell commits its text when it loses the focus,
|
|
34
46
|
* or on Enter (to the cell below) and Tab (to the next one); each commit rewrites that cell's
|
package/dist/edit/blocks.js
CHANGED
|
@@ -17,6 +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
22
|
import { addEntry, readFrontmatter, removeEntry, renameKey, setList, setScalar, } from './frontmatter.js';
|
|
21
23
|
import { addColumn, addRow, alignColumn, columnCount, readTable, removeColumn, removeRow, setCell, } from './table.js';
|
|
22
24
|
/** Whether a block's edit is being applied: the block is drawn anew, and the field it held lets the focus go. */
|
|
@@ -71,6 +73,110 @@ export function CalloutBlock({ view, text, marker }) {
|
|
|
71
73
|
const rewrite = (type, fold) => write(view, [{ from: marker.from, to: marker.to, insert: `[!${type}]${fold ?? ''}` }]);
|
|
72
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 })] }));
|
|
73
75
|
}
|
|
76
|
+
// ── Images ───────────────────────────────────────────────────────────────────
|
|
77
|
+
/** The narrowest an image is dragged to, so its handle stays within reach. */
|
|
78
|
+
const MIN_DRAGGED_WIDTH = 24;
|
|
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 — `` 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.
|
|
85
|
+
*/
|
|
86
|
+
export function ImageBlock({ view, source, children }) {
|
|
87
|
+
const words = useHost().strings.editor;
|
|
88
|
+
const frame = useRef(null);
|
|
89
|
+
const size = readImageSize(source);
|
|
90
|
+
const [image, setImage] = useState(null);
|
|
91
|
+
const [natural, setNatural] = useState(null);
|
|
92
|
+
const [typed, setTyped] = useState(null);
|
|
93
|
+
const [dragged, setDragged] = useState(null);
|
|
94
|
+
const drag = useRef(null);
|
|
95
|
+
// An embed's image arrives when the host answers; the controls wait for it.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const element = frame.current;
|
|
98
|
+
if (!element)
|
|
99
|
+
return;
|
|
100
|
+
const look = () => setImage(element.querySelector('img'));
|
|
101
|
+
look();
|
|
102
|
+
if (typeof MutationObserver === 'undefined')
|
|
103
|
+
return;
|
|
104
|
+
const observer = new MutationObserver(look);
|
|
105
|
+
observer.observe(element, { childList: true, subtree: true });
|
|
106
|
+
return () => observer.disconnect();
|
|
107
|
+
}, []);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!image)
|
|
110
|
+
return;
|
|
111
|
+
const read = () => setNatural(image.naturalWidth || null);
|
|
112
|
+
read();
|
|
113
|
+
image.addEventListener('load', read);
|
|
114
|
+
return () => image.removeEventListener('load', read);
|
|
115
|
+
}, [image]);
|
|
116
|
+
const resize = (width) => {
|
|
117
|
+
const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block');
|
|
118
|
+
if (!widget)
|
|
119
|
+
return;
|
|
120
|
+
const from = view.posAtDOM(widget);
|
|
121
|
+
// The widget stands where its source was: anything else means the note moved under it.
|
|
122
|
+
if (view.state.sliceDoc(from, from + source.length) !== source)
|
|
123
|
+
return;
|
|
124
|
+
write(view, resizeImage(source, from, width));
|
|
125
|
+
};
|
|
126
|
+
const commitTyped = () => {
|
|
127
|
+
if (typed === null)
|
|
128
|
+
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);
|
|
135
|
+
};
|
|
136
|
+
const onKeyDown = (event) => {
|
|
137
|
+
if (event.key === 'Enter') {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
commitTyped();
|
|
140
|
+
}
|
|
141
|
+
else if (event.key === 'Escape') {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
setTyped(null);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const shown = dragged ?? size?.width ?? null;
|
|
147
|
+
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) => {
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
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));
|
|
153
|
+
}, onPointerMove: (event) => {
|
|
154
|
+
const start = drag.current;
|
|
155
|
+
if (!start || start.pointer !== event.pointerId)
|
|
156
|
+
return;
|
|
157
|
+
const width = Math.max(MIN_DRAGGED_WIDTH, Math.round(start.width + event.clientX - start.x));
|
|
158
|
+
image.style.width = `${width}px`;
|
|
159
|
+
image.style.height = 'auto';
|
|
160
|
+
// 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
|
+
} })] }))] }));
|
|
179
|
+
}
|
|
74
180
|
// ── Tables ───────────────────────────────────────────────────────────────────
|
|
75
181
|
/** Where the focus goes back to when a table is drawn again after an edit of one of its cells. */
|
|
76
182
|
let pendingFocus = null;
|
|
@@ -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
|
+
}
|