@hachej/boring-ui-kit 0.4.7 → 0.4.8

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.
Files changed (46) hide show
  1. package/README.md +6 -2
  2. package/dist/markdown-editor.d.ts +6 -4
  3. package/dist/markdown-editor.js +64 -24
  4. package/dist/markdown-extensions.d.ts +2 -1
  5. package/dist/markdown-extensions.js +78 -10
  6. package/dist/markdown-icons.d.ts +20 -0
  7. package/dist/markdown-icons.js +2 -0
  8. package/dist/markdown-parser.js +1 -1
  9. package/dist/markdown-preserved.d.ts +4 -0
  10. package/dist/markdown-preserved.js +126 -0
  11. package/dist/markdown-safety.d.ts +2 -4
  12. package/dist/markdown-safety.js +16 -33
  13. package/dist/markdown-source.d.ts +17 -0
  14. package/dist/markdown-source.js +74 -0
  15. package/dist/viewer-components.d.ts +22 -0
  16. package/package.json +2 -2
  17. package/r/agent-target.json +4 -4
  18. package/r/app-shell.json +9 -9
  19. package/r/board-viewer.json +5 -5
  20. package/r/button.json +1 -1
  21. package/r/diagram-viewer.json +1 -1
  22. package/r/dock.json +1 -1
  23. package/r/documents.json +10 -10
  24. package/r/feedback-preview.json +1 -1
  25. package/r/feedback.json +5 -5
  26. package/r/file-tree.json +5 -5
  27. package/r/highlights.json +4 -4
  28. package/r/html-viewer.json +3 -3
  29. package/r/maquette-viewer.json +2 -2
  30. package/r/markdown-editor.json +9 -5
  31. package/r/mockup-viewer.json +1 -1
  32. package/r/pi-ambient.json +7 -7
  33. package/r/pi-app.json +15 -15
  34. package/r/pi-chat.json +5 -5
  35. package/r/pi-workspace.json +4 -4
  36. package/r/placed-agent.json +27 -0
  37. package/r/provider-setup.json +2 -2
  38. package/r/record-viewer.json +1 -1
  39. package/r/registry.json +151 -101
  40. package/r/requests-board.json +1 -1
  41. package/r/shared-chat.json +3 -3
  42. package/r/task-list-viewer.json +2 -2
  43. package/r/utils.json +1 -1
  44. package/r/version-history.json +3 -3
  45. package/r/viewers.json +32 -11
  46. 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 pinned desktop toolbar. The Aa header control toggles it; `formattingToolbar="selection"` selects the native Tiptap selection bubble instead. Phones always use the selection bubble. 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 use Tiptap's Markdown serialization and may normalize supported formatting.
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 includes a Document outline built from the native parsed headings when `showOutline` is true. By default, the outline follows the built-in `header` visibility. 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.
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, source toggle, Save) so a host bar such as the viewer frame supplies them. */
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
- /** Show local heading navigation. Defaults to the built-in header's visibility. */
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
- /** The complete formatting row is pinned by default on desktop; phones show it above the keyboard while editing, plus the selection bubble. */
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;
@@ -1,16 +1,17 @@
1
1
  'use client';
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
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
- 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 = 'pinned', autosave = true, onAutosaveChange, onSaveResult, activeController: active }) {
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 = formattingToolbar === 'pinned' && !phone;
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 markdown = finishRichMarkdown(current.getMarkdown(), controller.getSnapshot().text);
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
- const current = controller.getSnapshot();
180
- if (mode === 'rich') {
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(Control, { type: "button", "aria-pressed": mode === 'source', disabled: !richOffered, onClick: switchMode, children: "Markdown source" }), !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') {
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
- const RichStarterKit = StarterKit.extend({
29
- addExtensions() {
30
- return (this.parent?.() ?? []).map(extension => extension instanceof TiptapNode && extension.name === 'paragraph'
31
- ? extension.extend({ renderMarkdown: (node, helpers) => helpers.renderChildren(node.content ?? []) })
32
- : extension);
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
- RichStarterKit.configure({ link: { openOnClick: false, autolink: false, linkOnPaste: false }, underline: false }),
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
  }
@@ -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
  }]];
@@ -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" }]],
@@ -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({ tokenizer: { html: () => undefined, tag: () => undefined } });
4
+ const instance = new Marked();
5
5
  // marked HTML-escapes the text of inline tokens (`&` becomes `&amp;`); 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 => ({ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'" })[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
- * Decides whether the rich editor can open and edit `text` without changing anything but what the person edits: the text is
15
- * parsed into the editor's document and serialised back without any edit, and must equal the original apart from blank-line runs and
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;