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.
@@ -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"}
@@ -0,0 +1,2 @@
1
+ import { a as rangeToPosition, i as positionToRange, n as mount, o as repair, r as patch, s as toText, t as EditorDom } from "./index-6QEOehXh.mjs";
2
+ export { EditorDom, mount, patch, positionToRange, rangeToPosition, repair, toText };
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