@ai-matrx/rich-editor 0.0.0 → 0.1.2
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 +24 -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
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { Extension, Mark, Node, mergeAttributes } from "@tiptap/core";
|
|
2
|
+
import { Document } from "@tiptap/extension-document";
|
|
3
|
+
import { Text } from "@tiptap/extension-text";
|
|
4
|
+
import { Paragraph } from "@tiptap/extension-paragraph";
|
|
5
|
+
import { Heading } from "@tiptap/extension-heading";
|
|
6
|
+
import { Bold } from "@tiptap/extension-bold";
|
|
7
|
+
import { Italic } from "@tiptap/extension-italic";
|
|
8
|
+
import { Strike } from "@tiptap/extension-strike";
|
|
9
|
+
import { Code } from "@tiptap/extension-code";
|
|
10
|
+
import { Link } from "@tiptap/extension-link";
|
|
11
|
+
import { Blockquote } from "@tiptap/extension-blockquote";
|
|
12
|
+
import { HorizontalRule } from "@tiptap/extension-horizontal-rule";
|
|
13
|
+
import { HardBreak } from "@tiptap/extension-hard-break";
|
|
14
|
+
import {
|
|
15
|
+
BulletList,
|
|
16
|
+
ListItem,
|
|
17
|
+
ListKeymap,
|
|
18
|
+
OrderedList
|
|
19
|
+
} from "@tiptap/extension-list";
|
|
20
|
+
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
|
|
21
|
+
import { Dropcursor, Focus, Gapcursor, Placeholder, UndoRedo } from "@tiptap/extensions";
|
|
22
|
+
import { MarkdownTextPaste } from "./paste-markdown.js";
|
|
23
|
+
import { ClipboardPlainText } from "./clipboard-text.js";
|
|
24
|
+
const mdAttr = () => ({ default: null, rendered: false, keepOnSplit: false });
|
|
25
|
+
const MD_ID_NODE_TYPES = [
|
|
26
|
+
"paragraph",
|
|
27
|
+
"heading",
|
|
28
|
+
"bulletList",
|
|
29
|
+
"orderedList",
|
|
30
|
+
"listItem",
|
|
31
|
+
"blockquote",
|
|
32
|
+
"horizontalRule",
|
|
33
|
+
"sourceLocked"
|
|
34
|
+
];
|
|
35
|
+
function fenceFromPre(element) {
|
|
36
|
+
const code = element.querySelector("code");
|
|
37
|
+
const language = /(?:^|\s)(?:language|lang)-([\w+#.-]+)/.exec(code?.className ?? element.className)?.[1] ?? "";
|
|
38
|
+
const text = (code ?? element).textContent?.replace(/\n$/, "") ?? "";
|
|
39
|
+
let longest = 0;
|
|
40
|
+
for (const run of text.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
|
|
41
|
+
const fence = "`".repeat(Math.max(3, longest + 1));
|
|
42
|
+
return `${fence}${language}
|
|
43
|
+
${text}
|
|
44
|
+
${fence}`;
|
|
45
|
+
}
|
|
46
|
+
const SourceBlockNode = Node.create({
|
|
47
|
+
name: "sourceBlock",
|
|
48
|
+
group: "topblock",
|
|
49
|
+
content: "block+",
|
|
50
|
+
addAttributes() {
|
|
51
|
+
return { b: mdAttr() };
|
|
52
|
+
},
|
|
53
|
+
parseHTML() {
|
|
54
|
+
return [{ tag: "div[data-md-source-block]" }];
|
|
55
|
+
},
|
|
56
|
+
renderHTML({ HTMLAttributes }) {
|
|
57
|
+
return [
|
|
58
|
+
"div",
|
|
59
|
+
mergeAttributes(HTMLAttributes, {
|
|
60
|
+
"data-md-source-block": "",
|
|
61
|
+
class: "rich-editor-source-block"
|
|
62
|
+
}),
|
|
63
|
+
0
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const IslandBlockNode = Node.create({
|
|
68
|
+
name: "islandBlock",
|
|
69
|
+
group: "topblock block",
|
|
70
|
+
atom: true,
|
|
71
|
+
selectable: true,
|
|
72
|
+
draggable: true,
|
|
73
|
+
addAttributes() {
|
|
74
|
+
return {
|
|
75
|
+
raw: { default: "", rendered: false },
|
|
76
|
+
islandType: { default: "fence", rendered: false },
|
|
77
|
+
complete: { default: true, rendered: false },
|
|
78
|
+
b: mdAttr()
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
parseHTML() {
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
tag: "div[data-md-island]",
|
|
85
|
+
getAttrs: (element) => ({
|
|
86
|
+
raw: element.getAttribute("data-md-raw") ?? "",
|
|
87
|
+
islandType: element.getAttribute("data-md-island") ?? "fence"
|
|
88
|
+
})
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
// Pasted code (<pre>, <pre><code class="language-x">) becomes a fence island.
|
|
92
|
+
tag: "pre",
|
|
93
|
+
preserveWhitespace: "full",
|
|
94
|
+
getAttrs: (element) => ({ raw: fenceFromPre(element), islandType: "fence" })
|
|
95
|
+
}
|
|
96
|
+
];
|
|
97
|
+
},
|
|
98
|
+
renderHTML({ node }) {
|
|
99
|
+
return [
|
|
100
|
+
"div",
|
|
101
|
+
{
|
|
102
|
+
"data-md-island": String(node.attrs.islandType),
|
|
103
|
+
"data-md-raw": String(node.attrs.raw)
|
|
104
|
+
},
|
|
105
|
+
["pre", String(node.attrs.raw)]
|
|
106
|
+
];
|
|
107
|
+
},
|
|
108
|
+
renderText({ node }) {
|
|
109
|
+
return String(node.attrs.raw);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
const SourceLockedNode = Node.create({
|
|
113
|
+
name: "sourceLocked",
|
|
114
|
+
group: "block",
|
|
115
|
+
atom: true,
|
|
116
|
+
selectable: true,
|
|
117
|
+
draggable: true,
|
|
118
|
+
addAttributes() {
|
|
119
|
+
return {
|
|
120
|
+
raw: { default: "", rendered: false },
|
|
121
|
+
reason: { default: null, rendered: false },
|
|
122
|
+
b: mdAttr()
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
parseHTML() {
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
tag: "div[data-md-locked]",
|
|
129
|
+
getAttrs: (element) => ({
|
|
130
|
+
raw: element.getAttribute("data-md-raw") ?? ""
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
];
|
|
134
|
+
},
|
|
135
|
+
renderHTML({ node }) {
|
|
136
|
+
return [
|
|
137
|
+
"div",
|
|
138
|
+
{ "data-md-locked": "", "data-md-raw": String(node.attrs.raw) },
|
|
139
|
+
["pre", String(node.attrs.raw)]
|
|
140
|
+
];
|
|
141
|
+
},
|
|
142
|
+
renderText({ node }) {
|
|
143
|
+
return String(node.attrs.raw);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
const InlineIslandNode = Node.create({
|
|
147
|
+
name: "inlineIsland",
|
|
148
|
+
group: "inline",
|
|
149
|
+
inline: true,
|
|
150
|
+
atom: true,
|
|
151
|
+
selectable: true,
|
|
152
|
+
addAttributes() {
|
|
153
|
+
return {
|
|
154
|
+
raw: { default: "", rendered: false },
|
|
155
|
+
islandType: { default: "variable", rendered: false }
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
parseHTML() {
|
|
159
|
+
return [
|
|
160
|
+
{
|
|
161
|
+
// Pasted images keep their bytes as a markdown image island.
|
|
162
|
+
tag: "img[src]",
|
|
163
|
+
getAttrs: (element) => ({
|
|
164
|
+
raw: `![${(element.getAttribute("alt") ?? "").replace(/[[\]]/g, "")}](${element.getAttribute("src") ?? ""})`,
|
|
165
|
+
islandType: "md_image"
|
|
166
|
+
})
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
tag: "span[data-md-inline]",
|
|
170
|
+
getAttrs: (element) => ({
|
|
171
|
+
raw: element.getAttribute("data-md-raw") ?? "",
|
|
172
|
+
islandType: element.getAttribute("data-md-inline") ?? "variable"
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
];
|
|
176
|
+
},
|
|
177
|
+
renderHTML({ node }) {
|
|
178
|
+
return [
|
|
179
|
+
"span",
|
|
180
|
+
{
|
|
181
|
+
"data-md-inline": String(node.attrs.islandType),
|
|
182
|
+
"data-md-raw": String(node.attrs.raw)
|
|
183
|
+
},
|
|
184
|
+
String(node.attrs.raw)
|
|
185
|
+
];
|
|
186
|
+
},
|
|
187
|
+
renderText({ node }) {
|
|
188
|
+
return String(node.attrs.raw);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
const MarkdownFidelityAttributes = Extension.create({
|
|
192
|
+
name: "markdownFidelity",
|
|
193
|
+
addGlobalAttributes() {
|
|
194
|
+
return [
|
|
195
|
+
{ types: [...MD_ID_NODE_TYPES], attributes: { mdId: mdAttr() } },
|
|
196
|
+
{ types: ["heading"], attributes: { mdOpen: mdAttr(), mdClose: mdAttr() } },
|
|
197
|
+
{
|
|
198
|
+
types: ["listItem"],
|
|
199
|
+
attributes: {
|
|
200
|
+
mdLead: mdAttr(),
|
|
201
|
+
mdMarker: mdAttr(),
|
|
202
|
+
mdAfter: mdAttr(),
|
|
203
|
+
mdTask: mdAttr(),
|
|
204
|
+
mdIndent: mdAttr()
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
{ types: ["blockquote"], attributes: { mdPrefix: mdAttr(), mdAlert: mdAttr() } },
|
|
208
|
+
{
|
|
209
|
+
types: ["table"],
|
|
210
|
+
attributes: { mdId: mdAttr(), mdDelim: mdAttr(), mdAligns: mdAttr(), mdPipes: mdAttr() }
|
|
211
|
+
},
|
|
212
|
+
{ types: ["tableRow"], attributes: { mdRaw: mdAttr(), mdCells: mdAttr(), mdSegs: mdAttr() } },
|
|
213
|
+
{ types: ["horizontalRule", "hardBreak"], attributes: { mdRaw: mdAttr() } },
|
|
214
|
+
{
|
|
215
|
+
types: ["bold", "italic", "strike"],
|
|
216
|
+
attributes: { mdMarker: mdAttr(), mdDepth: mdAttr() }
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
types: ["code"],
|
|
220
|
+
attributes: { mdOpen: mdAttr(), mdClose: mdAttr(), mdDepth: mdAttr() }
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
types: ["link"],
|
|
224
|
+
attributes: {
|
|
225
|
+
mdForm: mdAttr(),
|
|
226
|
+
mdTail: mdAttr(),
|
|
227
|
+
mdTailHref: mdAttr(),
|
|
228
|
+
mdText: mdAttr(),
|
|
229
|
+
mdDepth: mdAttr()
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
const NewlineHardBreak = HardBreak.extend({
|
|
236
|
+
addKeyboardShortcuts() {
|
|
237
|
+
const newline = () => this.editor.commands.command(({ tr, dispatch }) => {
|
|
238
|
+
if (dispatch) tr.insertText("\n");
|
|
239
|
+
return true;
|
|
240
|
+
});
|
|
241
|
+
return { "Shift-Enter": newline, "Mod-Enter": newline };
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
const cellAlign = {
|
|
245
|
+
align: {
|
|
246
|
+
default: null,
|
|
247
|
+
parseHTML: (element) => element.style.textAlign || null,
|
|
248
|
+
renderHTML: (attributes) => attributes.align ? { style: `text-align: ${String(attributes.align)}` } : {}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const InlineTableCell = TableCell.extend({
|
|
252
|
+
content: "paragraph",
|
|
253
|
+
addAttributes() {
|
|
254
|
+
return { ...this.parent?.(), ...cellAlign };
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
const InlineTableHeader = TableHeader.extend({
|
|
258
|
+
content: "paragraph",
|
|
259
|
+
addAttributes() {
|
|
260
|
+
return { ...this.parent?.(), ...cellAlign };
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
const CombinableCode = Code.extend({ excludes: "" });
|
|
264
|
+
const EscapedText = Mark.create({
|
|
265
|
+
name: "mdEscape",
|
|
266
|
+
inclusive: false,
|
|
267
|
+
excludes: "",
|
|
268
|
+
parseHTML() {
|
|
269
|
+
return [{ tag: "span[data-md-escape]" }];
|
|
270
|
+
},
|
|
271
|
+
renderHTML() {
|
|
272
|
+
return ["span", { "data-md-escape": "" }, 0];
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
function createRichEditorExtensions(options = {}) {
|
|
276
|
+
const list = [
|
|
277
|
+
Document.extend({ content: "(block | topblock)+" }),
|
|
278
|
+
Paragraph,
|
|
279
|
+
Text,
|
|
280
|
+
Heading.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
281
|
+
Blockquote,
|
|
282
|
+
BulletList,
|
|
283
|
+
OrderedList,
|
|
284
|
+
ListItem,
|
|
285
|
+
ListKeymap,
|
|
286
|
+
HorizontalRule,
|
|
287
|
+
NewlineHardBreak,
|
|
288
|
+
Bold,
|
|
289
|
+
Italic,
|
|
290
|
+
Strike,
|
|
291
|
+
CombinableCode,
|
|
292
|
+
EscapedText,
|
|
293
|
+
Link.configure({
|
|
294
|
+
openOnClick: false,
|
|
295
|
+
autolink: false,
|
|
296
|
+
linkOnPaste: true
|
|
297
|
+
}),
|
|
298
|
+
Table.configure({ resizable: false }),
|
|
299
|
+
TableRow,
|
|
300
|
+
InlineTableHeader,
|
|
301
|
+
InlineTableCell,
|
|
302
|
+
SourceBlockNode,
|
|
303
|
+
IslandBlockNode,
|
|
304
|
+
SourceLockedNode,
|
|
305
|
+
InlineIslandNode,
|
|
306
|
+
MarkdownFidelityAttributes,
|
|
307
|
+
UndoRedo,
|
|
308
|
+
Focus.configure({ className: "has-focus", mode: "shallowest" }),
|
|
309
|
+
Gapcursor,
|
|
310
|
+
Dropcursor,
|
|
311
|
+
Placeholder.configure({ placeholder: options.placeholder ?? "Write\u2026" }),
|
|
312
|
+
MarkdownTextPaste.configure({ onNotice: options.onPasteNotice ?? null }),
|
|
313
|
+
ClipboardPlainText
|
|
314
|
+
];
|
|
315
|
+
return list.map((extension) => extension.extend({ addPasteRules: () => [] }));
|
|
316
|
+
}
|
|
317
|
+
export {
|
|
318
|
+
EscapedText,
|
|
319
|
+
InlineIslandNode,
|
|
320
|
+
IslandBlockNode,
|
|
321
|
+
MD_ID_NODE_TYPES,
|
|
322
|
+
MarkdownFidelityAttributes,
|
|
323
|
+
SourceBlockNode,
|
|
324
|
+
SourceLockedNode,
|
|
325
|
+
createRichEditorExtensions
|
|
326
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface FindOptions {
|
|
2
|
+
caseSensitive?: boolean;
|
|
3
|
+
wholeWord?: boolean;
|
|
4
|
+
regex?: boolean;
|
|
5
|
+
includeProtected?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface FindMatch {
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
/** The match overlaps protected content. */
|
|
11
|
+
inProtected: boolean;
|
|
12
|
+
/** The stored bytes of the island it overlaps (for the explicit-edit record). */
|
|
13
|
+
islandRaw: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface FindResult {
|
|
16
|
+
/** Matches the replace may act on. */
|
|
17
|
+
matches: FindMatch[];
|
|
18
|
+
/** Matches left out because they sit in protected content. */
|
|
19
|
+
skippedProtected: number;
|
|
20
|
+
/** A readable reason when the pattern is invalid. */
|
|
21
|
+
error: string | null;
|
|
22
|
+
}
|
|
23
|
+
/** The one pattern builder both views use. Null when the query is empty or invalid. */
|
|
24
|
+
export declare function buildFindRegex(query: string, options?: FindOptions): {
|
|
25
|
+
regex: RegExp | null;
|
|
26
|
+
error: string | null;
|
|
27
|
+
};
|
|
28
|
+
/** Ranges of every island in the text, in order, with the island's bytes. */
|
|
29
|
+
export declare function protectedRanges(text: string): Array<[number, number, string]>;
|
|
30
|
+
export declare function findMatches(text: string, query: string, options?: FindOptions): FindResult;
|
|
31
|
+
/** The replacement text for one match (`$1` groups work in regex mode). */
|
|
32
|
+
export declare function replacementFor(matched: string, query: string, replacement: string, options?: FindOptions): string;
|
|
33
|
+
/** Apply a replacement to the given matches (non-overlapping, in order). */
|
|
34
|
+
export declare function replaceMatches(text: string, matches: readonly FindMatch[], query: string, replacement: string, options?: FindOptions): string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { listIslands, tokenizeSource } from "@ai-matrx/content-ir/source";
|
|
2
|
+
const MAX_MATCHES = 5e3;
|
|
3
|
+
function escapeRegExp(value) {
|
|
4
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5
|
+
}
|
|
6
|
+
function buildFindRegex(query, options = {}) {
|
|
7
|
+
if (!query) return { regex: null, error: null };
|
|
8
|
+
let source = options.regex ? query : escapeRegExp(query);
|
|
9
|
+
if (options.wholeWord) source = `(?<![\\p{L}\\p{N}_])(?:${source})(?![\\p{L}\\p{N}_])`;
|
|
10
|
+
try {
|
|
11
|
+
return { regex: new RegExp(source, `gu${options.caseSensitive ? "" : "i"}`), error: null };
|
|
12
|
+
} catch (error) {
|
|
13
|
+
return {
|
|
14
|
+
regex: null,
|
|
15
|
+
error: `That pattern is not valid: ${error instanceof Error ? error.message : String(error)}`
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function protectedRanges(text) {
|
|
20
|
+
return listIslands(tokenizeSource(text)).map((island) => [island.start, island.end, island.raw]);
|
|
21
|
+
}
|
|
22
|
+
function findMatches(text, query, options = {}) {
|
|
23
|
+
const { regex, error } = buildFindRegex(query, options);
|
|
24
|
+
if (!regex) return { matches: [], skippedProtected: 0, error };
|
|
25
|
+
const ranges = protectedRanges(text);
|
|
26
|
+
const matches = [];
|
|
27
|
+
let skippedProtected = 0;
|
|
28
|
+
let rangeIndex = 0;
|
|
29
|
+
for (const match of text.matchAll(regex)) {
|
|
30
|
+
const start = match.index ?? 0;
|
|
31
|
+
const end = start + match[0].length;
|
|
32
|
+
if (end === start) continue;
|
|
33
|
+
while (rangeIndex < ranges.length && (ranges[rangeIndex]?.[1] ?? 0) <= start) rangeIndex += 1;
|
|
34
|
+
const range = ranges[rangeIndex];
|
|
35
|
+
const inProtected = Boolean(range && range[0] < end && range[1] > start);
|
|
36
|
+
if (inProtected && !options.includeProtected) {
|
|
37
|
+
skippedProtected += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
matches.push({ start, end, inProtected, islandRaw: inProtected && range ? range[2] : null });
|
|
41
|
+
if (matches.length >= MAX_MATCHES) break;
|
|
42
|
+
}
|
|
43
|
+
return { matches, skippedProtected, error: null };
|
|
44
|
+
}
|
|
45
|
+
function replacementFor(matched, query, replacement, options = {}) {
|
|
46
|
+
if (!options.regex) return replacement;
|
|
47
|
+
const { regex } = buildFindRegex(query, options);
|
|
48
|
+
if (!regex) return replacement;
|
|
49
|
+
return matched.replace(new RegExp(regex.source, regex.flags.replace("g", "")), replacement);
|
|
50
|
+
}
|
|
51
|
+
function replaceMatches(text, matches, query, replacement, options = {}) {
|
|
52
|
+
let out = "";
|
|
53
|
+
let cursor = 0;
|
|
54
|
+
for (const match of matches) {
|
|
55
|
+
out += text.slice(cursor, match.start);
|
|
56
|
+
out += replacementFor(text.slice(match.start, match.end), query, replacement, options);
|
|
57
|
+
cursor = match.end;
|
|
58
|
+
}
|
|
59
|
+
return out + text.slice(cursor);
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
buildFindRegex,
|
|
63
|
+
findMatches,
|
|
64
|
+
protectedRanges,
|
|
65
|
+
replaceMatches,
|
|
66
|
+
replacementFor
|
|
67
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Transaction } from "@tiptap/pm/state";
|
|
2
|
+
/** True when the transaction is an undo or a redo. */
|
|
3
|
+
export declare function isHistoryTransaction(tr: Transaction): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* The island raws an undo/redo transaction took out of the document (as a
|
|
6
|
+
* multiset difference, so one of two identical islands counts). Empty for
|
|
7
|
+
* every other transaction.
|
|
8
|
+
*/
|
|
9
|
+
export declare function islandsReleasedByHistory(tr: Transaction): string[];
|
|
10
|
+
/** The same rule for plain text (the Source view): island raws an undo/redo took out. */
|
|
11
|
+
export declare function islandsReleasedBetweenTexts(before: string, after: string): string[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { listIslands, tokenizeSource } from "@ai-matrx/content-ir/source";
|
|
2
|
+
const HISTORY_META = "history$";
|
|
3
|
+
const ISLAND_TYPES = /* @__PURE__ */ new Set(["islandBlock", "inlineIsland", "sourceLocked"]);
|
|
4
|
+
function islandRaws(doc) {
|
|
5
|
+
const counts = /* @__PURE__ */ new Map();
|
|
6
|
+
doc.descendants((node) => {
|
|
7
|
+
if (!ISLAND_TYPES.has(node.type.name)) return true;
|
|
8
|
+
const raw = String(node.attrs.raw ?? "");
|
|
9
|
+
counts.set(raw, (counts.get(raw) ?? 0) + 1);
|
|
10
|
+
return false;
|
|
11
|
+
});
|
|
12
|
+
return counts;
|
|
13
|
+
}
|
|
14
|
+
function isHistoryTransaction(tr) {
|
|
15
|
+
return tr.getMeta(HISTORY_META) !== void 0;
|
|
16
|
+
}
|
|
17
|
+
function islandsReleasedByHistory(tr) {
|
|
18
|
+
if (!tr.docChanged || !isHistoryTransaction(tr)) return [];
|
|
19
|
+
const before = islandRaws(tr.before);
|
|
20
|
+
const after = islandRaws(tr.doc);
|
|
21
|
+
const released = [];
|
|
22
|
+
for (const [raw, count] of before) {
|
|
23
|
+
if ((after.get(raw) ?? 0) < count) released.push(raw);
|
|
24
|
+
}
|
|
25
|
+
return released;
|
|
26
|
+
}
|
|
27
|
+
function islandsReleasedBetweenTexts(before, after) {
|
|
28
|
+
const count = (text) => {
|
|
29
|
+
const counts = /* @__PURE__ */ new Map();
|
|
30
|
+
for (const island of listIslands(tokenizeSource(text))) counts.set(island.raw, (counts.get(island.raw) ?? 0) + 1);
|
|
31
|
+
return counts;
|
|
32
|
+
};
|
|
33
|
+
const was = count(before);
|
|
34
|
+
const now = count(after);
|
|
35
|
+
const released = [];
|
|
36
|
+
for (const [raw, n] of was) if ((now.get(raw) ?? 0) < n) released.push(raw);
|
|
37
|
+
return released;
|
|
38
|
+
}
|
|
39
|
+
export {
|
|
40
|
+
isHistoryTransaction,
|
|
41
|
+
islandsReleasedBetweenTexts,
|
|
42
|
+
islandsReleasedByHistory
|
|
43
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the editor does when its host hands it a NEW `value` (verify-RC-B4 R6-4).
|
|
3
|
+
*
|
|
4
|
+
* The break this guards: under a slow server the host's own save echo arrived
|
|
5
|
+
* while the person was still typing — the host re-sent `value` (the text of the
|
|
6
|
+
* save that just landed), the editor treated it as "a new document", remounted
|
|
7
|
+
* and silently dropped every keystroke typed after Save was pressed. The next
|
|
8
|
+
* save then persisted a copy without that typing.
|
|
9
|
+
*
|
|
10
|
+
* Rules, in order:
|
|
11
|
+
* - the same value the host last sent → nothing;
|
|
12
|
+
* - the value is a save the editor made (in flight or just landed) or already
|
|
13
|
+
* equals the draft → it becomes the stored text; the draft is untouched;
|
|
14
|
+
* - the draft has unsaved edits → the draft is KEPT, the new value becomes the
|
|
15
|
+
* stored text the next save diffs against, and the person is told;
|
|
16
|
+
* - otherwise (nothing unsaved) → the new document opens.
|
|
17
|
+
*/
|
|
18
|
+
export type HostValueAction = "ignore" | "adopt-stored" | "keep-draft" | "reset";
|
|
19
|
+
export declare function reconcileHostValue(input: {
|
|
20
|
+
value: string;
|
|
21
|
+
/** The value the host sent last time. */
|
|
22
|
+
lastValue: string;
|
|
23
|
+
/** What the editor believes is stored. */
|
|
24
|
+
stored: string;
|
|
25
|
+
/** The person's current text (flushed). */
|
|
26
|
+
draft: string;
|
|
27
|
+
/** Texts this editor has sent to save and not yet seen settle (or just saw settle). */
|
|
28
|
+
ownSaves: ReadonlySet<string>;
|
|
29
|
+
}): HostValueAction;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
function reconcileHostValue(input) {
|
|
2
|
+
const { value, lastValue, stored, draft, ownSaves } = input;
|
|
3
|
+
if (value === lastValue) return "ignore";
|
|
4
|
+
if (ownSaves.has(value) || value === draft) return "adopt-stored";
|
|
5
|
+
if (draft !== stored) return "keep-draft";
|
|
6
|
+
return "reset";
|
|
7
|
+
}
|
|
8
|
+
export {
|
|
9
|
+
reconcileHostValue
|
|
10
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { DOMParser as ProseMirrorDOMParser } from "@tiptap/pm/model";
|
|
2
|
+
import { createSerializeContext, serializeBlock } from "./markdown-serialize.js";
|
|
3
|
+
import { normalizePastedHtml } from "./paste-html.js";
|
|
4
|
+
function htmlToMarkdown(html, schema) {
|
|
5
|
+
const container = document.createElement("div");
|
|
6
|
+
container.innerHTML = normalizePastedHtml(html).html;
|
|
7
|
+
const doc = ProseMirrorDOMParser.fromSchema(schema).parse(container);
|
|
8
|
+
return serializeBlock(doc, createSerializeContext()).replace(/\s+$/, "");
|
|
9
|
+
}
|
|
10
|
+
export {
|
|
11
|
+
htmlToMarkdown
|
|
12
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SourceEditResult } from "./source-format.js";
|
|
2
|
+
export type FormatCommandId = "bold" | "italic" | "strike" | "code" | "link" | "heading1" | "heading2" | "heading3" | "bulletList" | "orderedList" | "taskList" | "quote" | "codeBlock";
|
|
3
|
+
export declare const FORMAT_COMMAND_IDS: readonly FormatCommandId[];
|
|
4
|
+
export declare function isFormatCommandId(id: string): id is FormatCommandId;
|
|
5
|
+
/** Run one formatting command on markdown text. Returns the minimal change. */
|
|
6
|
+
export declare function formatMarkdown(text: string, from: number, to: number, command: FormatCommandId): SourceEditResult;
|
|
7
|
+
/** Is the command already on for this selection (the toolbar's pressed state)? */
|
|
8
|
+
export declare function isFormatActive(text: string, from: number, to: number, command: FormatCommandId): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* The keyboard chords for the commands (ProseMirror key names; "Mod" = ⌘ on
|
|
11
|
+
* Apple, Ctrl elsewhere). The rich editor's shortcut table carries the same
|
|
12
|
+
* keys; this map is what a plain textarea host binds.
|
|
13
|
+
*/
|
|
14
|
+
export declare const FORMAT_COMMAND_KEYS: Readonly<Record<FormatCommandId, readonly string[]>>;
|
|
15
|
+
/** ProseMirror-style key name for a keyboard event ("Mod-Shift-x"). */
|
|
16
|
+
export declare function keyNameOf(event: Pick<KeyboardEvent, "key" | "code" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">, apple: boolean): string | null;
|
|
17
|
+
/** The command a chord runs, or null. */
|
|
18
|
+
export declare function formatCommandForKey(keyName: string | null): FormatCommandId | null;
|