@cocreate/text 1.29.2 → 1.30.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/src/updateDom.js CHANGED
@@ -1,204 +1,151 @@
1
- import { sendPosition, _dispatchInputEvent } from './index';
2
- import { getSelection, processSelection, getElementPosition } from '@cocreate/selection';
3
- import { domParser } from '@cocreate/utils';
4
-
5
- export function updateDom({ domTextEditor, value, start, end, html }) {
6
- if (!domTextEditor.htmlString)
7
- domTextEditor.htmlString = html;
8
- if (start < 0 || start > domTextEditor.htmlString.length)
9
- throw new Error('position is out of range');
10
-
11
- let { element, path, position, type } = getElementPosition(domTextEditor.htmlString, start, end);
12
- parseHtml(domTextEditor, html);
1
+ /**
2
+ * updateDom.js
3
+ *
4
+ * An intelligent DOM morphing engine for collaborative editors.
5
+ * This engine uses a "Shadow" Virtual DOM approach. It updates the live DOM
6
+ * without destroying node identities, preserving native text selections,
7
+ * CSS animations, and scroll positions.
8
+ */
13
9
 
14
- let domEl, oldEl, curCaret, newEl;
15
- // console.log('element', element)
16
- // console.log('path', path)
10
+ import { domParser } from '@cocreate/utils';
17
11
 
12
+ /**
13
+ * Updates the live DOM editor safely using a virtual diffing approach.
14
+ * Crucially, it manages the `htmlString` and `vDom` state directly on
15
+ * the domTextEditor element for cross-module reference (e.g., selection mapping).
16
+ *
17
+ * @param {HTMLElement} domTextEditor - The live DOM element acting as the editor.
18
+ * @param {string} newHtml - The incoming HTML string from CRDT/state.
19
+ */
20
+ export function updateDom(domTextEditor, newHtml) {
18
21
  try {
19
- newEl = domTextEditor.newHtml.querySelector(path);
20
- } catch (err) {
21
- console.log('error', err)
22
+ if (!domTextEditor || typeof newHtml !== 'string') return;
23
+
24
+ // 1. Create our "Shadow" Virtual DOM from the new string
25
+ let vDom = domParser(newHtml);
26
+
27
+ // Extract the actual usable node (bypassing wrapper document tags if present)
28
+ let vNode = vDom.tagName === "DOM-PARSER" || vDom.nodeName === "#document"
29
+ ? (vDom.body || vDom)
30
+ : vDom;
31
+
32
+ // 2. State Management: Attach to domTextEditor for cross-module access
33
+ // This is exactly the "Shadow DOM" concept you envisioned!
34
+ // selection.js can now query domTextEditor.vDom without causing UI reflows.
35
+ domTextEditor.htmlString = newHtml;
36
+ domTextEditor.vDom = vNode.cloneNode(true);
37
+
38
+ // 3. Perform the DOM Morphing (Patching)
39
+ // We sync the live editor's children with our new virtual children.
40
+ syncChildren(domTextEditor, vNode);
41
+
42
+ } catch (error) {
43
+ console.error("Error during DOM morphing update:", error);
22
44
  }
45
+ }
23
46
 
24
- if (path && !newEl) {
25
- let index
26
- do {
27
- index = path.lastIndexOf(' >')
28
- if (index != -1)
29
- path = path.slice(0, index)
30
- newEl = domTextEditor.newHtml.querySelector(path);
31
- } while (!newEl && index != -1)
47
+ /**
48
+ * Recursively diffs and patches a live DOM node against a virtual node.
49
+ *
50
+ * @param {Node} liveNode - The node currently on the screen.
51
+ * @param {Node} vNode - The off-screen "shadow" node parsed from the string.
52
+ */
53
+ function syncNode(liveNode, vNode) {
54
+ // 1. Type Mismatch: If nodes are fundamentally different (e.g., DIV changed to SPAN),
55
+ // we must replace the live node entirely.
56
+ if (liveNode.nodeType !== vNode.nodeType || liveNode.nodeName !== vNode.nodeName) {
57
+ liveNode.replaceWith(vNode.cloneNode(true));
58
+ return;
32
59
  }
33
60
 
34
- if (!newEl) {
35
- // console.log("no newEL", path)
36
- newEl = domTextEditor.cloneNode(true);
37
- if (html != undefined)
38
- newEl.innerHTML = html;
39
- else
40
- newEl.innerHTML = domTextEditor.htmlString;
41
- domEl = domTextEditor;
42
- type = 'innerHTML';
43
- } else if (element.tagName == 'HTML') {
44
- // console.log('element = html')
45
- domEl = domTextEditor;
46
- type = 'innerHTML';
47
- } else if (path) {
48
- // console.log("else path", path)
49
- domEl = domTextEditor.querySelector(path);
50
- // if (!domEl || !oldEl){
51
- // let eid = newEl.getAttribute('eid');
52
- // if (!domEl && eid){
53
- // domEl = domTextEditor.querySelector(`[eid='${eid}']`);
54
- // }
55
- // if (!oldEl && eid){
56
- // oldEl = domTextEditor.oldHtml.querySelector(`[eid='${eid}']`);
57
- // }
58
- // }
61
+ // 2. Text Nodes: Update text content if changed.
62
+ // This preserves the exact Text Node identity, preventing caret loss!
63
+ if (liveNode.nodeType === Node.TEXT_NODE) {
64
+ if (liveNode.nodeValue !== vNode.nodeValue) {
65
+ liveNode.nodeValue = vNode.nodeValue;
66
+ }
67
+ return;
59
68
  }
60
69
 
61
- if (!domEl) {
62
- // console.log('no domEl')
63
- let index
64
- do {
65
- index = path.lastIndexOf(' >')
66
- if (index != -1)
67
- path = path.slice(0, index)
68
- domEl = domTextEditor.querySelector(path);
69
- } while (!domEl && index != -1)
70
-
71
- if (domEl) {
72
- newEl = domTextEditor.newHtml.querySelector(path);
73
- }
70
+ // 3. Element Nodes: Sync attributes and children
71
+ if (liveNode.nodeType === Node.ELEMENT_NODE) {
72
+ // ID/Key check (laying ground for your node tracking idea)
73
+ // If elements share an ID or 'eid', we know they are logically the same
74
+ syncAttributes(liveNode, vNode);
75
+ syncChildren(liveNode, vNode);
76
+ }
77
+ }
74
78
 
75
- if (!domEl || !newEl) {
76
- newEl = domTextEditor.cloneNode(true);
77
- if (html != undefined)
78
- newEl.innerHTML = html;
79
- else
80
- newEl.innerHTML = domTextEditor.htmlString;
81
- domEl = domTextEditor;
82
- type = 'innerHTML';
79
+ /**
80
+ * Synchronizes attributes from the virtual node to the live node.
81
+ *
82
+ * @param {HTMLElement} liveNode - The live element.
83
+ * @param {HTMLElement} vNode - The virtual element containing desired attributes.
84
+ */
85
+ function syncAttributes(liveNode, vNode) {
86
+ const liveAttrs = liveNode.attributes;
87
+ const vAttrs = vNode.attributes;
88
+
89
+ // Remove attributes that exist on live DOM but not on virtual DOM
90
+ // Run backward because removing modifies the liveAttrs live collection
91
+ for (let i = liveAttrs.length - 1; i >= 0; i--) {
92
+ const attrName = liveAttrs[i].name;
93
+ if (!vNode.hasAttribute(attrName)) {
94
+ liveNode.removeAttribute(attrName);
83
95
  }
84
96
  }
85
97
 
86
- if (domEl && newEl) {
87
- let activeElement = domEl.ownerDocument.activeElement;
88
- if (activeElement == domEl)
89
- curCaret = getSelection(activeElement);
90
- else if (activeElement && activeElement.tagName == 'BODY')
91
- curCaret = getSelection(domEl);
92
- else
93
- curCaret = getSelection(activeElement);
94
-
95
-
96
- if (!value && type != 'isStartTag' && type != 'textNode') {
97
- type = 'innerHTML';
98
+ // Add or update attributes from the virtual DOM to the live DOM
99
+ for (let i = 0; i < vAttrs.length; i++) {
100
+ const attrName = vAttrs[i].name;
101
+ const vValue = vAttrs[i].value;
102
+
103
+ // Only update if it actually changed, to prevent unnecessary layout thrashing
104
+ if (liveNode.getAttribute(attrName) !== vValue) {
105
+ liveNode.setAttribute(attrName, vValue);
98
106
  }
107
+ }
108
+ }
99
109
 
100
- // console.log('domEl', domEl)
101
- // console.log('newEl', newEl)
102
- if (start != end && type == 'innerHTML') {
103
- domTextEditor.htmlString = html;
104
- if (domEl.tagName != 'HTML' && newEl.parentElement) {
105
- domEl.parentElement.replaceChildren(...newEl.parentElement.childNodes);
106
- } else {
107
- domEl.replaceChildren(...newEl.childNodes);
108
- // console.log('Html tag', domEl)
109
- }
110
-
111
- if (curCaret && curCaret.range) {
112
- curCaret.range.startContainer = domEl;
113
- curCaret.range.endContainer = domEl;
114
- }
115
- } else if (type == 'isStartTag') {
116
- oldEl = domTextEditor.oldHtml.querySelector(path);
117
- if (!oldEl && domEl.tagName == 'HTML')
118
- oldEl = domTextEditor.oldHtml
119
- assignAttributes(newEl, oldEl, domEl);
120
- // console.log('isStartTag', domEl, newEl)
121
-
122
- } else if (type == 'insertAdjacent') {
123
- domEl.insertAdjacentHTML(position, value);
124
- // console.log('insertAdjacent', domEl, value)
125
- } else if (type == 'textNode') {
126
- if (start != end)
127
- domTextEditor.htmlString = html;
128
- domEl.innerHTML = newEl.innerHTML;
129
- // console.log('textnode', domEl.innerHTML, newEl.innerHTML)
130
-
131
- } else if (type == 'innerHTML') {
132
- domEl.replaceChildren(...newEl.childNodes);
133
- // console.log('innerHtml', domEl, newEl)
134
- }
135
- domTextEditor.htmlString = html;
136
-
137
- if (curCaret && start >= 0 && end >= 0) {
138
- if (curCaret.range && curCaret.range.startContainer == domEl) {
139
- if (curCaret.start >= curCaret.range.startOffset) {
140
- let p = processSelection(domEl, value, curCaret.start, curCaret.end, start, end, curCaret.range);
141
- sendPosition(domEl);
142
- _dispatchInputEvent(p.element, p.value, p.start, p.end, p.prev_start, p.prev_end);
143
- }
110
+ /**
111
+ * Diffs and reconciles child nodes between the live and virtual elements.
112
+ *
113
+ * @param {HTMLElement} liveNode - The live parent element.
114
+ * @param {HTMLElement} vNode - The virtual parent element.
115
+ */
116
+ function syncChildren(liveNode, vNode) {
117
+ const liveChildren = Array.from(liveNode.childNodes);
118
+ const vChildren = Array.from(vNode.childNodes);
119
+
120
+ const max = Math.max(liveChildren.length, vChildren.length);
121
+
122
+ for (let i = 0; i < max; i++) {
123
+ const liveChild = liveChildren[i];
124
+ const vChild = vChildren[i];
125
+
126
+ if (!liveChild && vChild) {
127
+ // Virtual DOM has new nodes that live DOM doesn't have yet (Insertion)
128
+
129
+ // Script Tag Handling: If a new script tag is injected, we must recreate it
130
+ // so the browser executes it properly.
131
+ if (vChild.nodeName === 'SCRIPT') {
132
+ let script = document.createElement('script');
133
+ syncAttributes(script, vChild);
134
+ script.innerHTML = vChild.innerHTML;
135
+ liveNode.appendChild(script);
144
136
  } else {
145
- let p = processSelection(domEl, value, curCaret.start, curCaret.end, start, end, curCaret.range);
146
- _dispatchInputEvent(p.element, p.value, p.start, p.end, p.prev_start, p.prev_end);
147
-
148
- }
149
- } else {
150
- _dispatchInputEvent(domTextEditor);
151
- }
152
-
153
- if (['HTML', 'HEAD', 'BODY', 'SCRIPT'].includes(newEl.tagName)) {
154
- let scripts;
155
- if (newEl.tagName == 'SCRIPT') {
156
- scripts = [newEl];
157
- }
158
- else {
159
- scripts = domEl.querySelectorAll('script');
160
- }
161
- for (let script of scripts) {
162
- let newScript = domEl.ownerDocument.createElement('script');
163
- for (let attribute of script.attributes) {
164
- newScript.setAttribute(attribute.name, attribute.value);
165
- }
166
- newScript.innerHTML = script.innerHTML;
167
- script.replaceWith(newScript);
137
+ liveNode.appendChild(vChild.cloneNode(true));
168
138
  }
139
+ }
140
+ else if (liveChild && !vChild) {
141
+ // Live DOM has extra nodes that virtual DOM no longer has (Deletion)
142
+ liveNode.removeChild(liveChild);
143
+ }
144
+ else if (liveChild && vChild) {
145
+ // Both nodes exist, we must deeply synchronize them (Mutation)
146
+ syncNode(liveChild, vChild);
169
147
  }
170
148
  }
171
149
  }
172
150
 
173
- function parseHtml(domTextEditor, html) {
174
- var dom = domParser(html);
175
- if (domTextEditor.newHtml) {
176
- domTextEditor.oldHtml = domTextEditor.newHtml;
177
- } else {
178
- domTextEditor.oldHtml = dom;
179
- }
180
- domTextEditor.newHtml = dom;
181
- }
182
-
183
- function assignAttributes(newEl, oldEl, domEl) {
184
- if (!oldEl) return;
185
- for (let newElAtt of newEl.attributes) {
186
- if (!oldEl.attributes[newElAtt.name] || oldEl.attributes[newElAtt.name].value !== newElAtt.value)
187
- try {
188
- domEl.setAttribute(newElAtt.name, newElAtt.value);
189
- }
190
- catch (err) {
191
- throw new Error("assignAttributes: " + err.message, err.name);
192
- }
193
- }
194
-
195
- if (newEl.attributes.length !== oldEl.attributes.length) {
196
- for (let i = 0, len = oldEl.attributes.length; i < len; i++) {
197
- let oldElAtt = oldEl.attributes[i];
198
- if (!newEl.attributes[oldElAtt.name]) {
199
- domEl.removeAttribute(oldElAtt.name);
200
- i--, len--;
201
- }
202
- }
203
- }
204
- }
151
+ export default updateDom;
package/src/updateText.js CHANGED
@@ -2,154 +2,196 @@ import crdt from "@cocreate/crdt";
2
2
  import { getAttributes } from "@cocreate/utils";
3
3
  import { getStringPosition } from "@cocreate/selection";
4
4
 
5
+ /**
6
+ * Translates a DOM element insertion/movement into string manipulation.
7
+ * Calculates index offsets dynamically to maintain accurate CRDT positions.
8
+ */
5
9
  export function insertAdjacentElement({
6
- domTextEditor,
7
- target,
8
- position,
9
- element,
10
- elementValue
10
+ domTextEditor,
11
+ target,
12
+ position,
13
+ element,
14
+ elementValue
11
15
  }) {
12
- try {
13
- let remove;
14
- if (element && !elementValue) {
15
- remove = getStringPosition({
16
- string: domTextEditor.htmlString,
17
- target: element
18
- });
19
- if (!remove || (!remove.start && !remove.end))
20
- throw new Error("insertAdjacentElement: element not found");
21
-
22
- elementValue = domTextEditor.htmlString.substring(
23
- remove.start,
24
- remove.end
25
- );
26
- }
27
-
28
- let { start } = getStringPosition({
29
- string: domTextEditor.htmlString,
30
- target,
31
- position,
32
- value: elementValue
33
- });
34
- if (remove)
35
- _updateText({
36
- domTextEditor,
37
- start: remove.start,
38
- end: remove.end
39
- });
40
- if (remove && remove.start < start) {
41
- let length = remove.end - remove.start;
42
- _updateText({
43
- domTextEditor,
44
- value: elementValue,
45
- start: start - length
46
- });
47
- } else _updateText({ domTextEditor, value: elementValue, start });
48
- } catch (error) {
49
- console.error(error);
50
- }
16
+ try {
17
+ let remove;
18
+
19
+ // 1. If moving an existing element, locate its current string coordinates
20
+ if (element && !elementValue) {
21
+ remove = getStringPosition({
22
+ string: domTextEditor.htmlString,
23
+ target: element
24
+ });
25
+
26
+ if (!remove || (!remove.start && !remove.end))
27
+ throw new Error("insertAdjacentElement: element not found");
28
+
29
+ elementValue = domTextEditor.htmlString.substring(
30
+ remove.start,
31
+ remove.end
32
+ );
33
+ }
34
+
35
+ // 2. Locate the coordinates for the insertion target
36
+ let { start } = getStringPosition({
37
+ string: domTextEditor.htmlString,
38
+ target,
39
+ position,
40
+ value: elementValue
41
+ });
42
+
43
+ // 3. Execute the move as a Delete + Insert
44
+ if (remove) {
45
+ _updateText({
46
+ domTextEditor,
47
+ start: remove.start,
48
+ end: remove.end
49
+ });
50
+ }
51
+
52
+ // 4. OT Math: Shift the insertion index left if the removed text preceded it
53
+ if (remove && remove.start < start) {
54
+ let length = remove.end - remove.start;
55
+ _updateText({
56
+ domTextEditor,
57
+ value: elementValue,
58
+ start: start - length
59
+ });
60
+ } else {
61
+ _updateText({ domTextEditor, value: elementValue, start });
62
+ }
63
+ } catch (error) {
64
+ console.error("Error in insertAdjacentElement:", error);
65
+ }
51
66
  }
52
67
 
53
68
  export function removeElement({ domTextEditor, target }) {
54
- updateDomText({ domTextEditor, target });
69
+ updateDomText({ domTextEditor, target });
55
70
  }
56
71
 
57
72
  export function setInnerText({ domTextEditor, target, value, start, end }) {
58
- updateDomText({ domTextEditor, target, value, pos: { start, end } });
73
+ updateDomText({ domTextEditor, target, value, pos: { start, end } });
59
74
  }
60
75
 
61
76
  export function setClass({ domTextEditor, target, value }) {
62
- updateDomText({ domTextEditor, target, attribute: "class", value });
77
+ updateDomText({ domTextEditor, target, attribute: "class", value });
63
78
  }
79
+
64
80
  export function removeClass({ domTextEditor, target, value }) {
65
- updateDomText({
66
- domTextEditor,
67
- target,
68
- attribute: "class",
69
- value,
70
- remove: true
71
- });
81
+ updateDomText({
82
+ domTextEditor,
83
+ target,
84
+ attribute: "class",
85
+ value,
86
+ remove: true
87
+ });
72
88
  }
73
89
 
74
90
  export function setStyle({ domTextEditor, target, property, value }) {
75
- updateDomText({
76
- domTextEditor,
77
- target,
78
- attribute: "style",
79
- property,
80
- value
81
- });
91
+ updateDomText({
92
+ domTextEditor,
93
+ target,
94
+ attribute: "style",
95
+ property,
96
+ value
97
+ });
82
98
  }
83
99
 
84
100
  export function removeStyle({ domTextEditor, target, property }) {
85
- updateDomText({
86
- domTextEditor,
87
- target,
88
- attribute: "style",
89
- property,
90
- remove: true
91
- });
101
+ updateDomText({
102
+ domTextEditor,
103
+ target,
104
+ attribute: "style",
105
+ property,
106
+ remove: true
107
+ });
92
108
  }
93
109
 
94
110
  export function setAttribute({ domTextEditor, target, name, value }) {
95
- updateDomText({ domTextEditor, target, attribute: name, value });
111
+ updateDomText({ domTextEditor, target, attribute: name, value });
96
112
  }
97
113
 
98
114
  export function removeAttribute({ domTextEditor, target, name }) {
99
- updateDomText({ domTextEditor, target, attribute: name, remove: "true" });
115
+ updateDomText({ domTextEditor, target, attribute: name, remove: "true" });
100
116
  }
101
117
 
102
118
  export function replaceInnerText({ domTextEditor, target, value }) {
103
- updateDomText({ domTextEditor, target, value });
119
+ updateDomText({ domTextEditor, target, value });
104
120
  }
105
121
 
122
+ /**
123
+ * Resolves DOM targets to string ranges and sends atomic updates.
124
+ */
106
125
  export function updateDomText({
107
- domTextEditor,
108
- target,
109
- position,
110
- element,
111
- elementValue,
112
- attribute,
113
- value,
114
- property,
115
- pos,
116
- remove
126
+ domTextEditor,
127
+ target,
128
+ position,
129
+ element,
130
+ elementValue,
131
+ attribute,
132
+ value,
133
+ property,
134
+ pos,
135
+ remove
117
136
  }) {
118
- let selection = getStringPosition({
119
- string: domTextEditor.htmlString,
120
- target,
121
- attribute,
122
- property,
123
- value,
124
- remove
125
- });
126
- if (!selection) return;
127
- let { start, end, newValue } = selection;
128
- if (pos) {
129
- start += pos.start;
130
- end += pos.end;
131
- }
132
- if (start != end) _updateText({ domTextEditor, start, end });
133
- if ((attribute && remove != "true") || (attribute && value))
134
- _updateText({
135
- domTextEditor,
136
- value: ` ${attribute}="${newValue}"`,
137
- start
138
- });
139
- else if (value) _updateText({ domTextEditor, value, start });
137
+ let selection = getStringPosition({
138
+ string: domTextEditor.htmlString,
139
+ target,
140
+ attribute,
141
+ property,
142
+ value,
143
+ remove
144
+ });
145
+
146
+ if (!selection) return;
147
+
148
+ let { start, end, newValue } = selection;
149
+
150
+ if (pos) {
151
+ start += pos.start;
152
+ end += pos.end;
153
+ }
154
+
155
+ // FIXED: Combined the isolated delete and insert into a single, atomic Replace operation
156
+ // This prevents the "ghost state" network glitch described in the code review.
157
+ let replaceValue = "";
158
+ if ((attribute && remove != "true") || (attribute && value)) {
159
+ replaceValue = ` ${attribute}="${newValue}"`;
160
+ } else if (value) {
161
+ replaceValue = value;
162
+ }
163
+
164
+ if (start != end || replaceValue) {
165
+ _updateText({
166
+ domTextEditor,
167
+ start,
168
+ end,
169
+ value: replaceValue
170
+ });
171
+ }
140
172
  }
141
173
 
174
+ /**
175
+ * Prepares the payload and communicates with the underlying CRDT engine.
176
+ */
142
177
  function _updateText({ domTextEditor, value, start, end }) {
143
- if (domTextEditor.tagName == "HTML")
144
- domTextEditor = domTextEditor.ownerDocument.defaultView.frameElement;
145
- const { array, object, key, isCrud } = getAttributes(domTextEditor);
146
- crdt.updateText({
147
- array,
148
- object,
149
- key,
150
- value,
151
- start,
152
- length: end - start,
153
- crud: isCrud
154
- });
155
- }
178
+ // Cross-frame sync logic: Bubble up to the iframe wrapper if targeting the root document
179
+ if (domTextEditor.tagName == "HTML") {
180
+ domTextEditor = domTextEditor.ownerDocument.defaultView.frameElement;
181
+ }
182
+
183
+ const { array, object, key, isCrud } = getAttributes(domTextEditor);
184
+
185
+ // Ensure we don't send negative or invalid lengths to the engine
186
+ let length = (end !== undefined && start !== undefined) ? end - start : 0;
187
+
188
+ crdt.updateText({
189
+ array,
190
+ object,
191
+ key,
192
+ value,
193
+ start,
194
+ length,
195
+ crud: isCrud
196
+ });
197
+ }