@miy2/xml-api 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -2
- package/dist/collab/bridge.js +1 -2
- package/dist/cst/cst-utils.d.ts +11 -0
- package/dist/cst/cst-utils.js +32 -0
- package/dist/cst/grammar.js +11 -25
- package/dist/cst/parser.js +7 -11
- package/dist/cst/xml-cst.js +1 -5
- package/dist/cst/xml-grammar.js +83 -86
- package/dist/dom.d.ts +20 -1
- package/dist/dom.js +93 -60
- package/dist/engine/editor-state.js +1 -5
- package/dist/engine/sync-engine.d.ts +43 -9
- package/dist/engine/sync-engine.js +166 -122
- package/dist/engine/transaction-builder.d.ts +14 -0
- package/dist/engine/transaction-builder.js +143 -0
- package/dist/engine/transaction.d.ts +3 -0
- package/dist/engine/transaction.js +12 -8
- package/dist/history-manager.d.ts +4 -0
- package/dist/history-manager.js +75 -5
- package/dist/model/formatter.d.ts +2 -0
- package/dist/model/formatter.js +15 -18
- package/dist/model/xml-api-model.d.ts +17 -6
- package/dist/model/xml-api-model.js +40 -34
- package/dist/model/xml-binder.d.ts +8 -1
- package/dist/model/xml-binder.js +48 -34
- package/dist/model/xml-schema.js +1 -5
- package/dist/view/schema-view.d.ts +80 -0
- package/dist/view/schema-view.js +222 -0
- package/dist/view/view-binder.d.ts +47 -0
- package/dist/view/view-binder.js +163 -0
- package/dist/xml-api-events.d.ts +8 -4
- package/dist/xml-api-events.js +1 -5
- package/dist/xml-api.d.ts +36 -22
- package/dist/xml-api.js +77 -59
- package/package.json +4 -2
- package/LICENSE.md +0 -21
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Document, Element, type Node } from "../dom";
|
|
2
|
+
import type { SyncEngine } from "../engine/sync-engine";
|
|
3
|
+
import type { Transaction } from "../engine/transaction";
|
|
4
|
+
import { ModelElement, type ModelNode } from "../model/xml-api-model";
|
|
5
|
+
import { type ExternalNode } from "./view-binder";
|
|
6
|
+
export interface SchemaViewConfig {
|
|
7
|
+
filter?: (node: ModelNode) => boolean;
|
|
8
|
+
}
|
|
9
|
+
export type ViewChangeEvent = {
|
|
10
|
+
type: "full";
|
|
11
|
+
target?: Node;
|
|
12
|
+
transaction?: Transaction;
|
|
13
|
+
} | {
|
|
14
|
+
type: "structure";
|
|
15
|
+
target: Node;
|
|
16
|
+
transaction?: Transaction;
|
|
17
|
+
addedNodes?: Node[];
|
|
18
|
+
removedNodes?: Node[];
|
|
19
|
+
} | {
|
|
20
|
+
type: "attribute";
|
|
21
|
+
target: Node;
|
|
22
|
+
key: string;
|
|
23
|
+
newValue: string | null;
|
|
24
|
+
transaction?: Transaction;
|
|
25
|
+
} | {
|
|
26
|
+
type: "text";
|
|
27
|
+
target: Node;
|
|
28
|
+
newValue?: string;
|
|
29
|
+
transaction?: Transaction;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* A filtered, schema-specific projection of the document.
|
|
33
|
+
*
|
|
34
|
+
* The SchemaView provides a "view" of the underlying Model that only contains nodes relevant
|
|
35
|
+
* to a specific schema (e.g., XHTML). It hides "invisible" nodes (like comments, processing instructions,
|
|
36
|
+
* or tags not in the allowed list) but preserves them in the Model during updates.
|
|
37
|
+
*
|
|
38
|
+
* This enables applications (like WYSIWYG editors) to work with a simplified DOM structure
|
|
39
|
+
* without destroying the full fidelity of the original source code.
|
|
40
|
+
*/
|
|
41
|
+
export declare class SchemaView {
|
|
42
|
+
private model;
|
|
43
|
+
private engine;
|
|
44
|
+
private config;
|
|
45
|
+
private document;
|
|
46
|
+
private events;
|
|
47
|
+
private currentMeta;
|
|
48
|
+
constructor(model: ModelElement, engine: SyncEngine, config?: SchemaViewConfig);
|
|
49
|
+
private initDocument;
|
|
50
|
+
/**
|
|
51
|
+
* Subscribes to changes in the view.
|
|
52
|
+
*
|
|
53
|
+
* Events emitted here are "projected" events:
|
|
54
|
+
* - They only fire for nodes visible in this view.
|
|
55
|
+
* - `target` nodes are View nodes (wrappers), not raw Model nodes.
|
|
56
|
+
* - Structure events (`addedNodes`/`removedNodes`) are filtered to exclude invisible nodes.
|
|
57
|
+
*
|
|
58
|
+
* @param handler Function to handle the events.
|
|
59
|
+
* @returns Unsubscribe function.
|
|
60
|
+
*/
|
|
61
|
+
on(handler: (event: ViewChangeEvent) => void): () => void;
|
|
62
|
+
private handleEngineEvent;
|
|
63
|
+
getDocument(): Document;
|
|
64
|
+
getRoot(): Element;
|
|
65
|
+
getNodeByModelId(id: string): Node | null;
|
|
66
|
+
getModelNode(viewNode: Node): ModelNode;
|
|
67
|
+
/**
|
|
68
|
+
* Reconciles an external DOM tree (e.g., from a browser's contentEditable) with this view.
|
|
69
|
+
*
|
|
70
|
+
* This method calculates the differences between the external tree and the current view,
|
|
71
|
+
* then applies minimal updates to the underlying Model. Crucially, it respects the
|
|
72
|
+
* "Invisible Node Preservation" rule: if the external tree is missing a node that is hidden
|
|
73
|
+
* in this view (like a comment), that node is preserved in the Model.
|
|
74
|
+
*
|
|
75
|
+
* @param externalDomNode The root of the external DOM tree to sync from.
|
|
76
|
+
* @param meta Optional metadata to attach to the generated transaction.
|
|
77
|
+
* @param target Optional specific element to reconcile (defaults to view root).
|
|
78
|
+
*/
|
|
79
|
+
reconcile(externalDomNode: ExternalNode, meta?: Record<string, any>, target?: Element): void;
|
|
80
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { createWrapper, Document, Element, } from "../dom";
|
|
2
|
+
import { ModelCDATA, ModelComment, ModelElement, ModelText, } from "../model/xml-api-model";
|
|
3
|
+
import { EventEmitter } from "../xml-api-events";
|
|
4
|
+
import { ViewBinder } from "./view-binder";
|
|
5
|
+
/**
|
|
6
|
+
* A filtered, schema-specific projection of the document.
|
|
7
|
+
*
|
|
8
|
+
* The SchemaView provides a "view" of the underlying Model that only contains nodes relevant
|
|
9
|
+
* to a specific schema (e.g., XHTML). It hides "invisible" nodes (like comments, processing instructions,
|
|
10
|
+
* or tags not in the allowed list) but preserves them in the Model during updates.
|
|
11
|
+
*
|
|
12
|
+
* This enables applications (like WYSIWYG editors) to work with a simplified DOM structure
|
|
13
|
+
* without destroying the full fidelity of the original source code.
|
|
14
|
+
*/
|
|
15
|
+
export class SchemaView {
|
|
16
|
+
constructor(model, engine, config = {}) {
|
|
17
|
+
this.model = model;
|
|
18
|
+
this.engine = engine;
|
|
19
|
+
this.config = config;
|
|
20
|
+
this.events = new EventEmitter();
|
|
21
|
+
// biome-ignore lint/suspicious/noExplicitAny: Metadata can store any type
|
|
22
|
+
this.currentMeta = { origin: "schema-view" };
|
|
23
|
+
this.initDocument();
|
|
24
|
+
this.engine.on(this.handleEngineEvent.bind(this));
|
|
25
|
+
}
|
|
26
|
+
initDocument() {
|
|
27
|
+
this.document = new Document();
|
|
28
|
+
if (this.config.filter) {
|
|
29
|
+
this.document.nodeFilter = this.config.filter;
|
|
30
|
+
}
|
|
31
|
+
const rootWrapper = createWrapper(this.model, this.document);
|
|
32
|
+
if (rootWrapper instanceof Element) {
|
|
33
|
+
this.document.documentElement = rootWrapper;
|
|
34
|
+
}
|
|
35
|
+
this.document.setObserver({
|
|
36
|
+
onAttributeChange: (element, name, value) => {
|
|
37
|
+
const model = element.getModel();
|
|
38
|
+
if (model instanceof ModelElement && model.cst) {
|
|
39
|
+
if (value !== null) {
|
|
40
|
+
this.engine.setAttribute(model, name, value, this.currentMeta);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
onChildAdded: (parent, child, index) => {
|
|
45
|
+
const parentModel = parent.getModel();
|
|
46
|
+
const childModel = child.getModel();
|
|
47
|
+
if (parentModel instanceof ModelElement && parentModel.cst) {
|
|
48
|
+
this.engine.insertNode(parentModel, childModel, index, this.currentMeta);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
onChildRemoved: (parent, child, _index) => {
|
|
52
|
+
const parentModel = parent.getModel();
|
|
53
|
+
const childModel = child.getModel();
|
|
54
|
+
if (parentModel instanceof ModelElement && parentModel.cst) {
|
|
55
|
+
if (childModel.cst) {
|
|
56
|
+
this.engine.removeNode(parentModel, childModel, this.currentMeta);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
onChildReplaced: (_parent, newChild, oldChild) => {
|
|
61
|
+
const newModel = newChild.getModel();
|
|
62
|
+
const oldModel = oldChild.getModel();
|
|
63
|
+
if (oldModel.cst) {
|
|
64
|
+
this.engine.replaceNode(oldModel, newModel, this.currentMeta);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
onTextChange: (node, text) => {
|
|
68
|
+
const modelNode = node.getModel();
|
|
69
|
+
if (modelNode instanceof ModelText && modelNode.cst) {
|
|
70
|
+
const newTextNode = new ModelText(text);
|
|
71
|
+
this.engine.replaceNode(modelNode, newTextNode, this.currentMeta);
|
|
72
|
+
}
|
|
73
|
+
else if (modelNode instanceof ModelComment && modelNode.cst) {
|
|
74
|
+
const newComment = new ModelComment(text);
|
|
75
|
+
this.engine.replaceNode(modelNode, newComment, this.currentMeta);
|
|
76
|
+
}
|
|
77
|
+
else if (modelNode instanceof ModelCDATA && modelNode.cst) {
|
|
78
|
+
const newCDATA = new ModelCDATA(text);
|
|
79
|
+
this.engine.replaceNode(modelNode, newCDATA, this.currentMeta);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
console.warn("Direct text node update not supported for this node or missing CST");
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
onElementTextChange: (element, text) => {
|
|
86
|
+
const model = element.getModel();
|
|
87
|
+
if (model instanceof ModelElement && model.cst) {
|
|
88
|
+
this.engine.updateText(model, text, this.currentMeta);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Subscribes to changes in the view.
|
|
95
|
+
*
|
|
96
|
+
* Events emitted here are "projected" events:
|
|
97
|
+
* - They only fire for nodes visible in this view.
|
|
98
|
+
* - `target` nodes are View nodes (wrappers), not raw Model nodes.
|
|
99
|
+
* - Structure events (`addedNodes`/`removedNodes`) are filtered to exclude invisible nodes.
|
|
100
|
+
*
|
|
101
|
+
* @param handler Function to handle the events.
|
|
102
|
+
* @returns Unsubscribe function.
|
|
103
|
+
*/
|
|
104
|
+
on(handler) {
|
|
105
|
+
return this.events.on(handler);
|
|
106
|
+
}
|
|
107
|
+
handleEngineEvent(event) {
|
|
108
|
+
// Ignore events that originated from this view (or any SchemaView)
|
|
109
|
+
// Ideally we should check if it's *this specific* view, but for now 'schema-view' covers self-cycles.
|
|
110
|
+
if (event.transaction &&
|
|
111
|
+
event.transaction.getMeta("origin") === "schema-view") {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (event.type === "full") {
|
|
115
|
+
// Update local model reference from engine if root changed
|
|
116
|
+
if (this.engine.model) {
|
|
117
|
+
this.model = this.engine.model;
|
|
118
|
+
this.initDocument();
|
|
119
|
+
}
|
|
120
|
+
this.events.emit({ type: "full", transaction: event.transaction });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!event.target)
|
|
124
|
+
return;
|
|
125
|
+
// Check if target is visible in view
|
|
126
|
+
// getNodeByModelId does the filtering check
|
|
127
|
+
const viewNode = this.getNodeByModelId(event.target.id);
|
|
128
|
+
if (viewNode) {
|
|
129
|
+
// Map event
|
|
130
|
+
if (event.type === "structure") {
|
|
131
|
+
const addedViewNodes = [];
|
|
132
|
+
if (event.addedNodes) {
|
|
133
|
+
for (const mNode of event.addedNodes) {
|
|
134
|
+
if (this.document.accepts(mNode)) {
|
|
135
|
+
addedViewNodes.push(createWrapper(mNode, this.document));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const removedViewNodes = [];
|
|
140
|
+
if (event.removedNodes) {
|
|
141
|
+
for (const mNode of event.removedNodes) {
|
|
142
|
+
if (this.document.accepts(mNode)) {
|
|
143
|
+
removedViewNodes.push(createWrapper(mNode, this.document));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
this.events.emit({
|
|
148
|
+
type: "structure",
|
|
149
|
+
target: viewNode,
|
|
150
|
+
transaction: event.transaction,
|
|
151
|
+
addedNodes: addedViewNodes,
|
|
152
|
+
removedNodes: removedViewNodes,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
else if (event.type === "attribute") {
|
|
156
|
+
this.events.emit({
|
|
157
|
+
type: "attribute",
|
|
158
|
+
target: viewNode,
|
|
159
|
+
key: event.key,
|
|
160
|
+
newValue: event.newValue,
|
|
161
|
+
transaction: event.transaction,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
else if (event.type === "text") {
|
|
165
|
+
this.events.emit({
|
|
166
|
+
type: "text",
|
|
167
|
+
target: viewNode,
|
|
168
|
+
newValue: event.newValue,
|
|
169
|
+
transaction: event.transaction,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
getDocument() {
|
|
175
|
+
return this.document;
|
|
176
|
+
}
|
|
177
|
+
getRoot() {
|
|
178
|
+
if (!this.document.documentElement) {
|
|
179
|
+
throw new Error("No root element in view");
|
|
180
|
+
}
|
|
181
|
+
return this.document.documentElement;
|
|
182
|
+
}
|
|
183
|
+
getNodeByModelId(id) {
|
|
184
|
+
const modelNode = this.model.findNodeById(id);
|
|
185
|
+
if (!modelNode)
|
|
186
|
+
return null;
|
|
187
|
+
if (!this.document.accepts(modelNode))
|
|
188
|
+
return null;
|
|
189
|
+
return createWrapper(modelNode, this.document);
|
|
190
|
+
}
|
|
191
|
+
getModelNode(viewNode) {
|
|
192
|
+
return viewNode.getModel();
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Reconciles an external DOM tree (e.g., from a browser's contentEditable) with this view.
|
|
196
|
+
*
|
|
197
|
+
* This method calculates the differences between the external tree and the current view,
|
|
198
|
+
* then applies minimal updates to the underlying Model. Crucially, it respects the
|
|
199
|
+
* "Invisible Node Preservation" rule: if the external tree is missing a node that is hidden
|
|
200
|
+
* in this view (like a comment), that node is preserved in the Model.
|
|
201
|
+
*
|
|
202
|
+
* @param externalDomNode The root of the external DOM tree to sync from.
|
|
203
|
+
* @param meta Optional metadata to attach to the generated transaction.
|
|
204
|
+
* @param target Optional specific element to reconcile (defaults to view root).
|
|
205
|
+
*/
|
|
206
|
+
reconcile(externalDomNode,
|
|
207
|
+
// biome-ignore lint/suspicious/noExplicitAny: Metadata can store any type
|
|
208
|
+
meta, target) {
|
|
209
|
+
const previousMeta = this.currentMeta;
|
|
210
|
+
if (meta) {
|
|
211
|
+
this.currentMeta = { ...this.currentMeta, ...meta };
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const binder = new ViewBinder(this.document);
|
|
215
|
+
// The external node corresponds to the root of the view (or the provided target)
|
|
216
|
+
binder.reconcile(externalDomNode, target || this.getRoot());
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
this.currentMeta = previousMeta;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Document as ApiDocument, Element as ApiElement } from "../dom";
|
|
2
|
+
export interface ExternalNode {
|
|
3
|
+
nodeType: number;
|
|
4
|
+
nodeName: string;
|
|
5
|
+
textContent: string | null;
|
|
6
|
+
childNodes: ArrayLike<ExternalNode>;
|
|
7
|
+
attributes?: ArrayLike<{
|
|
8
|
+
name: string;
|
|
9
|
+
value: string;
|
|
10
|
+
}>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Interface for logic that reconciles an external DOM tree with the internal document.
|
|
14
|
+
*/
|
|
15
|
+
export interface Reconciler {
|
|
16
|
+
/**
|
|
17
|
+
* Reconciles the internal element's children to match the external node's children.
|
|
18
|
+
*/
|
|
19
|
+
reconcile(externalNode: ExternalNode, internalElement: ApiElement): void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Handles the logic of reconciling a SchemaView (filtered DOM) with an external source
|
|
23
|
+
* (like a browser DOM in a contentEditable editor).
|
|
24
|
+
*
|
|
25
|
+
* Its primary responsibility is to apply changes from the external source to the internal
|
|
26
|
+
* SchemaView DOM without destroying data that the external source doesn't know about.
|
|
27
|
+
*
|
|
28
|
+
* Key Feature: Preservation of "Invisible" Nodes.
|
|
29
|
+
* If the Model contains "formatting whitespace" or other nodes that are present in the
|
|
30
|
+
* internal view but "invisible" or collapsed in the external view, the ViewBinder
|
|
31
|
+
* detects this and skips/preserves them instead of treating their absence as a deletion.
|
|
32
|
+
*/
|
|
33
|
+
export declare class ViewBinder implements Reconciler {
|
|
34
|
+
private document;
|
|
35
|
+
constructor(document: ApiDocument);
|
|
36
|
+
reconcile(externalNode: ExternalNode, internalElement: ApiElement): void;
|
|
37
|
+
private isSameType;
|
|
38
|
+
private updateNode;
|
|
39
|
+
private createFromExternal;
|
|
40
|
+
/**
|
|
41
|
+
* Checks if a node is "formatting whitespace" that should be preserved during sync,
|
|
42
|
+
* even if it is missing from the external view.
|
|
43
|
+
*
|
|
44
|
+
* This relies on the Model's classification of text nodes (e.g. `kind: 'whitespace'`).
|
|
45
|
+
*/
|
|
46
|
+
private isPreservableWhitespace;
|
|
47
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ModelText } from "../model/xml-api-model";
|
|
2
|
+
const IGNORED_ATTRIBUTES = new Set(["data-model-id"]);
|
|
3
|
+
/**
|
|
4
|
+
* Handles the logic of reconciling a SchemaView (filtered DOM) with an external source
|
|
5
|
+
* (like a browser DOM in a contentEditable editor).
|
|
6
|
+
*
|
|
7
|
+
* Its primary responsibility is to apply changes from the external source to the internal
|
|
8
|
+
* SchemaView DOM without destroying data that the external source doesn't know about.
|
|
9
|
+
*
|
|
10
|
+
* Key Feature: Preservation of "Invisible" Nodes.
|
|
11
|
+
* If the Model contains "formatting whitespace" or other nodes that are present in the
|
|
12
|
+
* internal view but "invisible" or collapsed in the external view, the ViewBinder
|
|
13
|
+
* detects this and skips/preserves them instead of treating their absence as a deletion.
|
|
14
|
+
*/
|
|
15
|
+
export class ViewBinder {
|
|
16
|
+
constructor(document) {
|
|
17
|
+
this.document = document;
|
|
18
|
+
}
|
|
19
|
+
reconcile(externalNode, internalElement) {
|
|
20
|
+
const bChildren = Array.from(externalNode.childNodes);
|
|
21
|
+
const vChildrenSnapshot = Array.from(internalElement.childNodes);
|
|
22
|
+
let bI = 0;
|
|
23
|
+
let vI = 0;
|
|
24
|
+
while (bI < bChildren.length || vI < vChildrenSnapshot.length) {
|
|
25
|
+
const bNode = bChildren[bI];
|
|
26
|
+
const vNode = vChildrenSnapshot[vI];
|
|
27
|
+
// Case 1: External exhausted
|
|
28
|
+
if (!bNode && vNode) {
|
|
29
|
+
// If vNode is formatting whitespace, preserve it.
|
|
30
|
+
if (this.isPreservableWhitespace(vNode)) {
|
|
31
|
+
vI++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// Otherwise, it was removed externally.
|
|
35
|
+
internalElement.removeChild(vNode);
|
|
36
|
+
vI++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// Case 2: Internal exhausted
|
|
40
|
+
if (bNode && !vNode) {
|
|
41
|
+
const newNode = this.createFromExternal(bNode);
|
|
42
|
+
if (newNode)
|
|
43
|
+
internalElement.appendChild(newNode);
|
|
44
|
+
bI++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// Case 3: Both exist
|
|
48
|
+
if (this.isPreservableWhitespace(vNode)) {
|
|
49
|
+
// If vNode is formatting whitespace, and bNode is an Element,
|
|
50
|
+
// we skip the whitespace to find the matching Element.
|
|
51
|
+
if (bNode.nodeType === 1) {
|
|
52
|
+
vI++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (this.isSameType(bNode, vNode)) {
|
|
57
|
+
this.updateNode(vNode, bNode);
|
|
58
|
+
if (bNode.nodeType === 1) {
|
|
59
|
+
// Element: recurse
|
|
60
|
+
this.reconcile(bNode, vNode);
|
|
61
|
+
}
|
|
62
|
+
bI++;
|
|
63
|
+
vI++;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
// Type mismatch
|
|
67
|
+
// If vNode was whitespace, we already handled it above.
|
|
68
|
+
// So this is a real mismatch (e.g. p vs div, or text vs element).
|
|
69
|
+
// Remove vNode (unless preservable - already checked)
|
|
70
|
+
// Check again just in case (redundant but safe)
|
|
71
|
+
if (this.isPreservableWhitespace(vNode)) {
|
|
72
|
+
vI++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
internalElement.removeChild(vNode);
|
|
76
|
+
vI++;
|
|
77
|
+
// Retry bNode against next vNode
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
isSameType(bNode, vNode) {
|
|
82
|
+
if (bNode.nodeType === 3 && vNode.nodeType === 3)
|
|
83
|
+
return true; // Text
|
|
84
|
+
if (bNode.nodeType === 1 && vNode.nodeType === 1) {
|
|
85
|
+
// Element
|
|
86
|
+
return (bNode.nodeName.toLowerCase() ===
|
|
87
|
+
vNode.tagName.toLowerCase());
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
updateNode(vNode, bNode) {
|
|
92
|
+
if (vNode.nodeType === 3) {
|
|
93
|
+
// Text
|
|
94
|
+
const newVal = bNode.textContent || "";
|
|
95
|
+
if (vNode.textContent !== newVal) {
|
|
96
|
+
vNode.textContent = newVal;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (vNode.nodeType === 1) {
|
|
100
|
+
// Element
|
|
101
|
+
const vEl = vNode;
|
|
102
|
+
// Attributes
|
|
103
|
+
if (bNode.attributes) {
|
|
104
|
+
// 1. Update/Add
|
|
105
|
+
for (let j = 0; j < bNode.attributes.length; j++) {
|
|
106
|
+
const attr = bNode.attributes[j];
|
|
107
|
+
if (IGNORED_ATTRIBUTES.has(attr.name))
|
|
108
|
+
continue;
|
|
109
|
+
if (vEl.getAttribute(attr.name) !== attr.value) {
|
|
110
|
+
vEl.setAttribute(attr.name, attr.value);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// 2. Remove missing (optional, might want to preserve attributes not in external view?
|
|
114
|
+
// But if external view is authoritative for the allowed schema, maybe we should remove?
|
|
115
|
+
// WYSIWYGEditor implementation didn't explicitly remove attributes.
|
|
116
|
+
// But it synced attributes one way.
|
|
117
|
+
// Let's stick to update/add for now to be safe, or check TODO.
|
|
118
|
+
// TODO doesn't specify attribute removal policy.
|
|
119
|
+
// But standard reconciliation usually implies full sync.
|
|
120
|
+
// I will assume strictly what is in bNode is what we want, BUT we must be careful about "invisible" attributes?
|
|
121
|
+
// SchemaView might filter attributes? No, SchemaView filter is on Nodes.
|
|
122
|
+
// So I'll stick to what WYSIWYGEditor did: only setAttribute.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
createFromExternal(bNode) {
|
|
127
|
+
if (bNode.nodeType === 3) {
|
|
128
|
+
return this.document.createTextNode(bNode.textContent || "");
|
|
129
|
+
}
|
|
130
|
+
else if (bNode.nodeType === 1) {
|
|
131
|
+
const vNew = this.document.createElement(bNode.nodeName.toLowerCase());
|
|
132
|
+
if (bNode.attributes) {
|
|
133
|
+
for (let j = 0; j < bNode.attributes.length; j++) {
|
|
134
|
+
const attrName = bNode.attributes[j].name;
|
|
135
|
+
if (IGNORED_ATTRIBUTES.has(attrName))
|
|
136
|
+
continue;
|
|
137
|
+
vNew.setAttribute(attrName, bNode.attributes[j].value);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const children = bNode.childNodes;
|
|
141
|
+
for (let k = 0; k < children.length; k++) {
|
|
142
|
+
const child = this.createFromExternal(children[k]);
|
|
143
|
+
if (child)
|
|
144
|
+
vNew.appendChild(child);
|
|
145
|
+
}
|
|
146
|
+
return vNew;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Checks if a node is "formatting whitespace" that should be preserved during sync,
|
|
152
|
+
* even if it is missing from the external view.
|
|
153
|
+
*
|
|
154
|
+
* This relies on the Model's classification of text nodes (e.g. `kind: 'whitespace'`).
|
|
155
|
+
*/
|
|
156
|
+
isPreservableWhitespace(node) {
|
|
157
|
+
const model = node.getModel();
|
|
158
|
+
if (model instanceof ModelText) {
|
|
159
|
+
return model.kind === "whitespace";
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
package/dist/xml-api-events.d.ts
CHANGED
|
@@ -11,6 +11,10 @@ export type ChangeEvent = {
|
|
|
11
11
|
type: "structure";
|
|
12
12
|
target: ModelNode;
|
|
13
13
|
transaction?: Transaction;
|
|
14
|
+
addedNodes?: ModelNode[];
|
|
15
|
+
removedNodes?: ModelNode[];
|
|
16
|
+
previousSibling?: ModelNode | null;
|
|
17
|
+
nextSibling?: ModelNode | null;
|
|
14
18
|
} | {
|
|
15
19
|
type: "attribute";
|
|
16
20
|
target: ModelNode;
|
|
@@ -30,14 +34,14 @@ export type EventHandler = (event: ChangeEvent) => void;
|
|
|
30
34
|
/**
|
|
31
35
|
* Internal event emitter for managing listeners.
|
|
32
36
|
*/
|
|
33
|
-
export declare class EventEmitter {
|
|
37
|
+
export declare class EventEmitter<E = ChangeEvent> {
|
|
34
38
|
private listeners;
|
|
35
39
|
/**
|
|
36
40
|
* Registers an event handler.
|
|
37
41
|
* @param handler The callback function.
|
|
38
42
|
* @returns A function to unsubscribe the handler.
|
|
39
43
|
*/
|
|
40
|
-
on(handler:
|
|
41
|
-
off(handler:
|
|
42
|
-
emit(event:
|
|
44
|
+
on(handler: (event: E) => void): () => void;
|
|
45
|
+
off(handler: (event: E) => void): void;
|
|
46
|
+
emit(event: E): void;
|
|
43
47
|
}
|
package/dist/xml-api-events.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.EventEmitter = void 0;
|
|
4
1
|
/**
|
|
5
2
|
* Internal event emitter for managing listeners.
|
|
6
3
|
*/
|
|
7
|
-
class EventEmitter {
|
|
4
|
+
export class EventEmitter {
|
|
8
5
|
constructor() {
|
|
9
6
|
this.listeners = [];
|
|
10
7
|
}
|
|
@@ -26,4 +23,3 @@ class EventEmitter {
|
|
|
26
23
|
}
|
|
27
24
|
}
|
|
28
25
|
}
|
|
29
|
-
exports.EventEmitter = EventEmitter;
|
package/dist/xml-api.d.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
import { Document } from "./dom";
|
|
2
1
|
import type { Grammar } from "./cst/grammar";
|
|
3
2
|
import type { CST } from "./cst/xml-cst";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { Document } from "./dom";
|
|
4
|
+
import { SyncEngine } from "./engine/sync-engine";
|
|
5
|
+
import { ModelElement } from "./model/xml-api-model";
|
|
6
|
+
import { SchemaView, type SchemaViewConfig } from "./view/schema-view";
|
|
7
|
+
import type { EventHandler } from "./xml-api-events";
|
|
6
8
|
/**
|
|
7
9
|
* The primary entry point for the XML API.
|
|
8
|
-
* Orchestrates the synchronization between source code (CST)
|
|
10
|
+
* Orchestrates the synchronization between source code (CST), the logical Model, and Schema Views.
|
|
11
|
+
*
|
|
12
|
+
* This class serves as the central hub for the "Three-Level Reconciliation" architecture:
|
|
13
|
+
* 1. Source <-> CST: Incremental parsing.
|
|
14
|
+
* 2. CST <-> Model: Logical binding and identity preservation.
|
|
15
|
+
* 3. Model <-> View: Schema projection and filtering (via `createView`).
|
|
9
16
|
*/
|
|
10
17
|
export declare class XMLAPI {
|
|
11
|
-
private
|
|
18
|
+
private _engine;
|
|
12
19
|
private document;
|
|
13
20
|
/**
|
|
14
21
|
* Initializes the API with the source XML string.
|
|
@@ -16,32 +23,36 @@ export declare class XMLAPI {
|
|
|
16
23
|
* @param grammar (Optional) Custom grammar definition.
|
|
17
24
|
*/
|
|
18
25
|
constructor(source: string, grammar?: Grammar);
|
|
26
|
+
/** The underlying synchronization engine. */
|
|
27
|
+
get engine(): SyncEngine;
|
|
19
28
|
/** The current source code string. */
|
|
20
29
|
get source(): string;
|
|
21
|
-
/**
|
|
22
|
-
* Alias for `source` to maintain compatibility with existing tests/demos temporarily.
|
|
23
|
-
* @deprecated Use `source` instead.
|
|
24
|
-
*/
|
|
25
|
-
get input(): string;
|
|
26
30
|
/** The authoritative logical model. */
|
|
27
31
|
get model(): ModelElement | null;
|
|
28
32
|
/** The Concrete Syntax Tree (Physical layer). */
|
|
29
33
|
get cst(): CST | null;
|
|
30
34
|
/** The grammar used for parsing. */
|
|
31
35
|
get grammar(): Grammar;
|
|
36
|
+
/**
|
|
37
|
+
* Creates a projected view of the document.
|
|
38
|
+
*
|
|
39
|
+
* A SchemaView allows you to work with a filtered subset of the document (e.g., only XHTML tags)
|
|
40
|
+
* while the underlying system maintains full fidelity of the original source (including comments,
|
|
41
|
+
* custom tags, and formatting) in the background.
|
|
42
|
+
*
|
|
43
|
+
* @param config Configuration for the view, including filter logic.
|
|
44
|
+
* @returns A `SchemaView` instance providing a DOM-like interface for the projected content.
|
|
45
|
+
*/
|
|
46
|
+
createView(config?: SchemaViewConfig): SchemaView;
|
|
32
47
|
/**
|
|
33
48
|
* Updates the source code directly (e.g. from a text editor).
|
|
34
49
|
* Attempts an optimized incremental update, falling back to full re-parse if needed.
|
|
35
50
|
* @param from Start index of the range to replace.
|
|
36
51
|
* @param to End index of the range.
|
|
37
52
|
* @param text The new text to insert.
|
|
53
|
+
* @param meta (Optional) Metadata for the transaction.
|
|
38
54
|
*/
|
|
39
|
-
updateSource(from: number, to: number, text: string): void;
|
|
40
|
-
/**
|
|
41
|
-
* Alias for `updateSource` to maintain compatibility.
|
|
42
|
-
* @deprecated Use `updateSource` instead.
|
|
43
|
-
*/
|
|
44
|
-
updateInput(from: number, to: number, text: string): void;
|
|
55
|
+
updateSource(from: number, to: number, text: string, meta?: Record<string, any>): void;
|
|
45
56
|
/**
|
|
46
57
|
* Returns a DOM-compatible Document object linked to this API.
|
|
47
58
|
* Changes made to the returned Document are automatically reflected in the source code.
|
|
@@ -53,10 +64,13 @@ export declare class XMLAPI {
|
|
|
53
64
|
on(handler: EventHandler): () => void;
|
|
54
65
|
undo(): void;
|
|
55
66
|
redo(): void;
|
|
56
|
-
/** @deprecated Use DOM interface or Engine directly if needed. */
|
|
57
|
-
setAttribute(modelNode: ModelElement, key: string, value: string): void;
|
|
58
|
-
/** @deprecated Use DOM interface or Engine directly if needed. */
|
|
59
|
-
updateText(modelNode: ModelElement, text: string): void;
|
|
60
|
-
/** @deprecated Use DOM interface or Engine directly if needed. */
|
|
61
|
-
replaceNode(target: ModelNode, content: ModelNode): void;
|
|
62
67
|
}
|
|
68
|
+
export { CST } from "./cst/xml-cst";
|
|
69
|
+
export { CDATASection, Comment, Document, Element, Node, NodeList, Text, } from "./dom";
|
|
70
|
+
export { EditorState } from "./engine/editor-state";
|
|
71
|
+
export { type TextPatch, Transaction } from "./engine/transaction";
|
|
72
|
+
export { ModelCDATA, ModelComment, ModelElement, ModelNode, ModelNodeType, ModelText, } from "./model/xml-api-model";
|
|
73
|
+
export { XMLBinder } from "./model/xml-binder";
|
|
74
|
+
export { SchemaView, type SchemaViewConfig } from "./view/schema-view";
|
|
75
|
+
export { type ExternalNode, ViewBinder } from "./view/view-binder";
|
|
76
|
+
export { type ChangeEvent, EventEmitter, type EventHandler, } from "./xml-api-events";
|