@ai-matrx/rich-editor 0.0.0 → 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.
- package/CHANGELOG.md +19 -0
- package/LICENSE +1 -1
- package/README.md +13 -3
- package/dist/core/block-insert.d.ts +27 -0
- package/dist/core/block-insert.js +24 -0
- package/dist/core/caret-context.d.ts +24 -0
- package/dist/core/caret-context.js +73 -0
- package/dist/core/clipboard-text.d.ts +5 -0
- package/dist/core/clipboard-text.js +47 -0
- package/dist/core/commands.d.ts +65 -0
- package/dist/core/commands.js +299 -0
- package/dist/core/extensions.d.ts +29 -0
- package/dist/core/extensions.js +326 -0
- package/dist/core/find-replace.d.ts +34 -0
- package/dist/core/find-replace.js +67 -0
- package/dist/core/history-approval.d.ts +11 -0
- package/dist/core/history-approval.js +43 -0
- package/dist/core/host-value.d.ts +29 -0
- package/dist/core/host-value.js +10 -0
- package/dist/core/html-to-markdown.d.ts +3 -0
- package/dist/core/html-to-markdown.js +12 -0
- package/dist/core/markdown-format.d.ts +18 -0
- package/dist/core/markdown-format.js +354 -0
- package/dist/core/markdown-parse.d.ts +35 -0
- package/dist/core/markdown-parse.js +533 -0
- package/dist/core/markdown-serialize.d.ts +27 -0
- package/dist/core/markdown-serialize.js +394 -0
- package/dist/core/outline.d.ts +17 -0
- package/dist/core/outline.js +41 -0
- package/dist/core/paste-html.d.ts +10 -0
- package/dist/core/paste-html.js +198 -0
- package/dist/core/paste-markdown.d.ts +18 -0
- package/dist/core/paste-markdown.js +70 -0
- package/dist/core/placeholders.d.ts +22 -0
- package/dist/core/placeholders.js +60 -0
- package/dist/core/save-plan.d.ts +47 -0
- package/dist/core/save-plan.js +287 -0
- package/dist/core/shortcuts.d.ts +13 -0
- package/dist/core/shortcuts.js +50 -0
- package/dist/core/source-format.d.ts +28 -0
- package/dist/core/source-format.js +104 -0
- package/dist/core/text-metrics.d.ts +11 -0
- package/dist/core/text-metrics.js +35 -0
- package/dist/core/variable-name.d.ts +13 -0
- package/dist/core/variable-name.js +13 -0
- package/dist/core/variables.d.ts +37 -0
- package/dist/core/variables.js +77 -0
- package/dist/core/visual-document.d.ts +52 -0
- package/dist/core/visual-document.js +142 -0
- package/dist/core/visual-find.d.ts +27 -0
- package/dist/core/visual-find.js +93 -0
- package/dist/format/format-target.d.ts +39 -0
- package/dist/format/format-target.js +79 -0
- package/dist/in-place/caret-handoff.d.ts +3 -0
- package/dist/in-place/caret-handoff.js +14 -0
- package/dist/in-place/in-place-session.d.ts +24 -0
- package/dist/in-place/in-place-session.js +32 -0
- package/package.json +95 -5
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
CHANGED
package/README.md
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
# @ai-matrx/rich-editor
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
6
|
|
|
5
|
-
|
|
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.
|
|
6
11
|
|
|
7
|
-
|
|
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;
|