@ai-matrx/rich-editor 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +16 -2
  4. package/dist/core/block-insert.d.ts +27 -0
  5. package/dist/core/block-insert.js +24 -0
  6. package/dist/core/caret-context.d.ts +24 -0
  7. package/dist/core/caret-context.js +73 -0
  8. package/dist/core/clipboard-text.d.ts +5 -0
  9. package/dist/core/clipboard-text.js +47 -0
  10. package/dist/core/commands.d.ts +65 -0
  11. package/dist/core/commands.js +299 -0
  12. package/dist/core/extensions.d.ts +29 -0
  13. package/dist/core/extensions.js +326 -0
  14. package/dist/core/find-replace.d.ts +34 -0
  15. package/dist/core/find-replace.js +67 -0
  16. package/dist/core/history-approval.d.ts +11 -0
  17. package/dist/core/history-approval.js +43 -0
  18. package/dist/core/host-value.d.ts +29 -0
  19. package/dist/core/host-value.js +10 -0
  20. package/dist/core/html-to-markdown.d.ts +3 -0
  21. package/dist/core/html-to-markdown.js +12 -0
  22. package/dist/core/markdown-format.d.ts +18 -0
  23. package/dist/core/markdown-format.js +354 -0
  24. package/dist/core/markdown-parse.d.ts +35 -0
  25. package/dist/core/markdown-parse.js +533 -0
  26. package/dist/core/markdown-serialize.d.ts +27 -0
  27. package/dist/core/markdown-serialize.js +394 -0
  28. package/dist/core/outline.d.ts +17 -0
  29. package/dist/core/outline.js +41 -0
  30. package/dist/core/paste-html.d.ts +10 -0
  31. package/dist/core/paste-html.js +198 -0
  32. package/dist/core/paste-markdown.d.ts +18 -0
  33. package/dist/core/paste-markdown.js +70 -0
  34. package/dist/core/placeholders.d.ts +22 -0
  35. package/dist/core/placeholders.js +60 -0
  36. package/dist/core/save-plan.d.ts +47 -0
  37. package/dist/core/save-plan.js +287 -0
  38. package/dist/core/shortcuts.d.ts +13 -0
  39. package/dist/core/shortcuts.js +50 -0
  40. package/dist/core/source-format.d.ts +28 -0
  41. package/dist/core/source-format.js +104 -0
  42. package/dist/core/text-metrics.d.ts +11 -0
  43. package/dist/core/text-metrics.js +35 -0
  44. package/dist/core/variable-name.d.ts +13 -0
  45. package/dist/core/variable-name.js +13 -0
  46. package/dist/core/variables.d.ts +37 -0
  47. package/dist/core/variables.js +77 -0
  48. package/dist/core/visual-document.d.ts +52 -0
  49. package/dist/core/visual-document.js +142 -0
  50. package/dist/core/visual-find.d.ts +27 -0
  51. package/dist/core/visual-find.js +93 -0
  52. package/dist/format/format-target.d.ts +39 -0
  53. package/dist/format/format-target.js +79 -0
  54. package/dist/in-place/caret-handoff.d.ts +3 -0
  55. package/dist/in-place/caret-handoff.js +14 -0
  56. package/dist/in-place/in-place-session.d.ts +24 -0
  57. package/dist/in-place/in-place-session.js +32 -0
  58. package/package.json +97 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ The npm-install canary applies the returned changes (it read the result object as text); package contents unchanged.
6
+
7
+ ## 0.1.0
8
+
9
+ First release — the editor's pure core leaves matrx-frontend `components/rich-editor`:
10
+ `core/*` (markdown parse/serialize over Tiptap's schema, formatting commands, source format,
11
+ save plan, find/replace, paste html/markdown, outline, text metrics, variables, history
12
+ approval, host value, block insert, shortcuts), `in-place/{in-place-session,caret-handoff}`,
13
+ `format/format-target`. Behavior verbatim; the GFM table source/lexer and table lines come from
14
+ `@ai-matrx/rich-content`. The `{{variable}}` name rule (`core/variable-name`) is ported from
15
+ `@ai-matrx/chat`; chat imports it from here at rich-content phase 3.
16
+
17
+ ### Consumer action
18
+
19
+ - matrx-frontend: import `@ai-matrx/rich-editor/core/<module>` instead of `@/components/rich-editor/core/<module>`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AI Matrix Engine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,17 @@
1
- # Temporary Holding Version
1
+ # @ai-matrx/rich-editor
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The AI Matrx editor over `@ai-matrx/rich-content`: Visual (Tiptap), Source (CodeMirror), Preview,
4
+ the markdown formatting command layer, and edit-in-place. Separate from the engine so consumers
5
+ that only render never install Tiptap or CodeMirror.
6
+
7
+ 0.1.0 ships the editor's pure core: markdown ⇄ ProseMirror parse/serialize, the formatting
8
+ commands (`core/markdown-format`), the save plan (splice save), find/replace, paste, outline,
9
+ text metrics, variables, the edit-in-place session and caret handoff. The Visual/Source/Preview
10
+ surfaces follow.
11
+
12
+ ```ts
13
+ import { formatMarkdown } from "@ai-matrx/rich-editor/core/markdown-format";
14
+ formatMarkdown("Ship the release today", 9, 16, "bold"); // → changes producing **release**
15
+ ```
16
+
17
+ Peers: `react`, `react-dom` (>= 19). MIT licensed.
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A BLOCK INSERTED "BEFORE" / "AFTER" THE SELECTION LANDS BETWEEN BLOCKS —
3
+ * never inside a word, and from ANY selection.
4
+ *
5
+ * `insertText(text, "after")` used to split the paragraph AT the caret, so a
6
+ * reference inserted with the caret inside "of" became "o" + block + "f"
7
+ * (G5 review, 2026-10-02). Then it split at the caret textblock's edge — which
8
+ * threw `TransformError: Inserted content deeper than insertion position`
9
+ * whenever the selection was NOT in a textblock: a selected island (the chip a
10
+ * previous insert left selected), ⌘A, a position between two top-level blocks
11
+ * (G8B review, 2026-10-02: picking a chat after a right-click with no click in
12
+ * the text did nothing and left the dialog open).
13
+ *
14
+ * Now the block goes to the TOP-LEVEL boundary beside the selection's block
15
+ * (before its start / after its end) — a position that exists for every
16
+ * selection — and the caret lands just after what was inserted. Guard:
17
+ * `__tests__/block-insert.test.ts` sweeps every position × selection kind.
18
+ */
19
+ import { type Node as PMNode, type Schema } from "@tiptap/pm/model";
20
+ import type { Transaction } from "@tiptap/pm/state";
21
+ /** The top-level boundary a block goes to, relative to [from, to]. */
22
+ export declare function blockInsertPosition(doc: PMNode, from: number, to: number, where: "before" | "after"): number;
23
+ /**
24
+ * Insert markdown as its own block(s) before / after the selection's block.
25
+ * Throws nothing it can avoid; returns false when the text parses to nothing.
26
+ */
27
+ export declare function insertMarkdownBlock(tr: Transaction, schema: Schema, text: string, where: "before" | "after"): boolean;
@@ -0,0 +1,24 @@
1
+ import { Fragment, Slice } from "@tiptap/pm/model";
2
+ import { Selection } from "@tiptap/pm/state";
3
+ import { markdownToSlice } from "./paste-markdown.js";
4
+ function blockInsertPosition(doc, from, to, where) {
5
+ const clamp = (pos) => Math.max(0, Math.min(pos, doc.content.size));
6
+ const $pos = doc.resolve(clamp(where === "before" ? from : to));
7
+ if ($pos.depth === 0) return $pos.pos;
8
+ return where === "before" ? $pos.before(1) : $pos.after(1);
9
+ }
10
+ function insertMarkdownBlock(tr, schema, text, where) {
11
+ const { from, to } = tr.selection;
12
+ const at = blockInsertPosition(tr.doc, from, to, where);
13
+ const parsed = markdownToSlice(text, schema);
14
+ if (parsed.size === 0) return false;
15
+ const blocks = new Slice(Fragment.from(parsed.content), 0, 0);
16
+ tr.replace(at, at, blocks);
17
+ const end = tr.mapping.map(at, 1);
18
+ tr.setSelection(Selection.near(tr.doc.resolve(Math.min(end, tr.doc.content.size)), 1));
19
+ return true;
20
+ }
21
+ export {
22
+ blockInsertPosition,
23
+ insertMarkdownBlock
24
+ };
@@ -0,0 +1,24 @@
1
+ /** The text around a click in the rendered view. */
2
+ export interface CaretContext {
3
+ /** Up to `CONTEXT_CHARS` rendered characters before the click. */
4
+ before: string;
5
+ /** Up to `CONTEXT_CHARS` rendered characters after the click. */
6
+ after: string;
7
+ }
8
+ export declare const CONTEXT_CHARS = 40;
9
+ /**
10
+ * Where the caret goes in `text` (an index 0..text.length), or null when the
11
+ * context cannot be found. Tries the longest context first (before + after),
12
+ * then shorter ones, then each side alone; the first occurrence wins.
13
+ */
14
+ export declare function locateCaret(text: string, context: CaretContext): number | null;
15
+ /**
16
+ * The context of a point in a rendered element: the text before and after the
17
+ * DOM position (`node`, `offset`) within `root`.
18
+ */
19
+ export declare function caretContextAt(root: Node, node: Node, offset: number): CaretContext | null;
20
+ /** The DOM position under a viewport point (both browser APIs). */
21
+ export declare function domPointAt(doc: Document, x: number, y: number): {
22
+ node: Node;
23
+ offset: number;
24
+ } | null;
@@ -0,0 +1,73 @@
1
+ const CONTEXT_CHARS = 40;
2
+ function collapse(text) {
3
+ let flat = "";
4
+ const at = [];
5
+ let lastSpace = false;
6
+ for (let i = 0; i < text.length; i++) {
7
+ const ch = text[i];
8
+ if (/\s/.test(ch)) {
9
+ if (lastSpace) continue;
10
+ lastSpace = true;
11
+ flat += " ";
12
+ } else {
13
+ lastSpace = false;
14
+ flat += ch;
15
+ }
16
+ at.push(i);
17
+ }
18
+ return { flat, at };
19
+ }
20
+ function locateCaret(text, context) {
21
+ const { flat, at } = collapse(text);
22
+ const before = collapse(context.before).flat;
23
+ const after = collapse(context.after).flat;
24
+ const toOriginal = (flatIndex) => flatIndex >= at.length ? text.length : at[flatIndex];
25
+ for (const len of [CONTEXT_CHARS, 24, 12, 6, 3]) {
26
+ const b = before.slice(-len);
27
+ const a = after.slice(0, len);
28
+ if (!b && !a) continue;
29
+ const idx = flat.indexOf(b + a);
30
+ if (idx >= 0 && (b + a).length >= Math.min(3, before.length + after.length)) return toOriginal(idx + b.length);
31
+ }
32
+ for (const len of [CONTEXT_CHARS, 12, 4]) {
33
+ const a = after.slice(0, len);
34
+ if (a.trim().length >= 3) {
35
+ const idx = flat.indexOf(a);
36
+ if (idx >= 0) return toOriginal(idx);
37
+ }
38
+ const b = before.slice(-len);
39
+ if (b.trim().length >= 3) {
40
+ const idx = flat.indexOf(b);
41
+ if (idx >= 0) return toOriginal(idx + b.length);
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+ function caretContextAt(root, node, offset) {
47
+ if (!root.contains(node)) return null;
48
+ const doc = root.ownerDocument;
49
+ if (!doc) return null;
50
+ const head = doc.createRange();
51
+ head.selectNodeContents(root);
52
+ head.setEnd(node, offset);
53
+ const tail = doc.createRange();
54
+ tail.selectNodeContents(root);
55
+ tail.setStart(node, offset);
56
+ const before = head.toString();
57
+ const after = tail.toString();
58
+ return { before: before.slice(-CONTEXT_CHARS), after: after.slice(0, CONTEXT_CHARS) };
59
+ }
60
+ function domPointAt(doc, x, y) {
61
+ const withPosition = doc;
62
+ const position = withPosition.caretPositionFromPoint?.(x, y);
63
+ if (position) return { node: position.offsetNode, offset: position.offset };
64
+ const range = withPosition.caretRangeFromPoint?.(x, y);
65
+ if (range) return { node: range.startContainer, offset: range.startOffset };
66
+ return null;
67
+ }
68
+ export {
69
+ CONTEXT_CHARS,
70
+ caretContextAt,
71
+ domPointAt,
72
+ locateCaret
73
+ };
@@ -0,0 +1,5 @@
1
+ import { Extension, getTextSerializersFromSchema } from "@tiptap/core";
2
+ import type { Node as PMNode } from "@tiptap/pm/model";
3
+ export declare function clipboardTextBetween(doc: PMNode, from: number, to: number, serializers: ReturnType<typeof getTextSerializersFromSchema>): string;
4
+ /** Outranks Tiptap's core clipboardTextSerializer (the first plugin with the prop wins). */
5
+ export declare const ClipboardPlainText: Extension<any, any>;
@@ -0,0 +1,47 @@
1
+ import { Extension, getTextSerializersFromSchema } from "@tiptap/core";
2
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
3
+ const BLOCK_SEPARATOR = "\n\n";
4
+ function clipboardTextBetween(doc, from, to, serializers) {
5
+ let text = "";
6
+ let pending = false;
7
+ const append = (piece) => {
8
+ if (!piece) return;
9
+ if (pending && text) text += BLOCK_SEPARATOR;
10
+ pending = false;
11
+ text += piece;
12
+ };
13
+ doc.nodesBetween(from, to, (node, pos, parent, index) => {
14
+ if (node.isBlock) pending = true;
15
+ const serialize = serializers[node.type.name];
16
+ if (serialize) {
17
+ if (parent) append(serialize({ node, pos, parent, index, range: { from, to } }));
18
+ return false;
19
+ }
20
+ if (node.isText) append((node.text ?? "").slice(Math.max(from, pos) - pos, to - pos));
21
+ return true;
22
+ });
23
+ return text;
24
+ }
25
+ const ClipboardPlainText = Extension.create({
26
+ name: "clipboardPlainText",
27
+ priority: 1e3,
28
+ addProseMirrorPlugins() {
29
+ const { editor } = this;
30
+ return [
31
+ new Plugin({
32
+ key: new PluginKey("clipboardPlainText"),
33
+ props: {
34
+ clipboardTextSerializer: () => {
35
+ const { doc, selection, schema } = editor.state;
36
+ const serializers = getTextSerializersFromSchema(schema);
37
+ return [...selection.ranges].sort((a, b) => a.$from.pos - b.$from.pos).map(({ $from, $to }) => clipboardTextBetween(doc, $from.pos, $to.pos, serializers)).join(BLOCK_SEPARATOR);
38
+ }
39
+ }
40
+ })
41
+ ];
42
+ }
43
+ });
44
+ export {
45
+ ClipboardPlainText,
46
+ clipboardTextBetween
47
+ };
@@ -0,0 +1,65 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import { type Node as PMNode } from "@tiptap/pm/model";
3
+ export type CalloutType = "NOTE" | "TIP" | "IMPORTANT" | "WARNING" | "CAUTION";
4
+ export declare const CALLOUT_TYPES: readonly CalloutType[];
5
+ export declare const TASK_OPEN = "[ ] ";
6
+ export declare const TASK_DONE = "[x] ";
7
+ /** A fenced code block with a fence longer than any backtick run in the code. */
8
+ export declare function fenceMarkdown(language: string, code?: string): string;
9
+ export declare function mathBlockMarkdown(tex?: string): string;
10
+ /** Kind JSON exactly as agents emit it: `__kind` first, pretty-printed. */
11
+ export declare function kindMarkdown(kind: string, data?: Record<string, unknown>): string;
12
+ /** Top-level index and position of the block that holds the selection. */
13
+ declare function topLevelAt(editor: Editor): {
14
+ index: number;
15
+ pos: number;
16
+ node: PMNode;
17
+ } | null;
18
+ /**
19
+ * Insert a block island at the cursor: an empty paragraph is replaced, a
20
+ * non-empty one gets the island after it. Returns the island's position.
21
+ */
22
+ export declare function insertIsland(editor: Editor, raw: string, islandType: string): number;
23
+ export declare function insertCodeBlock(editor: Editor, language?: string): number;
24
+ export declare function insertMathBlock(editor: Editor, tex?: string): number;
25
+ export declare function insertPageBreak(editor: Editor): number;
26
+ export declare function insertKind(editor: Editor, kind: string, data?: Record<string, unknown>): number;
27
+ /** An inline island (variable, inline math) at the cursor. */
28
+ export declare function insertInlineIsland(editor: Editor, raw: string, islandType: string): boolean;
29
+ export declare function insertVariable(editor: Editor, name: string): boolean;
30
+ export declare function insertInlineMath(editor: Editor, tex: string): boolean;
31
+ /** Replace an island's bytes — the one explicit island edit. */
32
+ export declare function replaceIslandRaw(editor: Editor, pos: number, raw: string): boolean;
33
+ export declare function removeNodeAt(editor: Editor, pos: number): boolean;
34
+ export declare function setCallout(editor: Editor, type: CalloutType | null): boolean;
35
+ /** Turn the current list (or paragraph) into a checklist, or back. */
36
+ export declare function toggleTaskList(editor: Editor): boolean;
37
+ export declare function toggleTaskChecked(editor: Editor, itemPos: number): boolean;
38
+ /** Footnote numbers already used in the document text. */
39
+ export declare function nextFootnoteNumber(text: string): number;
40
+ /**
41
+ * Insert a footnote reference at the cursor and its definition at the end of
42
+ * the document; the cursor lands in the definition.
43
+ */
44
+ export declare function insertFootnote(editor: Editor): number;
45
+ /**
46
+ * Move the block holding the cursor one step up or down among its siblings
47
+ * (a paragraph inside a stored block moves inside it; a whole stored block or
48
+ * island moves among the document's blocks).
49
+ */
50
+ export declare function moveBlock(editor: Editor, direction: "up" | "down"): boolean;
51
+ /** ```lang typed on its own line + Enter → a code block island. */
52
+ export declare function fenceFromParagraph(editor: Editor): boolean;
53
+ /** The top-level node (and its position) at a document position. */
54
+ export declare function topLevelNodeAt(doc: PMNode, pos: number): {
55
+ node: PMNode;
56
+ pos: number;
57
+ } | null;
58
+ export { topLevelAt };
59
+ export type ColumnAlignment = "left" | "center" | "right" | null;
60
+ /**
61
+ * Align the column the cursor is in: every cell of that column gets the
62
+ * alignment, so the GFM delimiter row (the only place markdown stores it) is
63
+ * rewritten and nothing else in the table moves.
64
+ */
65
+ export declare function setColumnAlign(editor: Editor, align: ColumnAlignment): boolean;
@@ -0,0 +1,299 @@
1
+ import { Fragment } from "@tiptap/pm/model";
2
+ import { NodeSelection, TextSelection } from "@tiptap/pm/state";
3
+ import { PAGE_BREAK_MARKDOWN } from "@ai-matrx/print/directives";
4
+ import { fenceOpenerOf } from "@ai-matrx/content-ir/source";
5
+ const CALLOUT_TYPES = ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"];
6
+ const TASK_OPEN = "[ ] ";
7
+ const TASK_DONE = "[x] ";
8
+ function fenceMarkdown(language, code = "") {
9
+ let longest = 0;
10
+ for (const run of code.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
11
+ const fence = "`".repeat(Math.max(3, longest + 1));
12
+ return `${fence}${language}
13
+ ${code}
14
+ ${fence}`;
15
+ }
16
+ function mathBlockMarkdown(tex = "") {
17
+ return `$$
18
+ ${tex}
19
+ $$`;
20
+ }
21
+ function kindMarkdown(kind, data = {}) {
22
+ const { __kind: _ignored, ...rest } = data;
23
+ return JSON.stringify({ __kind: kind, ...rest }, null, 2);
24
+ }
25
+ function topLevelAt(editor) {
26
+ const { doc, selection } = editor.state;
27
+ const $from = selection.$from;
28
+ const index = $from.depth === 0 ? $from.index(0) : $from.index(0);
29
+ if (index >= doc.childCount) return null;
30
+ let pos = 0;
31
+ for (let i = 0; i < index; i += 1) pos += doc.child(i).nodeSize;
32
+ return { index, pos, node: doc.child(index) };
33
+ }
34
+ function insertIsland(editor, raw, islandType) {
35
+ const { state } = editor;
36
+ const type = state.schema.nodes.islandBlock;
37
+ if (!type) return -1;
38
+ const island = type.create({ raw, islandType, complete: true });
39
+ const { $from } = state.selection;
40
+ let at = -1;
41
+ editor.commands.command(({ tr }) => {
42
+ const parent = $from.parent;
43
+ if (state.selection instanceof NodeSelection) {
44
+ at = state.selection.to;
45
+ tr.insert(at, island);
46
+ } else if (parent.isTextblock && parent.content.size === 0 && $from.depth > 0) {
47
+ at = $from.before($from.depth);
48
+ tr.replaceWith(at, $from.after($from.depth), island);
49
+ } else {
50
+ at = $from.depth > 0 ? $from.after(Math.min($from.depth, 2) || 1) : $from.pos;
51
+ tr.insert(at, island);
52
+ }
53
+ tr.setSelection(NodeSelection.create(tr.doc, at));
54
+ return true;
55
+ });
56
+ return at;
57
+ }
58
+ function insertCodeBlock(editor, language = "") {
59
+ return insertIsland(editor, fenceMarkdown(language), "fence");
60
+ }
61
+ function insertMathBlock(editor, tex = "") {
62
+ return insertIsland(editor, mathBlockMarkdown(tex), "math_block");
63
+ }
64
+ function insertPageBreak(editor) {
65
+ return insertIsland(editor, PAGE_BREAK_MARKDOWN, "html_comment");
66
+ }
67
+ function insertKind(editor, kind, data = {}) {
68
+ return insertIsland(editor, kindMarkdown(kind, data), "json");
69
+ }
70
+ function insertInlineIsland(editor, raw, islandType) {
71
+ const type = editor.state.schema.nodes.inlineIsland;
72
+ if (!type) return false;
73
+ return editor.chain().focus().command(({ tr }) => {
74
+ tr.replaceSelectionWith(type.create({ raw, islandType }), false);
75
+ return true;
76
+ }).run();
77
+ }
78
+ function insertVariable(editor, name) {
79
+ return insertInlineIsland(editor, `{{${name}}}`, "variable");
80
+ }
81
+ function insertInlineMath(editor, tex) {
82
+ return insertInlineIsland(editor, `$${tex}$`, "math_inline");
83
+ }
84
+ function replaceIslandRaw(editor, pos, raw) {
85
+ const node = editor.state.doc.nodeAt(pos);
86
+ if (!node || node.type.name !== "islandBlock" && node.type.name !== "inlineIsland" && node.type.name !== "sourceLocked") {
87
+ return false;
88
+ }
89
+ if (node.attrs.raw === raw) return true;
90
+ return editor.commands.command(({ tr }) => {
91
+ tr.setNodeMarkup(pos, void 0, { ...node.attrs, raw });
92
+ return true;
93
+ });
94
+ }
95
+ function removeNodeAt(editor, pos) {
96
+ const node = editor.state.doc.nodeAt(pos);
97
+ if (!node) return false;
98
+ return editor.commands.command(({ tr }) => {
99
+ tr.delete(pos, pos + node.nodeSize);
100
+ return true;
101
+ });
102
+ }
103
+ function setCallout(editor, type) {
104
+ const { $from } = editor.state.selection;
105
+ for (let depth = $from.depth; depth > 0; depth -= 1) {
106
+ const node = $from.node(depth);
107
+ if (node.type.name === "blockquote") {
108
+ const pos = $from.before(depth);
109
+ return editor.commands.command(({ tr }) => {
110
+ tr.setNodeMarkup(pos, void 0, { ...node.attrs, mdAlert: type ? `[!${type}]` : null });
111
+ return true;
112
+ });
113
+ }
114
+ }
115
+ if (!type) return false;
116
+ return editor.chain().focus().toggleBlockquote().command(({ tr }) => {
117
+ const $pos = tr.selection.$from;
118
+ for (let depth = $pos.depth; depth > 0; depth -= 1) {
119
+ const node = $pos.node(depth);
120
+ if (node.type.name === "blockquote") {
121
+ tr.setNodeMarkup($pos.before(depth), void 0, { ...node.attrs, mdAlert: `[!${type}]` });
122
+ return true;
123
+ }
124
+ }
125
+ return false;
126
+ }).run();
127
+ }
128
+ function toggleTaskList(editor) {
129
+ const { $from } = editor.state.selection;
130
+ let itemDepth = -1;
131
+ for (let depth = $from.depth; depth > 0; depth -= 1) {
132
+ if ($from.node(depth).type.name === "listItem") {
133
+ itemDepth = depth;
134
+ break;
135
+ }
136
+ }
137
+ if (itemDepth === -1) {
138
+ if (!editor.chain().focus().toggleBulletList().run()) return false;
139
+ return toggleTaskList(editor);
140
+ }
141
+ const list = $from.node(itemDepth - 1);
142
+ const listPos = $from.before(itemDepth - 1);
143
+ const makeTask = list.firstChild?.attrs.mdTask === null || list.firstChild?.attrs.mdTask === void 0;
144
+ return editor.commands.command(({ tr }) => {
145
+ list.forEach((item, offset) => {
146
+ tr.setNodeMarkup(listPos + 1 + offset, void 0, {
147
+ ...item.attrs,
148
+ mdTask: makeTask ? item.attrs.mdTask ?? TASK_OPEN : null
149
+ });
150
+ });
151
+ return true;
152
+ });
153
+ }
154
+ function toggleTaskChecked(editor, itemPos) {
155
+ const item = editor.state.doc.nodeAt(itemPos);
156
+ if (!item || item.type.name !== "listItem" || item.attrs.mdTask === null) return false;
157
+ const checked = /\[[xX]\]/.test(String(item.attrs.mdTask));
158
+ return editor.commands.command(({ tr }) => {
159
+ tr.setNodeMarkup(itemPos, void 0, { ...item.attrs, mdTask: checked ? TASK_OPEN : TASK_DONE });
160
+ return true;
161
+ });
162
+ }
163
+ function nextFootnoteNumber(text) {
164
+ let max = 0;
165
+ for (const match of text.matchAll(/\[\^(\d+)\]/g)) max = Math.max(max, Number(match[1]));
166
+ return max + 1;
167
+ }
168
+ function insertFootnote(editor) {
169
+ const n = nextFootnoteNumber(editor.state.doc.textContent);
170
+ const paragraph = editor.state.schema.nodes.paragraph;
171
+ if (!paragraph) return -1;
172
+ editor.commands.command(({ tr }) => {
173
+ tr.insertText(`[^${n}]`);
174
+ const end = tr.doc.content.size;
175
+ tr.insert(end, paragraph.create(null, editor.state.schema.text(`[^${n}]: `)));
176
+ tr.setSelection(TextSelection.create(tr.doc, tr.doc.content.size - 1));
177
+ return true;
178
+ });
179
+ return n;
180
+ }
181
+ function moveBlock(editor, direction) {
182
+ const { state } = editor;
183
+ const selection = state.selection;
184
+ let depth;
185
+ const $from = selection.$from;
186
+ if (selection instanceof NodeSelection && $from.depth === 0) depth = 0;
187
+ else depth = $from.depth >= 2 && $from.node(1).type.name === "sourceBlock" && $from.node(1).childCount > 1 ? 1 : 0;
188
+ const parent = $from.node(depth);
189
+ const index = $from.index(depth);
190
+ const target = direction === "up" ? index - 1 : index + 1;
191
+ if (target < 0 || target >= parent.childCount) return false;
192
+ const start = depth === 0 ? 0 : $from.start(depth);
193
+ const offsetOf = (i) => {
194
+ let pos = start;
195
+ for (let k = 0; k < i; k += 1) pos += parent.child(k).nodeSize;
196
+ return pos;
197
+ };
198
+ const first = direction === "up" ? target : index;
199
+ const firstNode = parent.child(first);
200
+ const secondNode = parent.child(first + 1);
201
+ const from = offsetOf(first);
202
+ const movingFrom = offsetOf(index);
203
+ const selectionOffset = selection.from - movingFrom;
204
+ return editor.commands.command(({ tr }) => {
205
+ tr.replaceWith(from, from + firstNode.nodeSize + secondNode.nodeSize, Fragment.from([secondNode, firstNode]));
206
+ const movedTo = direction === "up" ? from : from + secondNode.nodeSize;
207
+ tr.setSelection(
208
+ selection instanceof NodeSelection ? NodeSelection.create(tr.doc, movedTo) : TextSelection.near(tr.doc.resolve(Math.min(movedTo + selectionOffset, tr.doc.content.size)))
209
+ );
210
+ return true;
211
+ });
212
+ }
213
+ function fenceFromParagraph(editor) {
214
+ const { $from, empty } = editor.state.selection;
215
+ if (!empty || !$from.parent.isTextblock || $from.parent.type.name !== "paragraph") return false;
216
+ const text = $from.parent.textContent;
217
+ const opener = fenceOpenerOf(text);
218
+ const fence = opener ? opener.char.repeat(opener.ticks) : "";
219
+ if (!opener || text !== `${fence}${opener.lang}` || !/^[\w+#.-]*$/.test(opener.lang)) return false;
220
+ const match = [text, fence, opener.lang];
221
+ const pos = $from.before($from.depth);
222
+ const island = editor.state.schema.nodes.islandBlock?.create({
223
+ raw: `${match[1]}${match[2]}
224
+
225
+ ${match[1]}`,
226
+ islandType: "fence",
227
+ complete: true
228
+ });
229
+ if (!island) return false;
230
+ return editor.commands.command(({ tr }) => {
231
+ tr.replaceWith(pos, pos + $from.parent.nodeSize, island);
232
+ tr.setSelection(NodeSelection.create(tr.doc, pos));
233
+ return true;
234
+ });
235
+ }
236
+ function topLevelNodeAt(doc, pos) {
237
+ let offset = 0;
238
+ for (let i = 0; i < doc.childCount; i += 1) {
239
+ const node = doc.child(i);
240
+ if (pos >= offset && pos < offset + node.nodeSize) return { node, pos: offset };
241
+ offset += node.nodeSize;
242
+ }
243
+ return null;
244
+ }
245
+ function setColumnAlign(editor, align) {
246
+ const { $from } = editor.state.selection;
247
+ let cellDepth = -1;
248
+ for (let depth = $from.depth; depth > 0; depth -= 1) {
249
+ const name = $from.node(depth).type.name;
250
+ if (name === "tableCell" || name === "tableHeader") {
251
+ cellDepth = depth;
252
+ break;
253
+ }
254
+ }
255
+ if (cellDepth < 2) return false;
256
+ const column = $from.index(cellDepth - 1);
257
+ const table = $from.node(cellDepth - 2);
258
+ const tablePos = $from.before(cellDepth - 2);
259
+ return editor.commands.command(({ tr }) => {
260
+ let rowPos = tablePos + 1;
261
+ table.forEach((row) => {
262
+ let cellPos = rowPos + 1;
263
+ row.forEach((cell, _offset, index) => {
264
+ if (index === column) tr.setNodeMarkup(cellPos, void 0, { ...cell.attrs, align });
265
+ cellPos += cell.nodeSize;
266
+ });
267
+ rowPos += row.nodeSize;
268
+ });
269
+ return true;
270
+ });
271
+ }
272
+ export {
273
+ CALLOUT_TYPES,
274
+ TASK_DONE,
275
+ TASK_OPEN,
276
+ fenceFromParagraph,
277
+ fenceMarkdown,
278
+ insertCodeBlock,
279
+ insertFootnote,
280
+ insertInlineIsland,
281
+ insertInlineMath,
282
+ insertIsland,
283
+ insertKind,
284
+ insertMathBlock,
285
+ insertPageBreak,
286
+ insertVariable,
287
+ kindMarkdown,
288
+ mathBlockMarkdown,
289
+ moveBlock,
290
+ nextFootnoteNumber,
291
+ removeNodeAt,
292
+ replaceIslandRaw,
293
+ setCallout,
294
+ setColumnAlign,
295
+ toggleTaskChecked,
296
+ toggleTaskList,
297
+ topLevelAt,
298
+ topLevelNodeAt
299
+ };
@@ -0,0 +1,29 @@
1
+ import { Extension, Mark, Node, type Extensions } from "@tiptap/core";
2
+ /** Node types that carry an `mdId` (their source identity in the baseline). */
3
+ export declare const MD_ID_NODE_TYPES: readonly ["paragraph", "heading", "bulletList", "orderedList", "listItem", "blockquote", "horizontalRule", "sourceLocked"];
4
+ export declare const SourceBlockNode: Node<any, any>;
5
+ export declare const IslandBlockNode: Node<any, any>;
6
+ export declare const SourceLockedNode: Node<any, any>;
7
+ export declare const InlineIslandNode: Node<any, any>;
8
+ /** Records how the source spelled each construct (see header). */
9
+ export declare const MarkdownFidelityAttributes: Extension<any, any>;
10
+ /**
11
+ * An author's backslash escape (`\*`, `\_`, `\#`…): the reader sees the
12
+ * literal character, the stored bytes keep the backslash. The text node holds
13
+ * the bare character under this mark and the serializer writes `\` before each
14
+ * ASCII punctuation character in it. Not inclusive, so text typed beside it is
15
+ * plain (the editor never ADDS an escape); combinable with every other mark.
16
+ */
17
+ export declare const EscapedText: Mark<any, any>;
18
+ export interface RichEditorExtensionOptions {
19
+ /** Placeholder shown in an empty document. */
20
+ placeholder?: string;
21
+ /** Told when a paste changed shape on the way in (merged table cells split). */
22
+ onPasteNotice?: (message: string) => void;
23
+ }
24
+ /**
25
+ * The extension list both the browser editor and the headless corpus use.
26
+ * The React layer swaps in node views for the three atoms; the schema is
27
+ * identical either way.
28
+ */
29
+ export declare function createRichEditorExtensions(options?: RichEditorExtensionOptions): Extensions;