foldkit-richtext-dom 0.1.0
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/LICENSE +21 -0
- package/README.md +228 -0
- package/dist/editor-bundle.d.mts +7535 -0
- package/dist/editor-bundle.d.mts.map +1 -0
- package/dist/editor-bundle.mjs +243 -0
- package/dist/editor-bundle.mjs.map +1 -0
- package/dist/editor.d.mts +849 -0
- package/dist/editor.d.mts.map +1 -0
- package/dist/editor.mjs +95 -0
- package/dist/editor.mjs.map +1 -0
- package/dist/events-BTw9SJ7r.d.mts +77 -0
- package/dist/events-BTw9SJ7r.d.mts.map +1 -0
- package/dist/events.d.mts +2 -0
- package/dist/events.mjs +305 -0
- package/dist/events.mjs.map +1 -0
- package/dist/host.d.mts +25 -0
- package/dist/host.d.mts.map +1 -0
- package/dist/host.mjs +52 -0
- package/dist/host.mjs.map +1 -0
- package/dist/html.d.mts +19 -0
- package/dist/html.d.mts.map +1 -0
- package/dist/html.mjs +234 -0
- package/dist/html.mjs.map +1 -0
- package/dist/index-6QEOehXh.d.mts +38 -0
- package/dist/index-6QEOehXh.d.mts.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +304 -0
- package/dist/index.mjs.map +1 -0
- package/dist/toolbar.d.mts +26 -0
- package/dist/toolbar.d.mts.map +1 -0
- package/dist/toolbar.mjs +23 -0
- package/dist/toolbar.mjs.map +1 -0
- package/dist/view.d.mts +9 -0
- package/dist/view.d.mts.map +1 -0
- package/dist/view.mjs +62 -0
- package/dist/view.mjs.map +1 -0
- package/package.json +89 -0
package/dist/html.d.mts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as RichText from "foldkit-richtext";
|
|
2
|
+
//#region src/html.d.ts
|
|
3
|
+
export interface HtmlDiagnostic {
|
|
4
|
+
readonly code: 'Dropped' | 'Unwrapped' | 'Undeclared';
|
|
5
|
+
readonly detail: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ParsedHtml {
|
|
8
|
+
readonly blocks: ReadonlyArray<RichText.Block>;
|
|
9
|
+
readonly diagnostics: ReadonlyArray<HtmlDiagnostic>;
|
|
10
|
+
}
|
|
11
|
+
export interface ParseOptions {
|
|
12
|
+
/** When given, a node kind the Kit does not declare is degraded, not kept. */
|
|
13
|
+
readonly kit?: RichText.Kit | undefined;
|
|
14
|
+
readonly mint: () => string;
|
|
15
|
+
}
|
|
16
|
+
/** Parses HTML into semantic blocks, reporting everything it refused. */
|
|
17
|
+
export declare const parseHtml: (html: string, options: ParseOptions) => ParsedHtml;
|
|
18
|
+
//#endregion
|
|
19
|
+
//# sourceMappingURL=html.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"html.d.mts","names":[],"sources":["../src/html.ts"],"mappings":";;iBAUiB;WACN;WACA;;iBAGM;WACN,QAAQ,cAAc,SAAS;WAC/B,aAAa,cAAc;;iBAGrB;;WAEN,MAAM,SAAS;WACf;;;qBAqSE,YAAa,cAAc,SAAS,iBAAe"}
|
package/dist/html.mjs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import * as RichText from "foldkit-richtext";
|
|
2
|
+
//#region src/html.ts
|
|
3
|
+
/**
|
|
4
|
+
* HTML import, constrained by a Kit (§70). The parser walks a `DOMParser` tree
|
|
5
|
+
* with a whitelist: known block tags become blocks, known inline tags become
|
|
6
|
+
* marks, our own `data-*` attributes round-trip, and everything else is either
|
|
7
|
+
* unwrapped or dropped with a diagnostic. No attribute is ever interpreted, so
|
|
8
|
+
* a pasted `style`, `href`, or `onclick` cannot survive as anything executable —
|
|
9
|
+
* and `script`/`style`/`iframe` content is dropped along with its element.
|
|
10
|
+
*/
|
|
11
|
+
/** Elements whose content must never be imported, not even as text. */
|
|
12
|
+
const DROPPED = /* @__PURE__ */ new Set([
|
|
13
|
+
"script",
|
|
14
|
+
"style",
|
|
15
|
+
"template",
|
|
16
|
+
"iframe",
|
|
17
|
+
"object",
|
|
18
|
+
"embed",
|
|
19
|
+
"noscript",
|
|
20
|
+
"svg",
|
|
21
|
+
"math",
|
|
22
|
+
"link",
|
|
23
|
+
"meta",
|
|
24
|
+
"title"
|
|
25
|
+
]);
|
|
26
|
+
const BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
27
|
+
"p",
|
|
28
|
+
"div",
|
|
29
|
+
"section",
|
|
30
|
+
"article",
|
|
31
|
+
"blockquote",
|
|
32
|
+
"ul",
|
|
33
|
+
"ol",
|
|
34
|
+
"li",
|
|
35
|
+
"pre",
|
|
36
|
+
"table",
|
|
37
|
+
"tr",
|
|
38
|
+
"td",
|
|
39
|
+
"th",
|
|
40
|
+
"figure",
|
|
41
|
+
"figcaption",
|
|
42
|
+
"main",
|
|
43
|
+
"header",
|
|
44
|
+
"footer",
|
|
45
|
+
"aside"
|
|
46
|
+
]);
|
|
47
|
+
const MARK_TAGS = {
|
|
48
|
+
strong: "Bold",
|
|
49
|
+
b: "Bold",
|
|
50
|
+
em: "Italic",
|
|
51
|
+
i: "Italic",
|
|
52
|
+
code: "Code"
|
|
53
|
+
};
|
|
54
|
+
const headingLevel = (tag) => {
|
|
55
|
+
const match = /^h([1-6])$/.exec(tag);
|
|
56
|
+
return match === null ? void 0 : Number(match[1]);
|
|
57
|
+
};
|
|
58
|
+
/** Whether a tag starts a block of its own rather than inline content. */
|
|
59
|
+
const isBlockElement = (tag) => BLOCK_TAGS.has(tag) || headingLevel(tag) !== void 0 || DROPPED.has(tag);
|
|
60
|
+
const mergePiece = (pieces, piece) => {
|
|
61
|
+
const last = pieces[pieces.length - 1];
|
|
62
|
+
if (last === void 0 || piece.text.length === 0) return [...pieces, piece];
|
|
63
|
+
return RichText.sameMarkSet(last.marks, piece.marks) ? [...pieces.slice(0, -1), {
|
|
64
|
+
text: last.text + piece.text,
|
|
65
|
+
marks: last.marks
|
|
66
|
+
}] : [...pieces, piece];
|
|
67
|
+
};
|
|
68
|
+
/** Appends one text node's content to the current line, merging equal marks. */
|
|
69
|
+
const appendText = (lines, text, marks) => {
|
|
70
|
+
if (text.length === 0) return;
|
|
71
|
+
const current = lines[lines.length - 1] ?? [];
|
|
72
|
+
lines[lines.length - 1] = mergePiece(current, {
|
|
73
|
+
text,
|
|
74
|
+
marks
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Collects one node's inline content into lines, one line per `<br>`. Text is
|
|
79
|
+
* taken literally; known inline tags add a mark, our own `data-marks` span adds
|
|
80
|
+
* several, and every other element is transparent — its attributes are never
|
|
81
|
+
* read, so nothing in the markup can become behaviour.
|
|
82
|
+
*/
|
|
83
|
+
const collectNode = (node, marks, lines, diagnostics) => {
|
|
84
|
+
if (node.nodeType === 3) {
|
|
85
|
+
appendText(lines, node.textContent ?? "", marks);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (node.nodeType !== 1) return;
|
|
89
|
+
const element = node;
|
|
90
|
+
const tag = element.tagName.toLowerCase();
|
|
91
|
+
if (DROPPED.has(tag)) {
|
|
92
|
+
diagnostics.push({
|
|
93
|
+
code: "Dropped",
|
|
94
|
+
detail: tag
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (tag === "br") {
|
|
99
|
+
lines.push([]);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const mark = MARK_TAGS[tag];
|
|
103
|
+
const declared = element.getAttribute("data-marks");
|
|
104
|
+
const added = mark !== void 0 ? [mark] : tag === "span" && declared !== null && declared.trim().length > 0 ? declared.trim().split(/\s+/) : [];
|
|
105
|
+
if (added.length === 0 && (BLOCK_TAGS.has(tag) || headingLevel(tag) !== void 0)) diagnostics.push({
|
|
106
|
+
code: "Unwrapped",
|
|
107
|
+
detail: tag
|
|
108
|
+
});
|
|
109
|
+
for (const child of Array.from(element.childNodes)) collectNode(child, [...marks, ...added], lines, diagnostics);
|
|
110
|
+
};
|
|
111
|
+
const collectLines = (element, marks, lines, diagnostics) => {
|
|
112
|
+
for (const child of Array.from(element.childNodes)) collectNode(child, marks, lines, diagnostics);
|
|
113
|
+
};
|
|
114
|
+
const runsFrom = (pieces, mint) => pieces.filter((piece) => piece.text.length > 0).map((piece) => ({
|
|
115
|
+
type: "Text",
|
|
116
|
+
id: RichText.NodeId.make(mint()),
|
|
117
|
+
text: piece.text,
|
|
118
|
+
marks: [...new Set(piece.marks)]
|
|
119
|
+
}));
|
|
120
|
+
const paragraphBlocks = (lines, mint) => lines.map((line) => ({
|
|
121
|
+
type: "Paragraph",
|
|
122
|
+
id: RichText.NodeId.make(mint()),
|
|
123
|
+
children: runsFrom(line, mint)
|
|
124
|
+
}));
|
|
125
|
+
const headingBlock = (level, lines, mint, diagnostics, kit) => {
|
|
126
|
+
const [first, ...rest] = lines;
|
|
127
|
+
if (kit !== void 0 && !kit.nodes.some((node) => node.name === "Heading")) {
|
|
128
|
+
diagnostics.push({
|
|
129
|
+
code: "Undeclared",
|
|
130
|
+
detail: "Heading"
|
|
131
|
+
});
|
|
132
|
+
return paragraphBlocks(lines, mint);
|
|
133
|
+
}
|
|
134
|
+
return [{
|
|
135
|
+
type: "Heading",
|
|
136
|
+
id: RichText.NodeId.make(mint()),
|
|
137
|
+
level: Math.min(Math.max(level, 1), 6),
|
|
138
|
+
children: runsFrom(first ?? [], mint)
|
|
139
|
+
}, ...paragraphBlocks(rest, mint)];
|
|
140
|
+
};
|
|
141
|
+
/** Blocks of one element's children: inline runs group into paragraphs, block children recurse. */
|
|
142
|
+
const childBlocks = (element, mint, diagnostics, kit) => {
|
|
143
|
+
const blocks = [];
|
|
144
|
+
let lines = [[]];
|
|
145
|
+
const flush = () => {
|
|
146
|
+
if (lines.some((line) => line.length > 0)) blocks.push(...paragraphBlocks(lines, mint));
|
|
147
|
+
lines = [[]];
|
|
148
|
+
};
|
|
149
|
+
for (const child of Array.from(element.childNodes)) {
|
|
150
|
+
if (child.nodeType === 1) {
|
|
151
|
+
const childElement = child;
|
|
152
|
+
const childTag = childElement.tagName.toLowerCase();
|
|
153
|
+
const level = headingLevel(childTag);
|
|
154
|
+
if (isBlockElement(childTag)) {
|
|
155
|
+
flush();
|
|
156
|
+
if (DROPPED.has(childTag)) {
|
|
157
|
+
diagnostics.push({
|
|
158
|
+
code: "Dropped",
|
|
159
|
+
detail: childTag
|
|
160
|
+
});
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
blocks.push(...level === void 0 ? blocksFrom(childElement, mint, diagnostics, kit) : headingBlock(level, linesOf(childElement, diagnostics), mint, diagnostics, kit));
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
collectNode(child, [], lines, diagnostics);
|
|
168
|
+
}
|
|
169
|
+
flush();
|
|
170
|
+
return blocks;
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* A node block for a kind, holding runs or nested blocks as its declaration says
|
|
174
|
+
* (and as its content implies when no declaration settles it). Props are not
|
|
175
|
+
* carried by HTML, so the value starts empty.
|
|
176
|
+
*/
|
|
177
|
+
const nodeBlockFrom = (element, kind, declared, mint, diagnostics, kit) => {
|
|
178
|
+
return declared?.kind === "node" && declared.children === RichText.blockContent || Array.from(element.children).some((child) => isBlockElement(child.tagName.toLowerCase())) ? {
|
|
179
|
+
type: "Node",
|
|
180
|
+
kind,
|
|
181
|
+
id: RichText.NodeId.make(mint()),
|
|
182
|
+
props: {},
|
|
183
|
+
children: [],
|
|
184
|
+
blocks: childBlocks(element, mint, diagnostics, kit)
|
|
185
|
+
} : {
|
|
186
|
+
type: "Node",
|
|
187
|
+
kind,
|
|
188
|
+
id: RichText.NodeId.make(mint()),
|
|
189
|
+
props: {},
|
|
190
|
+
children: runsFrom(linesOf(element, diagnostics).flat(), mint)
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
/** The kind a list element becomes, so pasted lists stay semantic. */
|
|
194
|
+
const listKindOf = (tag) => tag === "ul" || tag === "ol" ? "List" : tag === "li" ? "ListItem" : void 0;
|
|
195
|
+
/** Blocks of one element, including our own preserved, application-node, and list elements. */
|
|
196
|
+
const blocksFrom = (element, mint, diagnostics, kit) => {
|
|
197
|
+
const tag = element.tagName.toLowerCase();
|
|
198
|
+
const preserved = element.getAttribute("data-unknown");
|
|
199
|
+
if (tag === "div" && preserved !== null && preserved.trim().length > 0) return [{
|
|
200
|
+
type: "Unknown",
|
|
201
|
+
id: RichText.NodeId.make(mint()),
|
|
202
|
+
originalType: preserved.trim(),
|
|
203
|
+
props: {},
|
|
204
|
+
children: []
|
|
205
|
+
}];
|
|
206
|
+
const kind = element.getAttribute("data-node")?.trim() ?? listKindOf(tag);
|
|
207
|
+
if (kind !== void 0 && kind.length > 0) {
|
|
208
|
+
const declaredNode = kit?.nodes.find((candidate) => candidate.name === kind);
|
|
209
|
+
if (kit === void 0 || declaredNode !== void 0) return [nodeBlockFrom(element, kind, declaredNode, mint, diagnostics, kit)];
|
|
210
|
+
diagnostics.push({
|
|
211
|
+
code: "Undeclared",
|
|
212
|
+
detail: kind
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return childBlocks(element, mint, diagnostics, kit);
|
|
216
|
+
};
|
|
217
|
+
const linesOf = (element, diagnostics) => {
|
|
218
|
+
const lines = [[]];
|
|
219
|
+
collectLines(element, [], lines, diagnostics);
|
|
220
|
+
return lines;
|
|
221
|
+
};
|
|
222
|
+
/** Parses HTML into semantic blocks, reporting everything it refused. */
|
|
223
|
+
const parseHtml = (html, options) => {
|
|
224
|
+
const owner = new DOMParser().parseFromString(html, "text/html");
|
|
225
|
+
const diagnostics = [];
|
|
226
|
+
return {
|
|
227
|
+
blocks: blocksFrom(owner.body, options.mint, diagnostics, options.kit),
|
|
228
|
+
diagnostics
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
//#endregion
|
|
232
|
+
export { parseHtml };
|
|
233
|
+
|
|
234
|
+
//# sourceMappingURL=html.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"html.mjs","names":[],"sources":["../src/html.ts"],"sourcesContent":["/**\n * HTML import, constrained by a Kit (§70). The parser walks a `DOMParser` tree\n * with a whitelist: known block tags become blocks, known inline tags become\n * marks, our own `data-*` attributes round-trip, and everything else is either\n * unwrapped or dropped with a diagnostic. No attribute is ever interpreted, so\n * a pasted `style`, `href`, or `onclick` cannot survive as anything executable —\n * and `script`/`style`/`iframe` content is dropped along with its element.\n */\nimport * as RichText from 'foldkit-richtext'\n\nexport interface HtmlDiagnostic {\n readonly code: 'Dropped' | 'Unwrapped' | 'Undeclared'\n readonly detail: string\n}\n\nexport interface ParsedHtml {\n readonly blocks: ReadonlyArray<RichText.Block>\n readonly diagnostics: ReadonlyArray<HtmlDiagnostic>\n}\n\nexport interface ParseOptions {\n /** When given, a node kind the Kit does not declare is degraded, not kept. */\n readonly kit?: RichText.Kit | undefined\n readonly mint: () => string\n}\n\n/** Elements whose content must never be imported, not even as text. */\nconst DROPPED = new Set([\n 'script',\n 'style',\n 'template',\n 'iframe',\n 'object',\n 'embed',\n 'noscript',\n 'svg',\n 'math',\n 'link',\n 'meta',\n 'title',\n])\n\nconst BLOCK_TAGS = new Set([\n 'p',\n 'div',\n 'section',\n 'article',\n 'blockquote',\n 'ul',\n 'ol',\n 'li',\n 'pre',\n 'table',\n 'tr',\n 'td',\n 'th',\n 'figure',\n 'figcaption',\n 'main',\n 'header',\n 'footer',\n 'aside',\n])\n\nconst MARK_TAGS: Readonly<Record<string, string>> = {\n strong: 'Bold',\n b: 'Bold',\n em: 'Italic',\n i: 'Italic',\n code: 'Code',\n}\n\nconst headingLevel = (tag: string): number | undefined => {\n const match = /^h([1-6])$/.exec(tag)\n return match === null ? undefined : Number(match[1])\n}\n\n/** Whether a tag starts a block of its own rather than inline content. */\nconst isBlockElement = (tag: string): boolean =>\n BLOCK_TAGS.has(tag) || headingLevel(tag) !== undefined || DROPPED.has(tag)\n\ninterface Piece {\n readonly text: string\n readonly marks: ReadonlyArray<string>\n}\n\ntype Lines = ReadonlyArray<ReadonlyArray<Piece>>\n\nconst mergePiece = (pieces: ReadonlyArray<Piece>, piece: Piece): ReadonlyArray<Piece> => {\n const last = pieces[pieces.length - 1]\n if (last === undefined || piece.text.length === 0) return [...pieces, piece]\n return RichText.sameMarkSet(last.marks, piece.marks)\n ? [...pieces.slice(0, -1), { text: last.text + piece.text, marks: last.marks }]\n : [...pieces, piece]\n}\n\n/** Appends one text node's content to the current line, merging equal marks. */\nconst appendText = (\n lines: Array<ReadonlyArray<Piece>>,\n text: string,\n marks: ReadonlyArray<string>,\n): void => {\n if (text.length === 0) return\n const current = lines[lines.length - 1] ?? []\n lines[lines.length - 1] = mergePiece(current, { text, marks })\n}\n\n/**\n * Collects one node's inline content into lines, one line per `<br>`. Text is\n * taken literally; known inline tags add a mark, our own `data-marks` span adds\n * several, and every other element is transparent — its attributes are never\n * read, so nothing in the markup can become behaviour.\n */\nconst collectNode = (\n node: Node,\n marks: ReadonlyArray<string>,\n lines: Array<ReadonlyArray<Piece>>,\n diagnostics: Array<HtmlDiagnostic>,\n): void => {\n if (node.nodeType === 3) {\n appendText(lines, node.textContent ?? '', marks)\n return\n }\n if (node.nodeType !== 1) return\n const element = node as Element\n const tag = element.tagName.toLowerCase()\n if (DROPPED.has(tag)) {\n diagnostics.push({ code: 'Dropped', detail: tag })\n return\n }\n if (tag === 'br') {\n lines.push([])\n return\n }\n const mark = MARK_TAGS[tag]\n const declared = element.getAttribute('data-marks')\n const added =\n mark !== undefined\n ? [mark]\n : tag === 'span' && declared !== null && declared.trim().length > 0\n ? declared.trim().split(/\\s+/)\n : []\n if (added.length === 0 && (BLOCK_TAGS.has(tag) || headingLevel(tag) !== undefined)) {\n diagnostics.push({ code: 'Unwrapped', detail: tag })\n }\n for (const child of Array.from(element.childNodes)) {\n collectNode(child, [...marks, ...added], lines, diagnostics)\n }\n}\n\nconst collectLines = (\n element: Element,\n marks: ReadonlyArray<string>,\n lines: Array<ReadonlyArray<Piece>>,\n diagnostics: Array<HtmlDiagnostic>,\n): void => {\n for (const child of Array.from(element.childNodes)) collectNode(child, marks, lines, diagnostics)\n}\n\nconst runsFrom = (pieces: ReadonlyArray<Piece>, mint: () => string): ReadonlyArray<RichText.Text> =>\n pieces\n .filter(piece => piece.text.length > 0)\n .map(piece => ({\n type: 'Text' as const,\n id: RichText.NodeId.make(mint()),\n text: piece.text,\n marks: [...new Set(piece.marks)],\n }))\n\nconst paragraphBlocks = (lines: Lines, mint: () => string): ReadonlyArray<RichText.Block> =>\n lines.map(line => ({\n type: 'Paragraph' as const,\n id: RichText.NodeId.make(mint()),\n children: runsFrom(line, mint),\n }))\n\nconst headingBlock = (\n level: number,\n lines: Lines,\n mint: () => string,\n diagnostics: Array<HtmlDiagnostic>,\n kit: RichText.Kit | undefined,\n): ReadonlyArray<RichText.Block> => {\n const [first, ...rest] = lines\n if (kit !== undefined && !kit.nodes.some(node => node.name === 'Heading')) {\n diagnostics.push({ code: 'Undeclared', detail: 'Heading' })\n return paragraphBlocks(lines, mint)\n }\n return [\n {\n type: 'Heading' as const,\n id: RichText.NodeId.make(mint()),\n level: Math.min(Math.max(level, 1), 6) as 1 | 2 | 3 | 4 | 5 | 6,\n children: runsFrom(first ?? [], mint),\n },\n ...paragraphBlocks(rest, mint),\n ]\n}\n\n/** Blocks of one element's children: inline runs group into paragraphs, block children recurse. */\nconst childBlocks = (\n element: Element,\n mint: () => string,\n diagnostics: Array<HtmlDiagnostic>,\n kit: RichText.Kit | undefined,\n): ReadonlyArray<RichText.Block> => {\n const blocks: Array<RichText.Block> = []\n let lines: Array<ReadonlyArray<Piece>> = [[]]\n const flush = (): void => {\n if (lines.some(line => line.length > 0)) blocks.push(...paragraphBlocks(lines, mint))\n lines = [[]]\n }\n for (const child of Array.from(element.childNodes)) {\n if (child.nodeType === 1) {\n const childElement = child as Element\n const childTag = childElement.tagName.toLowerCase()\n const level = headingLevel(childTag)\n const isBlock = isBlockElement(childTag)\n if (isBlock) {\n flush()\n if (DROPPED.has(childTag)) {\n diagnostics.push({ code: 'Dropped', detail: childTag })\n continue\n }\n blocks.push(\n ...(level === undefined\n ? blocksFrom(childElement, mint, diagnostics, kit)\n : headingBlock(level, linesOf(childElement, diagnostics), mint, diagnostics, kit)),\n )\n continue\n }\n }\n collectNode(child, [], lines, diagnostics)\n }\n flush()\n return blocks\n}\n\n/**\n * A node block for a kind, holding runs or nested blocks as its declaration says\n * (and as its content implies when no declaration settles it). Props are not\n * carried by HTML, so the value starts empty.\n */\nconst nodeBlockFrom = (\n element: Element,\n kind: string,\n declared: RichText.NodeDefinition | undefined,\n mint: () => string,\n diagnostics: Array<HtmlDiagnostic>,\n kit: RichText.Kit | undefined,\n): RichText.Block => {\n const holdsBlocks =\n (declared?.kind === 'node' && declared.children === RichText.blockContent) ||\n Array.from(element.children).some(child => isBlockElement(child.tagName.toLowerCase()))\n return holdsBlocks\n ? {\n type: 'Node',\n kind,\n id: RichText.NodeId.make(mint()),\n props: {},\n children: [],\n blocks: childBlocks(element, mint, diagnostics, kit),\n }\n : {\n type: 'Node',\n kind,\n id: RichText.NodeId.make(mint()),\n props: {},\n children: runsFrom(linesOf(element, diagnostics).flat(), mint),\n }\n}\n\n/** The kind a list element becomes, so pasted lists stay semantic. */\nconst listKindOf = (tag: string): string | undefined =>\n tag === 'ul' || tag === 'ol' ? 'List' : tag === 'li' ? 'ListItem' : undefined\n\n/** Blocks of one element, including our own preserved, application-node, and list elements. */\nconst blocksFrom = (\n element: Element,\n mint: () => string,\n diagnostics: Array<HtmlDiagnostic>,\n kit: RichText.Kit | undefined,\n): ReadonlyArray<RichText.Block> => {\n const tag = element.tagName.toLowerCase()\n const preserved = element.getAttribute('data-unknown')\n if (tag === 'div' && preserved !== null && preserved.trim().length > 0) {\n // Our own preserved-node placeholder: the payload stays opaque.\n return [\n {\n type: 'Unknown' as const,\n id: RichText.NodeId.make(mint()),\n originalType: preserved.trim(),\n props: {},\n children: [],\n },\n ]\n }\n const kind = element.getAttribute('data-node')?.trim() ?? listKindOf(tag)\n if (kind !== undefined && kind.length > 0) {\n const declaredNode = kit?.nodes.find(candidate => candidate.name === kind)\n if (kit === undefined || declaredNode !== undefined) {\n return [nodeBlockFrom(element, kind, declaredNode, mint, diagnostics, kit)]\n }\n diagnostics.push({ code: 'Undeclared', detail: kind })\n // Degrade to its content rather than keeping a kind the Kit refuses.\n }\n return childBlocks(element, mint, diagnostics, kit)\n}\n\nconst linesOf = (element: Element, diagnostics: Array<HtmlDiagnostic>): Lines => {\n const lines: Array<ReadonlyArray<Piece>> = [[]]\n collectLines(element, [], lines, diagnostics)\n return lines\n}\n\n/** Parses HTML into semantic blocks, reporting everything it refused. */\nexport const parseHtml = (html: string, options: ParseOptions): ParsedHtml => {\n const owner = new DOMParser().parseFromString(html, 'text/html')\n const diagnostics: Array<HtmlDiagnostic> = []\n const blocks = blocksFrom(owner.body, options.mint, diagnostics, options.kit)\n return { blocks, diagnostics }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as RichText from "foldkit-richtext";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
interface EditorDom {
|
|
4
|
+
/** The owned subtree root. Everything inside belongs to this interpreter. */
|
|
5
|
+
readonly root: HTMLElement;
|
|
6
|
+
/** Current element per block and run identity. */
|
|
7
|
+
readonly elements: ReadonlyMap<RichText.NodeId, HTMLElement>;
|
|
8
|
+
/** The semantic document this subtree currently represents. */
|
|
9
|
+
readonly content: RichText.Document;
|
|
10
|
+
/** The registry this subtree was rendered with, and is patched with again. */
|
|
11
|
+
readonly rendering: RichText.Rendering;
|
|
12
|
+
}
|
|
13
|
+
/** Builds the owned subtree and the identity index it is patched through. */
|
|
14
|
+
declare const mount: (owner: Document, content: RichText.Document, rendering?: RichText.Rendering) => EditorDom;
|
|
15
|
+
declare const patch: (dom: EditorDom, content: RichText.Document, changeSet: RichText.ChangeSet) => EditorDom;
|
|
16
|
+
/** The DOM range for a semantic position, or undefined if it no longer resolves. */
|
|
17
|
+
declare const positionToRange: (dom: EditorDom, position: RichText.Position) => Range | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Maps a browser selection endpoint back to a semantic position. Only text-node
|
|
20
|
+
* containers inside a run are supported; anything else (an element offset, a
|
|
21
|
+
* range outside the owned subtree) has no semantic position and returns
|
|
22
|
+
* undefined rather than guessing.
|
|
23
|
+
*/
|
|
24
|
+
declare const rangeToPosition: (dom: EditorDom, node: Node, offset: number) => RichText.Position | undefined;
|
|
25
|
+
/** Plain text of the subtree, one line per text block: for assertions and fallback copy. */
|
|
26
|
+
declare const toText: (dom: EditorDom) => string;
|
|
27
|
+
/**
|
|
28
|
+
* Recovery, not domain state (§31): makes the subtree match the document again
|
|
29
|
+
* after something outside the semantic pipeline touched it — a cancelled IME
|
|
30
|
+
* composition leaves text the document never had, and a browser extension can
|
|
31
|
+
* mutate anything. Blocks whose rendered text already matches are left alone,
|
|
32
|
+
* so this costs nothing in the normal case and returns the same `EditorDom`
|
|
33
|
+
* when nothing was wrong.
|
|
34
|
+
*/
|
|
35
|
+
declare const repair: (dom: EditorDom, content: RichText.Document) => EditorDom;
|
|
36
|
+
//#endregion
|
|
37
|
+
export { rangeToPosition as a, positionToRange as i, mount as n, repair as o, patch as r, toText as s, EditorDom as t };
|
|
38
|
+
//# sourceMappingURL=index-6QEOehXh.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index-6QEOehXh.d.mts","names":[],"sources":["../src/index.ts"],"mappings":";;UASiB;;WAEN,MAAM;;WAEN,UAAU,YAAY,SAAS,QAAQ;;WAEvC,SAAS,SAAS;;WAElB,WAAW,SAAS;;;cAuFlB,QACX,OAAO,UACP,SAAS,SAAS,UAClB,YAAW,SAAS,cACnB;cAsBU,QACX,KAAK,WACL,SAAS,SAAS,UAClB,WAAW,SAAS,cACnB;;cAoGU,kBAAmB,KAAK,WAAW,UAAU,SAAS,aAAW;;;;;;;cA8BjE,kBACX,KAAK,WACL,MAAM,MACN,mBACC,SAAS;;cAUC,SAAU,KAAK;;;;;;;;;cAuEf,SAAU,KAAK,WAAW,SAAS,SAAS,aAAW"}
|
package/dist/index.d.mts
ADDED
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import * as RichText from "foldkit-richtext";
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* The DOM interpreter for an editable subtree: it renders a semantic Document
|
|
5
|
+
* into an owned subtree, maps positions both ways, and patches only the nodes a
|
|
6
|
+
* ChangeSet names. No Foldkit VDOM reaches inside this subtree, and the semantic
|
|
7
|
+
* document stays authoritative — the DOM is never read as truth beyond mapping a
|
|
8
|
+
* browser selection back to a semantic position.
|
|
9
|
+
*/
|
|
10
|
+
const MARK_ATTRIBUTE = "data-marks";
|
|
11
|
+
/** The element a block's own type implies; a declared node kind is elsewhere. */
|
|
12
|
+
const blockTag = (block) => {
|
|
13
|
+
if (block.type === "Heading") return `h${block.level}`;
|
|
14
|
+
if (block.type === "Unknown" || block.type === "Node") return "div";
|
|
15
|
+
return "p";
|
|
16
|
+
};
|
|
17
|
+
/** The element a renderer entry names, with its attributes; children come later. */
|
|
18
|
+
const renderElement = (owner, entry) => {
|
|
19
|
+
const element = owner.createElement(entry.tag);
|
|
20
|
+
for (const [name, value] of Object.entries(entry.attributes)) element.setAttribute(name, value);
|
|
21
|
+
return element;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A run is one element carrying `data-run`, whatever the renderer wraps its text
|
|
25
|
+
* in. Nesting the mark elements *inside* that element is what keeps a browser
|
|
26
|
+
* selection mappable: `rangeToPosition` finds the run through `closest`, and the
|
|
27
|
+
* text node stays deepest, so an offset is still an offset into the run's text.
|
|
28
|
+
*/
|
|
29
|
+
const renderRun = (owner, run, rendering) => {
|
|
30
|
+
const element = owner.createElement("span");
|
|
31
|
+
element.setAttribute("data-run", run.id);
|
|
32
|
+
const { nest, unrendered } = RichText.runRendering(rendering, run);
|
|
33
|
+
let content = owner.createTextNode(run.text);
|
|
34
|
+
for (const entry of nest) {
|
|
35
|
+
const wrapper = renderElement(owner, entry);
|
|
36
|
+
wrapper.append(content);
|
|
37
|
+
content = wrapper;
|
|
38
|
+
}
|
|
39
|
+
element.append(content);
|
|
40
|
+
if (unrendered.length > 0) element.setAttribute(MARK_ATTRIBUTE, unrendered.join(" "));
|
|
41
|
+
return element;
|
|
42
|
+
};
|
|
43
|
+
const renderBlock = (owner, block, elements, rendering) => {
|
|
44
|
+
const entry = block.type === "Node" ? RichText.nodeRendering(rendering, block) : void 0;
|
|
45
|
+
const element = entry === void 0 ? owner.createElement(blockTag(block)) : renderElement(owner, entry);
|
|
46
|
+
element.setAttribute("data-block", block.id);
|
|
47
|
+
if (block.type === "Unknown") {
|
|
48
|
+
element.setAttribute("data-unknown", block.originalType);
|
|
49
|
+
element.setAttribute("contenteditable", "false");
|
|
50
|
+
element.textContent = `[${block.originalType}]`;
|
|
51
|
+
elements.set(block.id, element);
|
|
52
|
+
return element;
|
|
53
|
+
}
|
|
54
|
+
for (const run of block.children) {
|
|
55
|
+
const runElement = renderRun(owner, run, rendering);
|
|
56
|
+
element.append(runElement);
|
|
57
|
+
elements.set(run.id, runElement);
|
|
58
|
+
}
|
|
59
|
+
if (block.type === "Node" && block.blocks !== void 0) for (const nested of block.blocks) element.append(renderBlock(owner, nested, elements, rendering));
|
|
60
|
+
elements.set(block.id, element);
|
|
61
|
+
return element;
|
|
62
|
+
};
|
|
63
|
+
/** Builds the owned subtree and the identity index it is patched through. */
|
|
64
|
+
const mount = (owner, content, rendering = RichText.noRendering) => {
|
|
65
|
+
const root = owner.createElement("div");
|
|
66
|
+
root.setAttribute("contenteditable", "true");
|
|
67
|
+
const elements = /* @__PURE__ */ new Map();
|
|
68
|
+
for (const block of content.children) root.append(renderBlock(owner, block, elements, rendering));
|
|
69
|
+
return {
|
|
70
|
+
root,
|
|
71
|
+
elements,
|
|
72
|
+
content,
|
|
73
|
+
rendering
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Applies a ChangeSet: removed identities lose their elements, dirty identities
|
|
78
|
+
* are re-rendered in place (or inserted, when they are new), and every
|
|
79
|
+
* untouched element keeps its object identity, so the browser is not handed a
|
|
80
|
+
* rebuilt tree on each keystroke.
|
|
81
|
+
*/
|
|
82
|
+
const childIds = (element, attribute) => Array.from(element.children).map((child) => child.getAttribute(attribute)).filter((id) => id !== null);
|
|
83
|
+
const sameIds = (present, wanted) => present.length === wanted.length && present.every((id, at) => id === wanted[at]);
|
|
84
|
+
const patch = (dom, content, changeSet) => {
|
|
85
|
+
const elements = new Map(dom.elements);
|
|
86
|
+
const root = dom.root;
|
|
87
|
+
for (const id of changeSet.removedNodes) {
|
|
88
|
+
const element = elements.get(id);
|
|
89
|
+
if (element === void 0) continue;
|
|
90
|
+
element.remove();
|
|
91
|
+
elements.delete(id);
|
|
92
|
+
}
|
|
93
|
+
const rebuilt = /* @__PURE__ */ new Set();
|
|
94
|
+
patchBlocks(root.ownerDocument, root, content.children, elements, dom.rendering, rebuilt);
|
|
95
|
+
for (const id of changeSet.dirtyNodes) {
|
|
96
|
+
const located = RichText.locateRun(content, id);
|
|
97
|
+
if (located === void 0) continue;
|
|
98
|
+
const block = RichText.blockAtPath(content, located.path);
|
|
99
|
+
if (block === void 0 || rebuilt.has(block.id)) continue;
|
|
100
|
+
const parent = elements.get(block.id);
|
|
101
|
+
if (parent === void 0) continue;
|
|
102
|
+
const fresh = renderRun(root.ownerDocument, located.run, dom.rendering);
|
|
103
|
+
elements.get(id)?.remove();
|
|
104
|
+
elements.set(id, fresh);
|
|
105
|
+
const next = block.children[located.index + 1];
|
|
106
|
+
const nextElement = next === void 0 ? void 0 : elements.get(next.id);
|
|
107
|
+
if (nextElement !== void 0) nextElement.before(fresh);
|
|
108
|
+
else parent.append(fresh);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
root,
|
|
112
|
+
elements,
|
|
113
|
+
content,
|
|
114
|
+
rendering: dom.rendering
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Patches one block list into its container. A block is rebuilt only when its
|
|
119
|
+
* run list and its nested blocks are unchanged in shape, or it is new;
|
|
120
|
+
* otherwise its element stays, which keeps every sibling's identity — and a
|
|
121
|
+
* block that merely moved is moved, not re-rendered. A kept container needs no
|
|
122
|
+
* recursion: its nested identities are unchanged, so any change inside them is a
|
|
123
|
+
* run-level change the caller patches directly. Nested structural patching
|
|
124
|
+
* arrives with the operations that can produce it (§116 slice 3).
|
|
125
|
+
*/
|
|
126
|
+
const patchBlocks = (owner, container, blocks, elements, rendering, rebuilt) => {
|
|
127
|
+
let previousElement;
|
|
128
|
+
for (const [index, block] of blocks.entries()) {
|
|
129
|
+
const existing = elements.get(block.id);
|
|
130
|
+
const nested = block.type === "Node" && block.blocks !== void 0 ? block.blocks : [];
|
|
131
|
+
const sameStructure = existing !== void 0 && sameIds(childIds(existing, "data-run"), block.children.map((run) => run.id)) && sameIds(childIds(existing, "data-block"), nested.map((child) => child.id));
|
|
132
|
+
const place = (element) => {
|
|
133
|
+
if (previousElement !== void 0) previousElement.after(element);
|
|
134
|
+
else {
|
|
135
|
+
const nextId = blocks[index + 1]?.id;
|
|
136
|
+
const next = nextId === void 0 ? void 0 : elements.get(nextId);
|
|
137
|
+
if (next !== void 0) next.before(element);
|
|
138
|
+
else container.append(element);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
if (existing !== void 0 && sameStructure) {
|
|
142
|
+
if (container.children[index] !== existing) place(existing);
|
|
143
|
+
previousElement = existing;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const fresh = renderBlock(owner, block, elements, rendering);
|
|
147
|
+
existing?.remove();
|
|
148
|
+
place(fresh);
|
|
149
|
+
rebuilt.add(block.id);
|
|
150
|
+
previousElement = fresh;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* The text node a run's content starts in. Mark elements may wrap it, so this
|
|
155
|
+
* descends rather than taking `firstChild`, and returns nothing when the run
|
|
156
|
+
* holds no text at all.
|
|
157
|
+
*/
|
|
158
|
+
const textNodeOf = (element) => {
|
|
159
|
+
let node = element.firstChild;
|
|
160
|
+
while (node instanceof Element) node = node.firstChild;
|
|
161
|
+
return node instanceof Text ? node : void 0;
|
|
162
|
+
};
|
|
163
|
+
/** The DOM range for a semantic position, or undefined if it no longer resolves. */
|
|
164
|
+
const positionToRange = (dom, position) => {
|
|
165
|
+
const element = dom.elements.get(position.node);
|
|
166
|
+
if (element === void 0 || element.hasAttribute("data-unknown")) return void 0;
|
|
167
|
+
const text = textNodeOf(element);
|
|
168
|
+
const limit = text?.length ?? 0;
|
|
169
|
+
if (position.offset > limit) return void 0;
|
|
170
|
+
const range = dom.root.ownerDocument.createRange();
|
|
171
|
+
if (text === void 0) {
|
|
172
|
+
range.setStart(element, 0);
|
|
173
|
+
range.collapse(true);
|
|
174
|
+
return range;
|
|
175
|
+
}
|
|
176
|
+
range.setStart(text, position.offset);
|
|
177
|
+
range.collapse(true);
|
|
178
|
+
return range;
|
|
179
|
+
};
|
|
180
|
+
const runIdFor = (dom, node) => {
|
|
181
|
+
const id = ((node instanceof Element ? node : node.parentElement)?.closest("[data-run]"))?.getAttribute("data-run");
|
|
182
|
+
return id === null || id === void 0 ? void 0 : id;
|
|
183
|
+
};
|
|
184
|
+
/**
|
|
185
|
+
* Maps a browser selection endpoint back to a semantic position. Only text-node
|
|
186
|
+
* containers inside a run are supported; anything else (an element offset, a
|
|
187
|
+
* range outside the owned subtree) has no semantic position and returns
|
|
188
|
+
* undefined rather than guessing.
|
|
189
|
+
*/
|
|
190
|
+
const rangeToPosition = (dom, node, offset) => {
|
|
191
|
+
if (!(node instanceof Text)) return void 0;
|
|
192
|
+
const id = runIdFor(dom, node);
|
|
193
|
+
if (id === void 0) return void 0;
|
|
194
|
+
const located = RichText.locateRun(dom.content, id);
|
|
195
|
+
if (located === void 0) return void 0;
|
|
196
|
+
return {
|
|
197
|
+
node: id,
|
|
198
|
+
offset,
|
|
199
|
+
affinity: offset === located.run.text.length ? "after" : "before"
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
/** Plain text of the subtree, one line per text block: for assertions and fallback copy. */
|
|
203
|
+
const toText = (dom) => {
|
|
204
|
+
const lines = [];
|
|
205
|
+
const walk = (container) => {
|
|
206
|
+
for (const element of Array.from(container.children)) {
|
|
207
|
+
if (!element.hasAttribute("data-block")) continue;
|
|
208
|
+
if (Array.from(element.children).some((child) => child.hasAttribute("data-block"))) {
|
|
209
|
+
walk(element);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const line = (element.textContent ?? "").trim();
|
|
213
|
+
if (line.length > 0) lines.push(line);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
walk(dom.root);
|
|
217
|
+
return lines.join("\n");
|
|
218
|
+
};
|
|
219
|
+
const renderedText = (block) => {
|
|
220
|
+
if (block.type === "Unknown") return `[${block.originalType}]`;
|
|
221
|
+
return block.children.map((run) => run.text).join("") + (block.type === "Node" && block.blocks !== void 0 ? block.blocks.map(renderedText).join("") : "");
|
|
222
|
+
};
|
|
223
|
+
/** The chain of element names a run's text sits under, as `strong>em`. */
|
|
224
|
+
const runShape = (element) => {
|
|
225
|
+
const tags = [];
|
|
226
|
+
let node = element.firstChild;
|
|
227
|
+
while (node instanceof Element) {
|
|
228
|
+
tags.push(node.tagName.toLowerCase());
|
|
229
|
+
node = node.firstChild;
|
|
230
|
+
}
|
|
231
|
+
return tags.join(">");
|
|
232
|
+
};
|
|
233
|
+
/**
|
|
234
|
+
* Whether a run element still shows what the renderer produces for it: the same
|
|
235
|
+
* mark elements, and `data-marks` carrying exactly the names no entry renders. A
|
|
236
|
+
* browser can split a mark element or drop one without changing the text, and the
|
|
237
|
+
* semantic document stays the authority, so recovery has to see that.
|
|
238
|
+
*/
|
|
239
|
+
const runMatches = (element, rendering, run) => {
|
|
240
|
+
if (element === void 0) return false;
|
|
241
|
+
const { nest, unrendered } = RichText.runRendering(rendering, run);
|
|
242
|
+
const marks = unrendered.join(" ");
|
|
243
|
+
const carried = element.getAttribute(MARK_ATTRIBUTE);
|
|
244
|
+
if (marks.length === 0 ? carried !== null : carried !== marks) return false;
|
|
245
|
+
const expected = nest.map((entry) => entry.tag.toLowerCase()).reverse().join(">");
|
|
246
|
+
return runShape(element) === expected;
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* Recovery, not domain state (§31): makes the subtree match the document again
|
|
250
|
+
* after something outside the semantic pipeline touched it — a cancelled IME
|
|
251
|
+
* composition leaves text the document never had, and a browser extension can
|
|
252
|
+
* mutate anything. Blocks whose rendered text already matches are left alone,
|
|
253
|
+
* so this costs nothing in the normal case and returns the same `EditorDom`
|
|
254
|
+
* when nothing was wrong.
|
|
255
|
+
*/
|
|
256
|
+
const repair = (dom, content) => {
|
|
257
|
+
const present = /* @__PURE__ */ new Set();
|
|
258
|
+
const dirtyNodes = /* @__PURE__ */ new Set();
|
|
259
|
+
const removedNodes = /* @__PURE__ */ new Set();
|
|
260
|
+
const elements = new Map(dom.elements);
|
|
261
|
+
const inspect = (blocks) => {
|
|
262
|
+
for (const block of blocks) {
|
|
263
|
+
present.add(block.id);
|
|
264
|
+
for (const run of block.children) present.add(run.id);
|
|
265
|
+
const nested = block.type === "Node" && block.blocks !== void 0 ? block.blocks : [];
|
|
266
|
+
for (const child of nested) present.add(child.id);
|
|
267
|
+
const element = elements.get(block.id);
|
|
268
|
+
const runs = block.type === "Unknown" ? [] : block.children.map((run) => run.id);
|
|
269
|
+
const marksMatch = block.type === "Unknown" || block.children.every((run) => runMatches(elements.get(run.id), dom.rendering, run));
|
|
270
|
+
if (!(element !== void 0 && element.textContent === renderedText(block) && marksMatch && sameIds(childIds(element, "data-run"), runs) && sameIds(childIds(element, "data-block"), nested.map((child) => child.id)))) {
|
|
271
|
+
element?.remove();
|
|
272
|
+
elements.delete(block.id);
|
|
273
|
+
for (const run of runs) elements.delete(run);
|
|
274
|
+
dirtyNodes.add(block.id);
|
|
275
|
+
for (const run of runs) dirtyNodes.add(run);
|
|
276
|
+
}
|
|
277
|
+
inspect(nested);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
inspect(content.children);
|
|
281
|
+
for (const id of elements.keys()) if (!present.has(id)) removedNodes.add(id);
|
|
282
|
+
for (const element of Array.from(dom.root.querySelectorAll("[data-block], [data-run]"))) {
|
|
283
|
+
const identity = element.getAttribute("data-block") ?? element.getAttribute("data-run") ?? "";
|
|
284
|
+
if (identity.length > 0 && !present.has(identity)) element.remove();
|
|
285
|
+
}
|
|
286
|
+
if (dirtyNodes.size === 0 && removedNodes.size === 0) return dom;
|
|
287
|
+
return patch({
|
|
288
|
+
root: dom.root,
|
|
289
|
+
elements,
|
|
290
|
+
content: dom.content,
|
|
291
|
+
rendering: dom.rendering
|
|
292
|
+
}, content, {
|
|
293
|
+
dirtyNodes,
|
|
294
|
+
insertedNodes: /* @__PURE__ */ new Set(),
|
|
295
|
+
removedNodes,
|
|
296
|
+
textChanged: /* @__PURE__ */ new Set(),
|
|
297
|
+
structureChanged: false,
|
|
298
|
+
selectionChanged: false
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
//#endregion
|
|
302
|
+
export { mount, patch, positionToRange, rangeToPosition, repair, toText };
|
|
303
|
+
|
|
304
|
+
//# sourceMappingURL=index.mjs.map
|