@hachej/boring-ui-kit 0.4.7 → 0.4.9
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 +6 -2
- package/dist/markdown-editor.d.ts +6 -4
- package/dist/markdown-editor.js +64 -24
- package/dist/markdown-extensions.d.ts +2 -1
- package/dist/markdown-extensions.js +78 -10
- package/dist/markdown-icons.d.ts +20 -0
- package/dist/markdown-icons.js +2 -0
- package/dist/markdown-parser.js +1 -1
- package/dist/markdown-preserved.d.ts +4 -0
- package/dist/markdown-preserved.js +126 -0
- package/dist/markdown-safety.d.ts +2 -4
- package/dist/markdown-safety.js +16 -33
- package/dist/markdown-source.d.ts +17 -0
- package/dist/markdown-source.js +74 -0
- package/dist/viewer-components.d.ts +22 -0
- package/package.json +2 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +9 -9
- package/r/board-viewer.json +5 -5
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +10 -10
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +5 -5
- package/r/file-tree.json +5 -5
- package/r/highlights.json +4 -4
- package/r/html-viewer.json +3 -3
- package/r/maquette-viewer.json +2 -2
- package/r/markdown-editor.json +9 -5
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +7 -7
- package/r/pi-app.json +15 -15
- package/r/pi-chat.json +5 -5
- package/r/pi-workspace.json +4 -4
- package/r/placed-agent.json +27 -0
- package/r/provider-setup.json +3 -3
- package/r/record-viewer.json +1 -1
- package/r/registry.json +151 -101
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +4 -4
- package/r/task-list-viewer.json +2 -2
- package/r/utils.json +1 -1
- package/r/version-history.json +3 -3
- package/r/viewers.json +33 -12
- package/r/windows.json +1 -1
package/README.md
CHANGED
|
@@ -38,7 +38,11 @@ import { MarkdownEditor } from '@hachej/boring-ui-kit/markdown-editor';
|
|
|
38
38
|
|
|
39
39
|
Install the pinned React, React DOM, Marked and Tiptap peers listed in this package's manifest when selecting this entry. They remain optional for headless consumers. The renderer borrows its controller; unmounting does not dispose it or close its resource client. The host owns selection, identity and teardown.
|
|
40
40
|
|
|
41
|
-
Formatting defaults to the
|
|
41
|
+
Formatting defaults to the native Tiptap selection bubble on desktop and phone. The header’s **Formatting** icon toggles the full bar; unpressed means selection-only formatting. It remembers the choice for the viewer session, stays visible on phones with a 48px target and hides in Markdown mode. The **…** menu keeps **Formatted** / **Markdown**. Pass `formattingToolbar="pinned"` for the full desktop row (on phones it docks above the keyboard while editing). The built-in menu supports `locale="fr"`. Installed recipes supply real shadcn Base UI menu items through the optional `components.markdownMenu` binding. Keyboard shortcuts and Markdown input rules remain available. Rich formatting and Markdown source controls share controller state. Mounting, switching modes and projecting remote text never serialize the original buffer. Genuine rich edits serialize the changed block range through Tiptap; untouched source spans are retained verbatim.
|
|
42
|
+
|
|
43
|
+
Formatted opens documents with YAML front matter, raw HTML/comments, footnotes, reference links, CRLF and style variants (`*` bullets, four-space nesting, underscore emphasis and setext headings). Front matter is an editable literal code block. HTML and definitions are quiet raw blocks; inline HTML and references are inert source atoms, never executable HTML. A save without editing preserves the original bytes. Paragraph/heading/list-item source metadata also keeps unchanged spelling through edits and native undo. Edited content follows its existing heading style and document markers where valid; intraword emphasis uses stars when underscores would change its meaning. CRLF is edited as LF and restored on write. The existing legacy ` `-only paragraph cleanup remains.
|
|
44
|
+
|
|
45
|
+
Mixed line endings, bare carriage returns, NUL characters and blocks whose native projection cannot retain verified source spans still open in Markdown source with a specific explanation. `checkMarkdownRichSafety` reports that decision. Provider save, conflict and recovery semantics are unchanged.
|
|
42
46
|
|
|
43
47
|
Autosave is enabled by default after 1200ms of idle editing and flushes on blur and page hide through the existing conditional `controller.flush`. Untouched new buffers are not automatically published. `autosave={false}` restores explicit Save; `autosave={{ delayMs: 500 }}` changes the idle delay. Ctrl/Cmd+S always saves the exact selected buffer explicitly. Refused, conflicting and unconfirmed saves retain the draft and pause automatic retries until resolved.
|
|
44
48
|
|
|
@@ -48,7 +52,7 @@ Proposal review shows added/removed lines with two lines of context and expandab
|
|
|
48
52
|
|
|
49
53
|
Detailed alignment uses a matrix of at most 250,000 cells after trimming equal prefix/suffix lines. Larger comparisons show all removed/added middle lines with an explicit notice. This bounds the comparison matrix, not total document size, string-comparison cost or rendered rows.
|
|
50
54
|
|
|
51
|
-
The rich view
|
|
55
|
+
The rich view offers a Document outline built from the native parsed headings when `showOutline` is true. By default, the option follows the built-in `header` visibility; the outline stays hidden until **Show outline** is checked in the menu. Navigation changes the native selection without editing or saving Markdown. Rich/source switches retain each mode's selection and direction while the buffer version is unchanged. Text projected from outside (load, refresh, discard, source edits) puts the rich selection at the document start, where the browser leaves its caret, so a click or formatting command never acts on a stale selection at the end. External replacement or source edits invalidate older bookmarks; changing controllers starts a new local session. Human heading controls use the mounted presentation command described below.
|
|
52
56
|
|
|
53
57
|
### Mounted Markdown commands
|
|
54
58
|
|
|
@@ -2,7 +2,7 @@ import { type MarkdownDecoration, type MarkdownQuoteCapture } from './markdown-h
|
|
|
2
2
|
export type { MarkdownDecoration, MarkdownQuoteCapture } from './markdown-highlight-layer.js';
|
|
3
3
|
export { markdownMarkerBackground } from './markdown-highlight-layer.js';
|
|
4
4
|
import type { ViewerComponents } from './viewer-components.js';
|
|
5
|
-
export type { ViewerComponents } from './viewer-components.js';
|
|
5
|
+
export type { MarkdownEditorMenuLabels, MarkdownEditorMenuProps, ViewerComponents } from './viewer-components.js';
|
|
6
6
|
import type { MarkdownAutosave, MarkdownFlush } from './markdown-autosave.js';
|
|
7
7
|
import type { SaveResult } from './resources.js';
|
|
8
8
|
export type { MarkdownAutosave, MarkdownFlush } from './markdown-autosave.js';
|
|
@@ -26,9 +26,9 @@ export interface MarkdownEditorProps {
|
|
|
26
26
|
readonly placeholder?: string;
|
|
27
27
|
readonly className?: string;
|
|
28
28
|
readonly onMountedTools?: (tools: MarkdownMountedTools | null) => void;
|
|
29
|
-
/** `false` omits the built-in header (title, status,
|
|
29
|
+
/** `false` omits the built-in header (title, status, view menu, Save) so a host bar such as the viewer frame supplies them. */
|
|
30
30
|
readonly header?: boolean;
|
|
31
|
-
/**
|
|
31
|
+
/** Offer local heading navigation in the view menu. Defaults to the built-in header's visibility; initially hidden. */
|
|
32
32
|
readonly showOutline?: boolean;
|
|
33
33
|
/** Controlled mode. The editor still falls back to `source` for a document that rich editing cannot keep exactly. */
|
|
34
34
|
readonly mode?: 'rich' | 'source';
|
|
@@ -42,8 +42,10 @@ export interface MarkdownEditorProps {
|
|
|
42
42
|
/** Registers the async navigation barrier. Null while autosave is disabled or the editor is unmounted. */
|
|
43
43
|
readonly onAutosaveChange?: (flush: MarkdownFlush | null) => void;
|
|
44
44
|
readonly onSaveResult?: (result: SaveResult) => void;
|
|
45
|
-
/**
|
|
45
|
+
/** Selection formatting by default. Pinned shows the full row on desktop or above the phone keyboard while editing. */
|
|
46
46
|
readonly formattingToolbar?: 'selection' | 'pinned';
|
|
47
|
+
/** Locale of the built-in view menu. */
|
|
48
|
+
readonly locale?: 'en' | 'fr';
|
|
47
49
|
}
|
|
48
50
|
/** Borrows the controller. The host owns document selection, access and disposal. */
|
|
49
51
|
export declare function MarkdownEditor(props: MarkdownEditorProps): import("react").JSX.Element;
|
package/dist/markdown-editor.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx,
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { captureMarkdownQuote } from './markdown-quote.js';
|
|
4
4
|
import { captureRichMarkdownQuote, installMarkdownHighlights } from './markdown-highlight-layer.js';
|
|
5
5
|
export { markdownMarkerBackground } from './markdown-highlight-layer.js';
|
|
6
6
|
import { TextDraftControls } from './text-draft-controls.js';
|
|
7
7
|
import { useMarkdownAutosave } from './markdown-autosave.js';
|
|
8
|
-
import { Fragment, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
8
|
+
import { Fragment, useDeferredValue, useEffect, useLayoutEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
9
9
|
import { createPortal } from 'react-dom';
|
|
10
10
|
import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
|
|
11
11
|
import { BubbleMenu } from '@tiptap/react/menus';
|
|
12
12
|
import { createMarkdownExtensions } from './markdown-extensions.js';
|
|
13
13
|
import { checkMarkdownRichSafety, describeUnsafe, finishRichMarkdown } from './markdown-safety.js';
|
|
14
|
+
import { captureMarkdownSource, serializeMarkdownEdit } from './markdown-source.js';
|
|
14
15
|
import { MarkdownIcon } from './markdown-icons.js';
|
|
15
16
|
import { MarkdownProposalDiff } from './markdown-proposal-diff.js';
|
|
16
17
|
import { createMountedMarkdownTools } from './markdown-mounted.js';
|
|
@@ -64,22 +65,41 @@ function useKeyboardInset(active) {
|
|
|
64
65
|
}, [active]);
|
|
65
66
|
return inset;
|
|
66
67
|
}
|
|
67
|
-
|
|
68
|
+
const menuLabels = {
|
|
69
|
+
en: { moreActions: 'More actions', editorMode: 'Editor mode', formatted: 'Formatted', markdown: 'Markdown', showOutline: 'Show outline' },
|
|
70
|
+
fr: { moreActions: 'Plus d’actions', editorMode: 'Mode d’édition', formatted: 'Mis en forme', markdown: 'Markdown', showOutline: 'Afficher le plan' },
|
|
71
|
+
};
|
|
72
|
+
/** Unstyled native fallback. Installed recipes bind the host's shadcn dropdown. */
|
|
73
|
+
function NativeMarkdownViewOptions({ labels, mode, onModeChange, richDisabledReason, outline, input }) {
|
|
74
|
+
const InputControl = input ?? 'input';
|
|
75
|
+
const group = useId();
|
|
76
|
+
const menu = useRef(null);
|
|
77
|
+
const choose = (action) => { action(); if (menu.current)
|
|
78
|
+
menu.current.open = false; };
|
|
79
|
+
return _jsxs("details", { ref: menu, "data-boring": "markdown-menu", children: [_jsx("summary", { "aria-label": labels.moreActions, children: _jsx(MarkdownIcon, { name: "More" }) }), _jsxs("fieldset", { children: [_jsx("legend", { children: labels.editorMode }), _jsxs("label", { title: richDisabledReason, children: [_jsx(InputControl, { type: "radio", name: group, "aria-label": labels.formatted, "aria-describedby": richDisabledReason ? `${group}-reason` : undefined, checked: mode === 'rich', disabled: !!richDisabledReason, onChange: () => choose(() => onModeChange('rich')) }), _jsx(MarkdownIcon, { name: "Heading1" }), labels.formatted, richDisabledReason && _jsx("span", { id: `${group}-reason`, children: richDisabledReason })] }), _jsxs("label", { children: [_jsx(InputControl, { type: "radio", name: group, "aria-label": labels.markdown, checked: mode === 'source', onChange: () => choose(() => onModeChange('source')) }), _jsx(MarkdownIcon, { name: "Code" }), labels.markdown] })] }), outline && _jsxs("label", { children: [_jsx(InputControl, { type: "checkbox", "aria-label": labels.showOutline, checked: outline.shown, onChange: event => choose(() => outline.onChange(event.currentTarget.checked)) }), _jsx(MarkdownIcon, { name: "BulletList" }), labels.showOutline] })] });
|
|
80
|
+
}
|
|
81
|
+
function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActivate, components, controller, title = 'Document', initialMode = 'rich', placeholder = 'Start writing', className, onMountedTools, header = true, showOutline = header, mode: controlledMode, onModeChange, onRichSafety, resolveImage, formattingToolbar = 'selection', locale = 'en', autosave = true, onAutosaveChange, onSaveResult, activeController: active }) {
|
|
68
82
|
const Control = components?.button ?? 'button';
|
|
83
|
+
const FormattingControl = components?.formattingControl ?? 'button';
|
|
69
84
|
const Bubble = components?.bubble ?? Fragment;
|
|
70
85
|
const Source = components?.textarea ?? 'textarea';
|
|
71
86
|
const LinkInput = components?.input ?? 'input';
|
|
87
|
+
const ViewOptions = components?.markdownMenu;
|
|
88
|
+
const [toolbar, setToolbar] = useState(formattingToolbar);
|
|
89
|
+
useLayoutEffect(() => setToolbar(formattingToolbar), [formattingToolbar]);
|
|
90
|
+
const [outlineShown, setOutlineShown] = useState(false);
|
|
72
91
|
const phone = useSyncExternalStore(subscribePhone, phoneSnapshot, desktopSnapshot);
|
|
73
|
-
const showPinned =
|
|
92
|
+
const showPinned = toolbar === 'pinned' && !phone;
|
|
74
93
|
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
|
75
94
|
const projected = useRef(null);
|
|
95
|
+
const source = useRef(null);
|
|
76
96
|
const emitted = useRef(null);
|
|
77
97
|
const [ownMode, setOwnMode] = useState(initialMode);
|
|
78
98
|
const requestedMode = controlledMode ?? ownMode;
|
|
79
99
|
// Rich editing is only offered for a document the editor can keep exactly; anything else is edited as source.
|
|
80
100
|
const checked = useDeferredValue(state.text);
|
|
81
101
|
// The editor's own output is safe by construction; only text that arrived from elsewhere (or from the source box) is checked.
|
|
82
|
-
const ownText = state.text === emitted.current;
|
|
102
|
+
const ownText = state.text === emitted.current && source.current !== null;
|
|
83
103
|
const safety = useMemo(() => ownText ? { safe: true } : checkMarkdownRichSafety(checked), [checked, ownText]);
|
|
84
104
|
const richOffered = safety.safe;
|
|
85
105
|
const mode = richOffered ? requestedMode : 'source';
|
|
@@ -100,7 +120,7 @@ function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActiva
|
|
|
100
120
|
};
|
|
101
121
|
const editor = useEditor({
|
|
102
122
|
immediatelyRender: false,
|
|
103
|
-
extensions: createMarkdownExtensions({ placeholder, images: () => images.current }),
|
|
123
|
+
extensions: createMarkdownExtensions({ placeholder, images: () => images.current, source: () => controller.getSnapshot().text.replace(/\r\n/g, '\n') }),
|
|
104
124
|
content: '',
|
|
105
125
|
editable: !state.readOnly && state.lifecycle === 'active',
|
|
106
126
|
editorProps: { attributes: { role: 'textbox', 'aria-label': `${title} rich text`, 'aria-multiline': 'true', tabindex: '0', class: 'boring-markdown-content' } },
|
|
@@ -108,7 +128,9 @@ function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActiva
|
|
|
108
128
|
if (active.current !== controller || !interaction.current || !transaction.docChanged || controller.getSnapshot().lifecycle !== 'active' || controller.getSnapshot().readOnly)
|
|
109
129
|
return;
|
|
110
130
|
interaction.current = false;
|
|
111
|
-
const
|
|
131
|
+
const previous = controller.getSnapshot().text;
|
|
132
|
+
const markdown = serializeMarkdownEdit(current, source.current, previous) ?? finishRichMarkdown(current.getMarkdown(), previous);
|
|
133
|
+
source.current = captureMarkdownSource(current, markdown);
|
|
112
134
|
projected.current = markdown;
|
|
113
135
|
emitted.current = markdown;
|
|
114
136
|
controller.actions.edit(markdown);
|
|
@@ -127,7 +149,8 @@ function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActiva
|
|
|
127
149
|
// its caret when the text is replaced. Replacing the whole document would otherwise leave the editor's selection at the end while the
|
|
128
150
|
// caret shows the start: a click there changes nothing the editor sees, so formatting applied to the end, and ProseMirror's own sync
|
|
129
151
|
// after a focus or click put the caret back at the end.
|
|
130
|
-
editor.chain().setContent(state.text, { contentType: 'markdown', emitUpdate: false }).setTextSelection(0).run();
|
|
152
|
+
editor.chain().setContent(state.text.replace(/\r\n/g, '\n'), { contentType: 'markdown', emitUpdate: false }).setTextSelection(0).command(({ tr }) => { tr.setMeta('addToHistory', false); return true; }).run();
|
|
153
|
+
source.current = captureMarkdownSource(editor, state.text);
|
|
131
154
|
projected.current = state.text;
|
|
132
155
|
}, [editor, state.text]);
|
|
133
156
|
useEffect(() => { editor?.setEditable(!state.readOnly && state.lifecycle === 'active'); }, [editor, state.readOnly, state.lifecycle]);
|
|
@@ -175,24 +198,31 @@ function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActiva
|
|
|
175
198
|
});
|
|
176
199
|
return { document: current.state.doc, headings };
|
|
177
200
|
}, equalityFn: (left, right) => left?.document === right?.document });
|
|
178
|
-
const switchMode = () => {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
richBookmark.current = editor && !editor.isDestroyed && projected.current === current.text
|
|
182
|
-
? { version: current.bufferVersion, selection: editor.state.selection.getBookmark() } : null;
|
|
183
|
-
}
|
|
184
|
-
else {
|
|
185
|
-
const input = sourceInput.current;
|
|
186
|
-
sourceBookmark.current = input?.value === current.text
|
|
187
|
-
? { version: current.bufferVersion, start: input.selectionStart, end: input.selectionEnd, direction: input.selectionDirection } : null;
|
|
188
|
-
}
|
|
201
|
+
const switchMode = (next) => {
|
|
202
|
+
if (next === mode || next === 'rich' && !richOffered)
|
|
203
|
+
return;
|
|
189
204
|
interaction.current = false;
|
|
190
|
-
focusAfterSwitch.current = true;
|
|
191
|
-
const next = mode === 'source' ? 'rich' : 'source';
|
|
192
205
|
if (controlledMode === undefined)
|
|
193
206
|
setOwnMode(next);
|
|
194
207
|
onModeChange?.(next);
|
|
195
208
|
};
|
|
209
|
+
// The captured textarea survives DOM removal, including a mode change from a host menu.
|
|
210
|
+
useLayoutEffect(() => {
|
|
211
|
+
const source = sourceInput.current;
|
|
212
|
+
return () => {
|
|
213
|
+
if (active.current !== controller || activeMode.current === mode)
|
|
214
|
+
return;
|
|
215
|
+
const current = controller.getSnapshot();
|
|
216
|
+
if (mode === 'rich')
|
|
217
|
+
richBookmark.current = editor && !editor.isDestroyed && projected.current === current.text
|
|
218
|
+
? { version: current.bufferVersion, selection: editor.state.selection.getBookmark() } : null;
|
|
219
|
+
else
|
|
220
|
+
sourceBookmark.current = source?.value === current.text
|
|
221
|
+
? { version: current.bufferVersion, start: source.selectionStart, end: source.selectionEnd, direction: source.selectionDirection } : null;
|
|
222
|
+
interaction.current = false;
|
|
223
|
+
focusAfterSwitch.current = true;
|
|
224
|
+
};
|
|
225
|
+
}, [active, controller, editor, mode]);
|
|
196
226
|
useEffect(() => {
|
|
197
227
|
if (!focusAfterSwitch.current || active.current !== controller || state.lifecycle !== 'active')
|
|
198
228
|
return;
|
|
@@ -253,8 +283,14 @@ function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActiva
|
|
|
253
283
|
const [linkDraft, setLinkDraft] = useState(null);
|
|
254
284
|
// On a phone the full formatting row docks above the soft keyboard while the rich surface has focus.
|
|
255
285
|
const editing = useEditorFocus(editor);
|
|
256
|
-
const dockVisible = phone && !unavailable && !!editor && (editing || linkDraft !== null);
|
|
286
|
+
const dockVisible = phone && toolbar === 'pinned' && !unavailable && !!editor && (editing || linkDraft !== null);
|
|
257
287
|
const keyboardInset = useKeyboardInset(dockVisible);
|
|
288
|
+
const previousToolbar = useRef(toolbar);
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
if (previousToolbar.current !== toolbar && toolbar === 'pinned' && phone && mode === 'rich')
|
|
291
|
+
editor?.commands.focus(undefined, { scrollIntoView: false });
|
|
292
|
+
previousToolbar.current = toolbar;
|
|
293
|
+
}, [toolbar, phone, mode, editor]);
|
|
258
294
|
const command = (operation) => { interaction.current = true; try {
|
|
259
295
|
operation();
|
|
260
296
|
}
|
|
@@ -274,6 +310,10 @@ function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActiva
|
|
|
274
310
|
setLinkDraft(null);
|
|
275
311
|
};
|
|
276
312
|
const formatTools = _jsxs(_Fragment, { children: [tool('Bold', 'Bold', marks?.bold, () => chain().toggleBold().run()), tool('Italic', 'Italic', marks?.italic, () => chain().toggleItalic().run()), tool('Strikethrough', 'Strike', marks?.strike, () => chain().toggleStrike().run()), tool('Highlight', 'Highlight', marks?.highlight, () => chain().toggleHighlight().run()), tool('Code', 'Code', marks?.code, () => chain().toggleCode().run()), separator, tool('Heading 1', 'Heading1', marks?.h1, () => chain().toggleHeading({ level: 1 }).run()), tool('Heading 2', 'Heading2', marks?.h2, () => chain().toggleHeading({ level: 2 }).run()), tool('Heading 3', 'Heading3', marks?.h3, () => chain().toggleHeading({ level: 3 }).run()), separator, tool('Bullet list', 'BulletList', marks?.bullet, () => chain().toggleBulletList().run()), tool('Numbered list', 'OrderedList', marks?.ordered, () => chain().toggleOrderedList().run()), tool('Task list', 'TaskList', marks?.task, () => chain().toggleTaskList().run()), separator, tool('Quote', 'Quote', marks?.quote, () => chain().toggleBlockquote().run()), tool('Code block', 'CodeBlock', marks?.codeBlock, () => chain().toggleCodeBlock().run()), tool('Link', 'Link', marks?.link || linkDraft !== null, openLink), tool('Table', 'Table', marks?.table, () => marks?.table ? chain().deleteTable().run() : chain().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()), tool('Horizontal rule', 'Rule', false, () => chain().setHorizontalRule().run()), marks?.table && _jsxs(_Fragment, { children: [separator, tool('Add table row', 'Rows', false, () => chain().addRowAfter().run()), tool('Add table column', 'Columns', false, () => chain().addColumnAfter().run()), tool('Delete table', 'Trash', false, () => chain().deleteTable().run())] })] });
|
|
313
|
+
const viewOptions = {
|
|
314
|
+
labels: menuLabels[locale], mode, onModeChange: switchMode, richDisabledReason: richOffered ? undefined : describeUnsafe(safety),
|
|
315
|
+
outline: showOutline ? { shown: outlineShown, onChange: setOutlineShown } : undefined,
|
|
316
|
+
};
|
|
277
317
|
return _jsxs("section", { className: className, "data-boring": "markdown-editor", "data-mode": mode, "data-dock": (mode === 'rich' && dockVisible) || undefined, "data-dirty": state.dirty || undefined, onBlurCapture: event => { if (saving.enabled && !event.currentTarget.contains(event.relatedTarget))
|
|
278
318
|
void saving.immediate(); }, onKeyDownCapture: event => {
|
|
279
319
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
|
@@ -281,11 +321,11 @@ function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActiva
|
|
|
281
321
|
if (!unavailable && !uncertain)
|
|
282
322
|
save();
|
|
283
323
|
}
|
|
284
|
-
}, children: [header && _jsxs("header", { children: [_jsx("strong", { children: title }), state.readOnly && _jsx("span", { children: "Read only" }), _jsx("span", { role: "status", children: state.lifecycle === 'disposed' ? 'Closed' : pending ? 'Saving…' : uncertain ? 'Save unconfirmed' : conflict ? 'Changed elsewhere' : state.dirty ? 'Unsaved changes' : 'Saved' }), _jsx(
|
|
324
|
+
}, children: [header && _jsxs("header", { children: [_jsx("strong", { children: title }), state.readOnly && _jsx("span", { children: "Read only" }), _jsx("span", { role: "status", children: state.lifecycle === 'disposed' ? 'Closed' : pending ? 'Saving…' : uncertain ? 'Save unconfirmed' : conflict ? 'Changed elsewhere' : state.dirty ? 'Unsaved changes' : 'Saved' }), ViewOptions ? _jsx(ViewOptions, { ...viewOptions }) : _jsx(NativeMarkdownViewOptions, { ...viewOptions, input: components?.input }), mode === 'rich' && _jsx(FormattingControl, { type: "button", "aria-label": locale === 'fr' ? 'Mise en forme' : 'Formatting', title: locale === 'fr' ? 'Mise en forme' : 'Formatting', "aria-pressed": toolbar === 'pinned', onClick: () => setToolbar(toolbar === 'pinned' ? 'selection' : 'pinned'), children: _jsx(MarkdownIcon, { name: "Type" }) }), !saving.enabled && _jsx(Control, { type: "button", disabled: unavailable || !state.dirty || pending || uncertain, onClick: save, children: "Save" })] }), _jsx(TextDraftControls, { components: components, recovery: state.recovery, actions: controller.actions, readOnly: state.readOnly, blocked: state.lifecycle !== 'active' || state.save.kind === 'pending' || (state.save.kind === 'settled' && state.save.result.kind === 'unknown') }), mode === 'rich' && showPinned && _jsx("div", { role: "toolbar", "aria-label": "Text formatting", className: "boring-markdown-toolbar", children: formatTools }), mode === 'rich' && dockVisible && createPortal(_jsxs("div", { role: "toolbar", "aria-label": "Text formatting", className: "boring-viewer boring-markdown-dock", "data-testid": "markdown-dock", "data-keyboard": keyboardInset > 0 || undefined, style: { bottom: keyboardInset }, onPointerDown: event => event.preventDefault(), children: [Rule && _jsx(Rule, { className: "boring-markdown-dock-rule" }), formatTools] }), document.body), mode === 'rich' && editor && !showPinned && !unavailable && _jsx(BubbleMenu, { editor: editor, updateDelay: 0, options: BUBBLE_OPTIONS, appendTo: bubbleContainer, role: "toolbar", "aria-label": "Text formatting", className: "boring-viewer boring-markdown-bubble", children: _jsxs(Bubble, { children: [tool('Bold', 'Bold', marks?.bold, () => chain().toggleBold().run()), tool('Italic', 'Italic', marks?.italic, () => chain().toggleItalic().run()), tool('Highlight', 'Highlight', marks?.highlight, () => chain().toggleHighlight().run()), tool('Code', 'Code', marks?.code, () => chain().toggleCode().run()), tool('Link', 'Link', marks?.link || linkDraft !== null, openLink)] }) }), mode === 'rich' && linkDraft !== null && _jsxs("form", { className: "boring-markdown-link", "aria-label": "Link address", onSubmit: event => { event.preventDefault(); applyLink(); }, children: [_jsx(LinkInput, { type: "text", inputMode: "url", autoFocus: true, "aria-label": "Link address", placeholder: "https://", value: linkDraft, onChange: event => setLinkDraft(event.currentTarget.value), onKeyDown: event => { if (event.key === 'Escape') {
|
|
285
325
|
event.preventDefault();
|
|
286
326
|
setLinkDraft(null);
|
|
287
327
|
editor?.commands.focus();
|
|
288
|
-
} } }), _jsx(Control, { type: "submit", children: linkDraft.trim() === '' && marks?.link ? 'Remove link' : 'Apply link' }), _jsx(Control, { type: "button", onClick: () => { setLinkDraft(null); editor?.commands.focus(); }, children: "Cancel" })] }), !richOffered && _jsx("p", { role: "note", className: "boring-markdown-notice", "data-testid": "markdown-source-only", children: describeUnsafe(safety) }), showOutline && mode === 'rich' && outline && outline.headings.length > 0 && _jsx("nav", { "aria-label": "Document outline", children: _jsx("ol", { children: outline.headings.map((heading, index) => _jsx("li", { "data-heading-level": heading.level, children: _jsx(Control, { type: "button", "aria-label": `Go to heading: ${heading.text || 'Untitled heading'}`, disabled: state.lifecycle !== 'active', onClick: () => {
|
|
328
|
+
} } }), _jsx(Control, { type: "submit", children: linkDraft.trim() === '' && marks?.link ? 'Remove link' : 'Apply link' }), _jsx(Control, { type: "button", onClick: () => { setLinkDraft(null); editor?.commands.focus(); }, children: "Cancel" })] }), !richOffered && _jsx("p", { role: "note", className: "boring-markdown-notice", "data-testid": "markdown-source-only", children: describeUnsafe(safety) }), showOutline && outlineShown && mode === 'rich' && outline && outline.headings.length > 0 && _jsx("nav", { "aria-label": "Document outline", children: _jsx("ol", { children: outline.headings.map((heading, index) => _jsx("li", { "data-heading-level": heading.level, children: _jsx(Control, { type: "button", "aria-label": `Go to heading: ${heading.text || 'Untitled heading'}`, disabled: state.lifecycle !== 'active', onClick: () => {
|
|
289
329
|
if (!editor || editor.isDestroyed || active.current !== controller || controller.getSnapshot().lifecycle !== 'active'
|
|
290
330
|
|| controller.getSnapshot().bufferVersion !== state.bufferVersion || editor.state.doc !== outline.document)
|
|
291
331
|
return;
|
|
@@ -6,11 +6,12 @@ export declare const inlineImagesOnly: MarkdownImageResolver;
|
|
|
6
6
|
export interface MarkdownExtensionOptions {
|
|
7
7
|
readonly placeholder?: string;
|
|
8
8
|
readonly images?: () => MarkdownImageResolver | undefined;
|
|
9
|
+
readonly source?: () => string;
|
|
9
10
|
}
|
|
10
11
|
/**
|
|
11
12
|
* The Markdown constructs the rich editor can keep exactly: StarterKit (headings, lists, quotes, code, rules, bold, italic,
|
|
12
13
|
* strike, links), task lists, GFM tables, images and highlight. Underline is off because Markdown has no underline.
|
|
13
14
|
* Images are inert: without a host resolver they render as a labelled placeholder and never make a request.
|
|
14
15
|
*/
|
|
15
|
-
export declare function createMarkdownExtensions({ placeholder, images }?: MarkdownExtensionOptions): Extensions;
|
|
16
|
+
export declare function createMarkdownExtensions({ placeholder, images, source }?: MarkdownExtensionOptions): Extensions;
|
|
16
17
|
//# sourceMappingURL=markdown-extensions.d.ts.map
|
|
@@ -7,6 +7,7 @@ import Image from '@tiptap/extension-image';
|
|
|
7
7
|
import Highlight from '@tiptap/extension-highlight';
|
|
8
8
|
import { Node as TiptapNode } from '@tiptap/core';
|
|
9
9
|
import { createMarkdownParser } from './markdown-parser.js';
|
|
10
|
+
import { preservedMarkdownNodes } from './markdown-preserved.js';
|
|
10
11
|
/**
|
|
11
12
|
* The serialiser HTML-encodes text (`&` becomes `&`), which rewrites ordinary prose such as "Notes & Highlights". The parser
|
|
12
13
|
* here reads text back as plain characters, so only the backslash escapes for Markdown syntax are kept.
|
|
@@ -25,13 +26,79 @@ const PlainTextMarkdown = Markdown.extend({
|
|
|
25
26
|
};
|
|
26
27
|
},
|
|
27
28
|
});
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
function sourceBlock(extension, schema) {
|
|
30
|
+
return extension.extend({
|
|
31
|
+
addAttributes() { return { ...this.parent?.(), markdownSource: { default: null, rendered: false, parseHTML: () => null } }; },
|
|
32
|
+
parseMarkdown(token, helpers) {
|
|
33
|
+
const parsed = this.parent(token, helpers), current = schema();
|
|
34
|
+
if (!current || !token.raw || Array.isArray(parsed) || /^(?:[ \t]* [ \t]*)+$/.test(token.raw.trim()))
|
|
35
|
+
return parsed;
|
|
36
|
+
const snapshot = current.nodeFromJSON(parsed).toJSON();
|
|
37
|
+
return { ...parsed, attrs: { ...parsed.attrs, markdownSource: { raw: token.raw.replace(/\n+$/, ''), snapshot: JSON.stringify(snapshot) } } };
|
|
38
|
+
},
|
|
39
|
+
renderMarkdown(node, helpers, context) {
|
|
40
|
+
const original = node.attrs?.['markdownSource'];
|
|
41
|
+
const snapshot = { ...node, attrs: { ...node.attrs, markdownSource: null } };
|
|
42
|
+
if (original && typeof original.raw === 'string' && original.snapshot === JSON.stringify(snapshot))
|
|
43
|
+
return original.raw;
|
|
44
|
+
return this.parent(node, helpers, context);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function sourceStarterKit(source, schema) {
|
|
49
|
+
return StarterKit.extend({
|
|
50
|
+
addExtensions() {
|
|
51
|
+
return (this.parent?.() ?? []).map(extension => {
|
|
52
|
+
if (extension instanceof TiptapNode && extension.name === 'paragraph')
|
|
53
|
+
return sourceBlock(extension.extend({ renderMarkdown: (node, helpers) => helpers.renderChildren(node.content ?? []) }), schema);
|
|
54
|
+
if (extension.name === 'bold' || extension.name === 'italic')
|
|
55
|
+
return extension.extend({ renderMarkdown: (node, helpers, context) => {
|
|
56
|
+
const text = typeof context.meta?.['markText'] === 'string' ? context.meta['markText'] : '';
|
|
57
|
+
const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
58
|
+
const prose = source().replace(/[*_]/g, '');
|
|
59
|
+
const intraword = text === '' || !prose.includes(text) || new RegExp(`(?:[\\p{L}\\p{N}]${escaped}|${escaped}[\\p{L}\\p{N}])`, 'u').test(prose);
|
|
60
|
+
const marker = extension.name === 'bold' ? (!intraword && /__\S/.test(source()) ? '__' : '**') : (!intraword && /(?:^|\s)_\S/.test(source()) ? '_' : '*');
|
|
61
|
+
return marker + helpers.renderChildren(node) + marker;
|
|
62
|
+
} });
|
|
63
|
+
if (extension instanceof TiptapNode && extension.name === 'listItem')
|
|
64
|
+
return extension.extend({
|
|
65
|
+
addAttributes() { return { ...this.parent?.(), markdownSource: { default: null, rendered: false, parseHTML: () => null } }; },
|
|
66
|
+
parseMarkdown(token, helpers) {
|
|
67
|
+
const parsed = this.parent(token, helpers);
|
|
68
|
+
const current = schema();
|
|
69
|
+
if (!current || !token.raw || Array.isArray(parsed))
|
|
70
|
+
return parsed;
|
|
71
|
+
const content = current.nodeFromJSON(parsed).toJSON().content ?? [];
|
|
72
|
+
return { ...parsed, attrs: { ...parsed.attrs, markdownSource: { raw: token.raw.replace(/\n$/, ''), content: JSON.stringify(content) } } };
|
|
73
|
+
},
|
|
74
|
+
renderMarkdown(node, helpers, context) {
|
|
75
|
+
const original = node.attrs?.['markdownSource'];
|
|
76
|
+
if (original && typeof original.raw === 'string' && original.content === JSON.stringify(node.content ?? []))
|
|
77
|
+
return original.raw;
|
|
78
|
+
const rendered = this.parent(node, helpers, context);
|
|
79
|
+
const bullet = /^ {0,3}([-*+])\s/m.exec(source())?.[1] ?? '-';
|
|
80
|
+
return context.parentType === 'bulletList' ? rendered.replace(/^- /, `${bullet} `) : rendered;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
if (extension instanceof TiptapNode && extension.name === 'horizontalRule')
|
|
84
|
+
return extension.extend({ renderMarkdown: () => /^ {0,3}(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$/m.exec(source())?.[0] ?? '---' });
|
|
85
|
+
if (extension instanceof TiptapNode && extension.name === 'heading')
|
|
86
|
+
return sourceBlock(extension.extend({
|
|
87
|
+
addAttributes() { return { ...this.parent?.(), markdownHeadingStyle: { default: 'atx', rendered: false, parseHTML: () => 'atx' } }; },
|
|
88
|
+
parseMarkdown(token, helpers) {
|
|
89
|
+
const parsed = this.parent(token, helpers);
|
|
90
|
+
return { ...parsed, attrs: { ...parsed.attrs, markdownHeadingStyle: /^ {0,3}#{1,6}(?:\s|$)/.test(token.raw ?? '') ? 'atx' : 'setext' } };
|
|
91
|
+
},
|
|
92
|
+
renderMarkdown(node, helpers, context) {
|
|
93
|
+
const text = helpers.renderChildren(node.content ?? []), level = Number(node.attrs?.['level']);
|
|
94
|
+
return level <= 2 && node.attrs?.['markdownHeadingStyle'] === 'setext' ? `${text}\n${(level === 1 ? '=' : '-').repeat(Math.max(3, text.length))}` : this.parent(node, helpers, context);
|
|
95
|
+
},
|
|
96
|
+
}), schema);
|
|
97
|
+
return extension;
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
35
102
|
/**
|
|
36
103
|
* A GFM table keeps the exact text it was written in (unpadded `|a|b|`, `:---:` alignment rows, `_emphasis_`, escaped pipes, empty
|
|
37
104
|
* cells) while its content is unchanged: parsing records the source and the canonical JSON of the rows it produced, and rendering
|
|
@@ -76,7 +143,7 @@ export const inlineImagesOnly = source => /^data:image\/(?:png|jpe?g|gif|webp);b
|
|
|
76
143
|
* strike, links), task lists, GFM tables, images and highlight. Underline is off because Markdown has no underline.
|
|
77
144
|
* Images are inert: without a host resolver they render as a labelled placeholder and never make a request.
|
|
78
145
|
*/
|
|
79
|
-
export function createMarkdownExtensions({ placeholder, images } = {}) {
|
|
146
|
+
export function createMarkdownExtensions({ placeholder, images, source = () => '' } = {}) {
|
|
80
147
|
const InertImage = Image.extend({
|
|
81
148
|
renderHTML({ node }) {
|
|
82
149
|
const source = String(node.attrs['src'] ?? '');
|
|
@@ -93,12 +160,13 @@ export function createMarkdownExtensions({ placeholder, images } = {}) {
|
|
|
93
160
|
onBeforeCreate() { this.parent?.(); schema = this.editor.schema; },
|
|
94
161
|
});
|
|
95
162
|
return [
|
|
96
|
-
|
|
163
|
+
sourceStarterKit(source, () => schema).configure({ link: { openOnClick: false, autolink: false, linkOnPaste: false }, underline: false }),
|
|
164
|
+
...preservedMarkdownNodes(),
|
|
97
165
|
TaskList, TaskItem.configure({ nested: true }),
|
|
98
166
|
SourceTable.configure({ resizable: false }), TableRow, TableHeader, TableCell,
|
|
99
167
|
InertImage.configure({ inline: false, allowBase64: true }),
|
|
100
168
|
Highlight,
|
|
101
169
|
Placeholder.configure({ placeholder: placeholder ?? 'Start writing' }),
|
|
102
|
-
PlainTextMarkdown.configure({ marked: createMarkdownParser(), markedOptions: { gfm: true, breaks: false } }),
|
|
170
|
+
PlainTextMarkdown.configure({ marked: createMarkdownParser(), markedOptions: { gfm: true, breaks: false }, indentation: { style: 'space', size: /^ {4}[-*+]\s/m.test(source()) ? 4 : 2 } }),
|
|
103
171
|
];
|
|
104
172
|
}
|
package/dist/markdown-icons.d.ts
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
import { type ReactElement } from 'react';
|
|
2
2
|
declare const ICONS: {
|
|
3
|
+
readonly Type: readonly [readonly ["path", {
|
|
4
|
+
readonly d: "M12 4v16";
|
|
5
|
+
}], readonly ["path", {
|
|
6
|
+
readonly d: "M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2";
|
|
7
|
+
}], readonly ["path", {
|
|
8
|
+
readonly d: "M9 20h6";
|
|
9
|
+
}]];
|
|
10
|
+
readonly More: readonly [readonly ["circle", {
|
|
11
|
+
readonly cx: "12";
|
|
12
|
+
readonly cy: "5";
|
|
13
|
+
readonly r: "1";
|
|
14
|
+
}], readonly ["circle", {
|
|
15
|
+
readonly cx: "12";
|
|
16
|
+
readonly cy: "12";
|
|
17
|
+
readonly r: "1";
|
|
18
|
+
}], readonly ["circle", {
|
|
19
|
+
readonly cx: "12";
|
|
20
|
+
readonly cy: "19";
|
|
21
|
+
readonly r: "1";
|
|
22
|
+
}]];
|
|
3
23
|
readonly Bold: readonly [readonly ["path", {
|
|
4
24
|
readonly d: "M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8";
|
|
5
25
|
}]];
|
package/dist/markdown-icons.js
CHANGED
|
@@ -3,6 +3,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
// Path data is from Lucide (https://lucide.dev, ISC license), 24x24 outline icons.
|
|
4
4
|
import { createElement } from 'react';
|
|
5
5
|
const ICONS = {
|
|
6
|
+
Type: [["path", { "d": "M12 4v16" }], ["path", { "d": "M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2" }], ["path", { "d": "M9 20h6" }]],
|
|
7
|
+
More: [["circle", { "cx": "12", "cy": "5", "r": "1" }], ["circle", { "cx": "12", "cy": "12", "r": "1" }], ["circle", { "cx": "12", "cy": "19", "r": "1" }]],
|
|
6
8
|
Bold: [["path", { "d": "M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8" }]],
|
|
7
9
|
Italic: [["line", { "x1": "19", "x2": "10", "y1": "4", "y2": "4" }], ["line", { "x1": "14", "x2": "5", "y1": "20", "y2": "20" }], ["line", { "x1": "15", "x2": "9", "y1": "4", "y2": "20" }]],
|
|
8
10
|
Strike: [["path", { "d": "M16 4H9a3 3 0 0 0-2.83 4" }], ["path", { "d": "M14 12a4 4 0 0 1 0 8H6" }], ["line", { "x1": "4", "x2": "20", "y1": "12", "y2": "12" }]],
|
package/dist/markdown-parser.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Marked, marked } from 'marked';
|
|
2
2
|
/** Tiptap expects the callable marked API; keep its mutable options local to one editor. */
|
|
3
3
|
export function createMarkdownParser() {
|
|
4
|
-
const instance = new Marked(
|
|
4
|
+
const instance = new Marked();
|
|
5
5
|
// marked HTML-escapes the text of inline tokens (`&` becomes `&`); the editor holds plain text, so decode it again or a
|
|
6
6
|
// serialised document would not match what was written.
|
|
7
7
|
const plain = (text) => text.replace(/&(?:amp|lt|gt|quot|#39);/g, entity => ({ '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" })[entity] ?? entity);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Node } from '@tiptap/core';
|
|
2
|
+
/** Literal Markdown, never HTML inserted into the host DOM. These atoms are host presentation, not Pi execution. */
|
|
3
|
+
export declare function preservedMarkdownNodes(): Node<any, any>[];
|
|
4
|
+
//# sourceMappingURL=markdown-preserved.d.ts.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Node } from '@tiptap/core';
|
|
2
|
+
import { Marked } from 'marked';
|
|
3
|
+
const literalHtml = new Marked();
|
|
4
|
+
/** Literal Markdown, never HTML inserted into the host DOM. These atoms are host presentation, not Pi execution. */
|
|
5
|
+
export function preservedMarkdownNodes() {
|
|
6
|
+
const block = Node.create({
|
|
7
|
+
name: 'preservedBlock', group: 'block', atom: true,
|
|
8
|
+
addAttributes: () => ({ raw: { default: '', rendered: false }, label: { default: 'HTML', rendered: false } }),
|
|
9
|
+
parseHTML: () => [],
|
|
10
|
+
renderHTML: ({ node }) => ['div', { class: 'boring-markdown-preserved', 'data-preserved': node.attrs['label'] },
|
|
11
|
+
['small', {}, String(node.attrs['label'])], ['pre', {}, String(node.attrs['raw'])]],
|
|
12
|
+
markdownTokenName: 'preservedBlock',
|
|
13
|
+
markdownTokenizer: {
|
|
14
|
+
name: 'preservedBlock', level: 'block',
|
|
15
|
+
start: source => { const index = source.search(/\n(?=---[ \t]*\n| {0,3}\[[^\]\n]+\]:| {0,3}<)/); return index < 0 ? -1 : index + 1; },
|
|
16
|
+
tokenize(source, tokens) {
|
|
17
|
+
if (/^ {0,3}</.test(source)) {
|
|
18
|
+
const html = literalHtml.lexer(source)[0];
|
|
19
|
+
if (html?.type === 'html')
|
|
20
|
+
return { type: 'preservedBlock', raw: html.raw, label: 'HTML' };
|
|
21
|
+
}
|
|
22
|
+
const front = tokens.length === 0 && /^---[ \t]*\n[\s\S]*?\n(?:---|\.\.\.)[ \t]*(?:\n|$)/.exec(source);
|
|
23
|
+
const definition = /^ {0,3}\[[^\]\n]+\]:[^\n]*(?:\n(?:[ \t]+[^\n]*|(?=\n[ \t]+)\n))*\n?/.exec(source);
|
|
24
|
+
const raw = front ? front[0] : definition?.[0];
|
|
25
|
+
return raw ? { type: 'preservedBlock', raw, label: front ? 'Front matter' : definition[0].trimStart().startsWith('[^') ? 'Footnote' : 'Reference' } : undefined;
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
parseMarkdown: token => ({ type: 'preservedBlock', attrs: { raw: token.raw?.replace(/\n+$/, ''), label: token['label'] } }),
|
|
29
|
+
renderMarkdown: node => String(node.attrs?.['raw'] ?? '').replace(/\n$/, ''),
|
|
30
|
+
addNodeView() {
|
|
31
|
+
return ({ node, editor, getPos }) => {
|
|
32
|
+
const dom = document.createElement('div');
|
|
33
|
+
dom.className = 'boring-markdown-preserved';
|
|
34
|
+
dom.dataset['preserved'] = String(node.attrs['label']);
|
|
35
|
+
const label = document.createElement('small');
|
|
36
|
+
label.textContent = String(node.attrs['label']);
|
|
37
|
+
dom.append(label);
|
|
38
|
+
if (node.attrs['label'] !== 'Front matter') {
|
|
39
|
+
const pre = document.createElement('pre');
|
|
40
|
+
pre.textContent = String(node.attrs['raw']);
|
|
41
|
+
dom.append(pre);
|
|
42
|
+
return { dom };
|
|
43
|
+
}
|
|
44
|
+
const code = document.createElement('pre');
|
|
45
|
+
code.textContent = String(node.attrs['raw']);
|
|
46
|
+
code.contentEditable = editor.isEditable ? 'plaintext-only' : 'false';
|
|
47
|
+
code.setAttribute('role', 'textbox');
|
|
48
|
+
code.setAttribute('aria-label', 'Front matter code');
|
|
49
|
+
code.setAttribute('aria-multiline', 'true');
|
|
50
|
+
code.spellcheck = false;
|
|
51
|
+
dom.append(code);
|
|
52
|
+
const input = () => {
|
|
53
|
+
const pos = getPos();
|
|
54
|
+
if (!editor.isEditable || typeof pos !== 'number')
|
|
55
|
+
return;
|
|
56
|
+
const raw = code.textContent ?? '';
|
|
57
|
+
if (code.children.length)
|
|
58
|
+
code.replaceChildren(document.createTextNode(raw));
|
|
59
|
+
editor.view.dispatch(editor.state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, raw }));
|
|
60
|
+
};
|
|
61
|
+
const insertText = (event) => {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
if (!editor.isEditable)
|
|
64
|
+
return;
|
|
65
|
+
const text = 'clipboardData' in event ? event.clipboardData?.getData('text/plain') : event.dataTransfer?.getData('text/plain');
|
|
66
|
+
if (!text)
|
|
67
|
+
return;
|
|
68
|
+
const selection = dom.ownerDocument.getSelection();
|
|
69
|
+
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
|
70
|
+
const inserted = document.createTextNode(text);
|
|
71
|
+
if (range && code.contains(range.commonAncestorContainer)) {
|
|
72
|
+
range.deleteContents();
|
|
73
|
+
range.insertNode(inserted);
|
|
74
|
+
range.setStartAfter(inserted);
|
|
75
|
+
range.collapse(true);
|
|
76
|
+
selection.removeAllRanges();
|
|
77
|
+
selection.addRange(range);
|
|
78
|
+
}
|
|
79
|
+
else
|
|
80
|
+
code.append(inserted);
|
|
81
|
+
code.dispatchEvent(new dom.ownerDocument.defaultView.Event('input', { bubbles: true }));
|
|
82
|
+
};
|
|
83
|
+
code.addEventListener('input', input);
|
|
84
|
+
code.addEventListener('paste', insertText);
|
|
85
|
+
code.addEventListener('drop', insertText);
|
|
86
|
+
return { dom, stopEvent: () => true, ignoreMutation: () => true,
|
|
87
|
+
update(next) {
|
|
88
|
+
if (next.type !== node.type)
|
|
89
|
+
return false;
|
|
90
|
+
node = next;
|
|
91
|
+
if (code.textContent !== node.attrs['raw'])
|
|
92
|
+
code.textContent = String(node.attrs['raw']);
|
|
93
|
+
code.contentEditable = editor.isEditable ? 'plaintext-only' : 'false';
|
|
94
|
+
return true;
|
|
95
|
+
}, destroy: () => { code.removeEventListener('input', input); code.removeEventListener('paste', insertText); code.removeEventListener('drop', insertText); } };
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const inline = Node.create({
|
|
100
|
+
name: 'preservedInline', group: 'inline', inline: true, atom: true,
|
|
101
|
+
addAttributes: () => ({ raw: { default: '', rendered: false } }),
|
|
102
|
+
parseHTML: () => [],
|
|
103
|
+
renderHTML: ({ node }) => ['span', { class: 'boring-markdown-preserved-inline' }, String(node.attrs['raw'])],
|
|
104
|
+
markdownTokenName: 'preservedInline',
|
|
105
|
+
markdownTokenizer: {
|
|
106
|
+
name: 'preservedInline', level: 'inline', start: source => source.search(/[<\[]/),
|
|
107
|
+
tokenize(source) {
|
|
108
|
+
if (source.startsWith('<')) {
|
|
109
|
+
const html = literalHtml.Lexer.lexInline(source, literalHtml.defaults)[0];
|
|
110
|
+
if (html?.type === 'html')
|
|
111
|
+
return { type: 'preservedInline', raw: html.raw };
|
|
112
|
+
}
|
|
113
|
+
const raw = /^\[(?:\^[^\]\s]+|[^\]\n]+)\](?:\[[^\]\n]*\])?(?!\()/.exec(source)?.[0];
|
|
114
|
+
return raw ? { type: 'preservedInline', raw } : undefined;
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
parseMarkdown: token => ({ type: 'preservedInline', attrs: { raw: token.raw } }),
|
|
118
|
+
renderMarkdown: node => String(node.attrs?.['raw'] ?? ''),
|
|
119
|
+
});
|
|
120
|
+
const html = Node.create({
|
|
121
|
+
name: 'preservedHtml', markdownTokenName: 'html', group: 'block', atom: true,
|
|
122
|
+
parseHTML: () => [], renderHTML: () => ['div'],
|
|
123
|
+
parseMarkdown: token => ({ type: token['block'] ? 'preservedBlock' : 'preservedInline', attrs: { raw: token.raw, label: 'HTML' } }),
|
|
124
|
+
});
|
|
125
|
+
return [block, inline, html];
|
|
126
|
+
}
|
|
@@ -11,10 +11,8 @@ export declare function trailingWhitespace(text: string): string;
|
|
|
11
11
|
/** The text a rich edit publishes: the serialised document, whitespace normalised, with the original's trailing newline. */
|
|
12
12
|
export declare function finishRichMarkdown(serialized: string, previous: string): string;
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* the ends. Named constructs the editor cannot hold (front matter, raw HTML, footnotes, reference links, CRLF) are reported as the
|
|
17
|
-
* reason. Without a DOM only the named constructs can be checked.
|
|
14
|
+
* Verifies a native projection and its exact source spans. Unchanged blocks retain their source, and unsupported constructs are
|
|
15
|
+
* inert atoms. The legacy whitespace comparison remains for empty/nbsp-only layout. Without a DOM only encoding can be checked.
|
|
18
16
|
*/
|
|
19
17
|
export declare function checkMarkdownRichSafety(text: string): MarkdownRichSafety;
|
|
20
18
|
export declare function describeUnsafe(safety: MarkdownRichSafety): string;
|