@b9g/revise 0.1.1 → 0.1.3

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/contentarea.js CHANGED
@@ -1,497 +1,530 @@
1
1
  /// <reference types="contentarea.d.ts" />
2
2
  import { Edit } from './edit.js';
3
- import './subseq.js';
4
3
 
5
4
  /// <reference lib="dom" />
6
- class ContentEvent extends CustomEvent {
7
- constructor(typeArg, eventInit) {
8
- // Maybe we should do some runtime eventInit validation.
9
- super(typeArg, { bubbles: true, ...eventInit });
10
- }
11
- }
12
- /** Whether the node’s info is up to date. */
13
- const IS_VALID = 1 << 0;
14
- /** Whether the node is responsible for the newline before it. */
15
- const PREPENDS_NEWLINE = 1 << 1;
16
- /** Whether the node is responsible for the newline after it. */
17
- const APPENDS_NEWLINE = 1 << 2;
18
- class NodeInfo {
19
- constructor(offset) {
20
- this.flags = 0;
21
- this.size = 0;
22
- this.offset = offset;
23
- }
24
- }
25
- /********************************************/
26
- /*** ContentAreaElement symbol properties ***/
27
- /********************************************/
28
- const $slot = Symbol.for("revise$slot");
29
- const $cache = Symbol.for("revise$cache");
30
- const $value = Symbol.for("revise$value");
31
- const $observer = Symbol.for("revise$observer");
32
- const $selectionStart = Symbol.for("revise$selectionStart");
33
- const $onselectionchange = Symbol.for("revise$onselectionchange");
34
- const css = `:host {
35
- display: contents;
36
- white-space: pre-wrap;
37
- white-space: break-spaces;
38
- overflow-wrap: break-word;
39
- }`;
5
+ /***************************************************/
6
+ /*** ContentAreaElement private property symbols ***/
7
+ /***************************************************/
8
+ const _cache = Symbol.for("ContentArea._cache");
9
+ const _observer = Symbol.for("ContentArea._observer");
10
+ const _onselectionchange = Symbol.for("ContentArea._onselectionchange");
11
+ const _value = Symbol.for("ContentArea._value");
12
+ const _selectionRange = Symbol.for("ContentArea._selectionRange");
13
+ const _staleValue = Symbol.for("ContentArea._staleValue");
14
+ const _staleSelectionRange = Symbol.for("ContentArea._slateSelectionRange");
15
+ const _compositionBuffer = Symbol.for("ContentArea._compositionBuffer");
16
+ const _compositionStartValue = Symbol.for("ContentArea._compositionStartValue");
17
+ const _compositionSelectionRange = Symbol.for("ContentArea._compositionSelectionRange");
40
18
  class ContentAreaElement extends HTMLElement {
41
19
  constructor() {
42
20
  super();
43
- {
44
- // Creating the shadow DOM.
45
- const slot = document.createElement("slot");
46
- const shadow = this.attachShadow({ mode: "closed" });
47
- const style = document.createElement("style");
48
- style.textContent = css;
49
- shadow.appendChild(style);
50
- slot.contentEditable = this.contentEditable;
51
- shadow.appendChild(slot);
52
- this[$slot] = slot;
53
- }
54
- this[$cache] = new Map();
55
- this[$value] = "";
56
- this[$observer] = new MutationObserver((records) => {
57
- validate(this, null, records);
21
+ this[_cache] = new Map();
22
+ this[_observer] = new MutationObserver((records) => {
23
+ if (this[_compositionBuffer]) {
24
+ // Buffer mutations during composition but still process them to keep cache in sync
25
+ this[_compositionBuffer].push(...records);
26
+ }
27
+ validate(this, records);
58
28
  });
59
- this[$selectionStart] = 0;
60
- this[$onselectionchange] = () => {
61
- validate(this);
62
- this[$selectionStart] = getSelectionRange(this, this[$cache]).selectionStart;
29
+ this[_onselectionchange] = () => {
30
+ this[_selectionRange] = getSelectionRange(this);
63
31
  };
32
+ this[_value] = "";
33
+ this[_selectionRange] = { start: 0, end: 0, direction: "none" };
34
+ this[_staleValue] = undefined;
35
+ this[_staleSelectionRange] = undefined;
36
+ this[_compositionBuffer] = undefined;
37
+ this[_compositionStartValue] = undefined;
38
+ this[_compositionSelectionRange] = undefined;
64
39
  }
65
40
  /******************************/
66
41
  /*** Custom Element methods ***/
67
42
  /******************************/
68
- static get observedAttributes() {
69
- return ["contenteditable"];
70
- }
71
43
  connectedCallback() {
72
- this[$observer].observe(this, {
44
+ this[_observer].observe(this, {
73
45
  subtree: true,
74
46
  childList: true,
75
47
  characterData: true,
48
+ characterDataOldValue: true,
76
49
  attributes: true,
50
+ attributeOldValue: true,
77
51
  attributeFilter: [
78
52
  "data-content",
79
- "data-contentbefore",
80
- "data-contentafter",
53
+ // TODO: implement these attributes
54
+ //"data-contentbefore",
55
+ //"data-contentafter",
81
56
  ],
82
57
  });
83
- this[$value] = getValue(this, this[$cache], "");
84
- this[$selectionStart] = getSelectionRange(this, this[$cache]).selectionStart;
85
- document.addEventListener("selectionchange", this[$onselectionchange],
58
+ document.addEventListener("selectionchange", this[_onselectionchange],
86
59
  // We use capture in an attempt to run before other event listeners.
87
60
  true);
61
+ validate(this);
62
+ this[_onselectionchange]();
63
+ // Composition event handling
64
+ let processCompositionTimeout;
65
+ this.addEventListener("compositionstart", () => {
66
+ clearTimeout(processCompositionTimeout); // Cancel pending commit
67
+ if (processCompositionTimeout == null) {
68
+ this[_compositionBuffer] = [];
69
+ this[_compositionStartValue] = this[_value];
70
+ this[_compositionSelectionRange] = { ...this[_selectionRange] };
71
+ }
72
+ processCompositionTimeout = undefined;
73
+ });
74
+ const processComposition = () => {
75
+ if (this[_compositionBuffer] &&
76
+ this[_compositionBuffer].length > 0 &&
77
+ this[_compositionStartValue] !== undefined &&
78
+ this[_compositionSelectionRange] !== undefined) {
79
+ const edit = Edit.diff(this[_compositionStartValue], this[_value], this[_compositionSelectionRange].start);
80
+ const ev = new ContentEvent("contentchange", {
81
+ detail: { edit, source: null, mutations: this[_compositionBuffer] }
82
+ });
83
+ this.dispatchEvent(ev);
84
+ this[_staleValue] = undefined;
85
+ this[_staleSelectionRange] = undefined;
86
+ }
87
+ this[_compositionBuffer] = undefined;
88
+ this[_compositionStartValue] = undefined;
89
+ this[_compositionSelectionRange] = undefined;
90
+ processCompositionTimeout = undefined;
91
+ };
92
+ this.addEventListener("compositionend", () => {
93
+ clearTimeout(processCompositionTimeout);
94
+ processCompositionTimeout = setTimeout(processComposition);
95
+ });
96
+ this.addEventListener("blur", () => {
97
+ clearTimeout(processCompositionTimeout);
98
+ processComposition();
99
+ });
100
+ this.addEventListener("keydown", (e) => {
101
+ if (e.key === "Escape" && this[_compositionBuffer]) {
102
+ clearTimeout(processCompositionTimeout);
103
+ processComposition();
104
+ }
105
+ });
88
106
  }
89
107
  disconnectedCallback() {
90
- this[$cache].clear();
91
- this[$value] = "";
92
- this[$observer].disconnect();
93
- // JSDOM-based environments like Jest will make the global document null
94
- // before calling the disconnectedCallback for some reason.
108
+ this[_cache].clear();
109
+ this[_value] = "";
110
+ this[_observer].disconnect();
111
+ // JSDOM-based environments like Jest sometimes make the global document
112
+ // null before calling the disconnectedCallback for some reason.
95
113
  if (document) {
96
- document.removeEventListener("selectionchange", this[$onselectionchange], true);
114
+ document.removeEventListener("selectionchange", this[_onselectionchange], true);
97
115
  }
98
116
  }
99
- attributeChangedCallback(name) {
100
- switch (name) {
101
- case "contenteditable": {
102
- const slot = this[$slot];
103
- // We have to set slot.contentEditable to this.contentEditable because
104
- // Chrome has trouble selecting elements across shadow DOM boundaries.
105
- // See https://bugs.chromium.org/p/chromium/issues/detail?id=1175930
106
- // Chrome has additional issues with using the host element as a
107
- // contenteditable element but this normalizes some of the behavior.
108
- slot.contentEditable = this.contentEditable;
109
- break;
110
- }
111
- }
112
- }
113
- /***********************/
114
- /*** Content methods ***/
115
- /***********************/
116
117
  get value() {
117
118
  validate(this);
118
- return this[$value];
119
+ return this[_staleValue] == null ? this[_value] : this[_staleValue];
119
120
  }
120
121
  get selectionStart() {
121
122
  validate(this);
122
- return getSelectionRange(this, this[$cache]).selectionStart;
123
+ const range = this[_staleSelectionRange] || this[_selectionRange];
124
+ return range.start;
123
125
  }
124
- set selectionStart(selectionStart) {
126
+ set selectionStart(start) {
125
127
  validate(this);
126
- const selectionRange = getSelectionRange(this, this[$cache]);
127
- setSelectionRange(this, this[$cache], selectionStart, selectionRange.selectionEnd, selectionRange.selectionDirection);
128
+ const { end, direction } = getSelectionRange(this);
129
+ setSelectionRange(this, { start, end, direction });
128
130
  }
129
131
  get selectionEnd() {
130
132
  validate(this);
131
- return getSelectionRange(this, this[$cache]).selectionEnd;
133
+ const range = this[_staleSelectionRange] || this[_selectionRange];
134
+ return range.end;
132
135
  }
133
- set selectionEnd(selectionEnd) {
136
+ set selectionEnd(end) {
134
137
  validate(this);
135
- const selectionRange = getSelectionRange(this, this[$cache]);
136
- setSelectionRange(this, this[$cache], selectionRange.selectionStart, selectionEnd, selectionRange.selectionDirection);
138
+ const { start, direction } = getSelectionRange(this);
139
+ setSelectionRange(this, { start, end, direction });
137
140
  }
138
141
  get selectionDirection() {
139
142
  validate(this);
140
- return getSelectionRange(this, this[$cache]).selectionDirection;
143
+ const range = this[_staleSelectionRange] || this[_selectionRange];
144
+ return range.direction;
141
145
  }
142
- set selectionDirection(selectionDirection) {
146
+ set selectionDirection(direction) {
143
147
  validate(this);
144
- const selectionRange = getSelectionRange(this, this[$cache]);
145
- setSelectionRange(this, this[$cache], selectionRange.selectionStart, selectionRange.selectionEnd, selectionDirection);
148
+ const { start, end } = getSelectionRange(this);
149
+ setSelectionRange(this, { start, end, direction });
146
150
  }
147
151
  getSelectionRange() {
148
152
  validate(this);
149
- return getSelectionRange(this, this[$cache]);
153
+ const range = this[_staleSelectionRange] || this[_selectionRange];
154
+ return { ...range };
150
155
  }
151
- setSelectionRange(selectionStart, selectionEnd, selectionDirection = "none") {
156
+ setSelectionRange(start, end, direction = "none") {
152
157
  validate(this);
153
- setSelectionRange(this, this[$cache], selectionStart, selectionEnd, selectionDirection);
158
+ setSelectionRange(this, { start, end, direction });
154
159
  }
155
160
  indexAt(node, offset) {
156
161
  validate(this);
157
- const cache = this[$cache];
158
- return indexAt(this, cache, node, offset);
162
+ return indexAt(this, node, offset);
159
163
  }
160
164
  nodeOffsetAt(index) {
161
165
  validate(this);
162
- const cache = this[$cache];
163
- return nodeOffsetAt(this, cache, index);
166
+ return nodeOffsetAt(this, index);
164
167
  }
165
168
  source(source) {
166
- return validate(this, source, this[$observer].takeRecords());
169
+ return validate(this, this[_observer].takeRecords(), source);
167
170
  }
168
171
  }
169
- // TODO: custom newlines?
170
- const NEWLINE = "\n";
171
- // TODO: Try using getComputedStyle
172
- const BLOCKLIKE_ELEMENTS = new Set([
173
- "ADDRESS",
174
- "ARTICLE",
175
- "ASIDE",
176
- "BLOCKQUOTE",
177
- "CAPTION",
178
- "DETAILS",
179
- "DIALOG",
180
- "DD",
181
- "DIV",
182
- "DL",
183
- "DT",
184
- "FIELDSET",
185
- "FIGCAPTION",
186
- "FIGURE",
187
- "FOOTER",
188
- "FORM",
189
- "H1",
190
- "H2",
191
- "H3",
192
- "H4",
193
- "H5",
194
- "H6",
195
- "HEADER",
196
- "HGROUP",
197
- "HR",
198
- "LI",
199
- "MAIN",
200
- "NAV",
201
- "OL",
202
- "P",
203
- "PRE",
204
- "SECTION",
205
- "TABLE",
206
- "TR",
207
- "UL",
208
- ]);
209
- function validate(root, source = null, records) {
210
- const cache = root[$cache];
211
- // We use the existence of records to determine whether
212
- // contentchange events should be fired synchronously.
213
- let delay = false;
214
- if (records === undefined) {
215
- delay = true;
216
- records = root[$observer].takeRecords();
217
- }
218
- if (!invalidate(root, cache, records)) {
219
- return false;
172
+ const PreventDefaultSource = Symbol.for("ContentArea.PreventDefaultSource");
173
+ class ContentEvent extends CustomEvent {
174
+ constructor(typeArg, eventInit) {
175
+ // Maybe we should do some runtime eventInit validation.
176
+ super(typeArg, { bubbles: true, ...eventInit });
220
177
  }
221
- const oldValue = root[$value];
222
- const oldSelectionStart = root[$selectionStart];
223
- const value = (root[$value] = getValue(root, cache, oldValue));
224
- const selectionStart = getSelectionRange(root, cache).selectionStart;
225
- const hint = Math.min(oldSelectionStart, selectionStart);
226
- // TODO: This call is expensive. If we have getValue return an edit instead
227
- // of a string, we might be able to save a lot in CPU time.
228
- const edit = Edit.diff(oldValue, value, hint);
229
- const ev = new ContentEvent("contentchange", { detail: { edit, source } });
230
- if (delay) {
231
- Promise.resolve().then(() => root.dispatchEvent(ev));
178
+ preventDefault() {
179
+ if (this.defaultPrevented) {
180
+ return;
181
+ }
182
+ super.preventDefault();
183
+ const area = this.target;
184
+ area[_staleValue] = area[_value];
185
+ area[_staleSelectionRange] = area[_selectionRange];
186
+ const records = this.detail.mutations;
187
+ for (let i = records.length - 1; i >= 0; i--) {
188
+ const record = records[i];
189
+ switch (record.type) {
190
+ case 'childList': {
191
+ for (let j = 0; j < record.addedNodes.length; j++) {
192
+ const node = record.addedNodes[j];
193
+ if (node.parentNode) {
194
+ node.parentNode.removeChild(node);
195
+ }
196
+ }
197
+ for (let j = 0; j < record.removedNodes.length; j++) {
198
+ const node = record.removedNodes[j];
199
+ record.target.insertBefore(node, record.nextSibling);
200
+ }
201
+ break;
202
+ }
203
+ case 'characterData': {
204
+ if (record.oldValue !== null) {
205
+ record.target.data = record.oldValue;
206
+ }
207
+ break;
208
+ }
209
+ case 'attributes': {
210
+ if (record.oldValue === null) {
211
+ record.target.removeAttribute(record.attributeName);
212
+ }
213
+ else {
214
+ record.target.setAttribute(record.attributeName, record.oldValue);
215
+ }
216
+ break;
217
+ }
218
+ }
219
+ }
220
+ const records1 = (area)[_observer].takeRecords();
221
+ validate(area, records1, PreventDefaultSource);
232
222
  }
233
- else {
234
- root.dispatchEvent(ev);
223
+ }
224
+ /*** NodeInfo.flags ***/
225
+ /** Whether the node is old. */
226
+ const IS_OLD = 1 << 0;
227
+ /** Whether the node’s info is still up-to-date. */
228
+ const IS_VALID = 1 << 1;
229
+ /** Whether the node has a styling of type display: block or similar. */
230
+ const IS_BLOCKLIKE = 1 << 2;
231
+ /** Whether the node is responsible for the newline before it. */
232
+ const PREPENDS_NEWLINE = 1 << 3;
233
+ /** Whether the node is responsible for the newline after it. */
234
+ const APPENDS_NEWLINE = 1 << 4;
235
+ /** Data associated with the child nodes of a ContentAreaElement. */
236
+ class NodeInfo {
237
+ constructor(offset) {
238
+ this.f = 0;
239
+ this.offset = offset;
240
+ this.length = 0;
241
+ }
242
+ }
243
+ /**
244
+ * Should be called before calling any ContentAreaElement methods.
245
+ *
246
+ * This function ensures the cache is up to date.
247
+ *
248
+ * Dispatches "contentchange" events.
249
+ *
250
+ * @returns whether a change was detected
251
+ */
252
+ function validate(_this, records = _this[_observer].takeRecords(), source = null) {
253
+ if (typeof _this !== "object" || _this[_cache] == null) {
254
+ throw new TypeError("this is not a ContentAreaElement");
255
+ }
256
+ else if (!document.contains(_this)) {
257
+ throw new Error("ContentArea cannot be read before it is inserted into the DOM");
258
+ }
259
+ if (!invalidate(_this, records)) {
260
+ return false;
261
+ }
262
+ const oldValue = _this[_value];
263
+ const edit = diff(_this, oldValue, _this[_selectionRange].start);
264
+ _this[_value] = edit.apply(oldValue);
265
+ _this[_selectionRange] = getSelectionRange(_this);
266
+ // Don't dispatch events during composition or preventDefault operations
267
+ if (source !== PreventDefaultSource && !_this[_compositionBuffer]) {
268
+ const ev = new ContentEvent("contentchange", { detail: { edit, source, mutations: records } });
269
+ _this.dispatchEvent(ev);
270
+ _this[_staleValue] = undefined;
271
+ _this[_staleSelectionRange] = undefined;
235
272
  }
236
273
  return true;
237
274
  }
238
- function invalidate(root, cache, records) {
239
- if (!cache.get(root)) {
275
+ function invalidate(_this, records) {
276
+ const cache = _this[_cache];
277
+ if (!cache.get(_this)) {
278
+ // The root ContentAreaElement will not be deleted from the cache until the
279
+ // element is removed from the DOM, so this is the first time the
280
+ // ContentAreaElement is being validated.
240
281
  return true;
241
282
  }
242
283
  let invalid = false;
243
284
  for (let i = 0; i < records.length; i++) {
244
285
  const record = records[i];
245
- // We make sure all added and removed nodes are cleared from the cache just
246
- // in case of any MutationObserver weirdness.
286
+ // We make sure all added and removed nodes and their children are deleted
287
+ // from the cache in case of any weirdness where nodes have been moved.
247
288
  for (let j = 0; j < record.addedNodes.length; j++) {
248
- clear(record.addedNodes[j], cache);
289
+ const addedNode = record.addedNodes[j];
290
+ clear(addedNode, cache);
249
291
  }
250
292
  for (let j = 0; j < record.removedNodes.length; j++) {
251
293
  clear(record.removedNodes[j], cache);
252
294
  }
253
- // TODO: invalidate data-content nodes correctly.
254
295
  let node = record.target;
255
- if (node === root) {
296
+ if (node === _this) {
256
297
  invalid = true;
257
298
  continue;
258
299
  }
259
- else if (!root.contains(node)) {
300
+ else if (!_this.contains(node)) {
260
301
  clear(node, cache);
261
302
  continue;
262
303
  }
263
- for (; node !== root; node = node.parentNode) {
304
+ for (; node !== _this; node = node.parentNode) {
264
305
  if (!cache.has(node)) {
265
306
  break;
266
307
  }
267
- const info = cache.get(node);
268
- if (info) {
269
- info.flags &= ~IS_VALID;
308
+ const nodeInfo = cache.get(node);
309
+ if (nodeInfo) {
310
+ nodeInfo.f &= ~IS_VALID;
270
311
  }
271
312
  invalid = true;
272
313
  }
273
314
  }
274
315
  if (invalid) {
275
- const info = cache.get(root);
276
- info.flags &= ~IS_VALID;
316
+ const nodeInfo = cache.get(_this);
317
+ nodeInfo.f &= ~IS_VALID;
277
318
  }
278
319
  return invalid;
279
320
  }
280
- // This is the single most complicated function in the library!!!
281
321
  /**
282
- * This function both returns the content of the root (always a content-area
283
- * element, and populates the cache with info about the contents of nodes for
284
- * future reads.
285
- * @param root - The root element (usually a content-area element)
286
- * @param cache - The nodeInfo cache associated with the root
287
- * @param oldContent - The previous content of the root.
322
+ * For a given parent node and node info cache, clear the info for the node and
323
+ * all of its child nodes from the cache.
288
324
  */
289
- function getValue(root, cache, oldContent) {
290
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
291
- // TODO: It might be faster to construct and return a edit rather than
292
- // concatenating a giant string.
293
- let content = "";
294
- // Because the content variable is a heavily concatenated string and likely
295
- // inferred by most engines as requiring a “rope-like” data structure for
296
- // performance, reading from the end of the string to detect newlines can be
297
- // a bottleneck. Therefore, we store that info in this boolean instead.
298
- /** A boolean which indicates whether content currently ends in a newline. */
299
- let hasNewline = false;
300
- /** The start of the current node relative to its parent. */
301
- let offset = 0;
302
- // The current index into oldContent. We use this to copy unchanged text over
303
- // and track deletions.
304
- // If there are nodes which have cached start and length information, we get
305
- // their contents from oldContent string using oldIndex so we don’t have to
306
- // read it from the DOM.
307
- let oldIndex = 0;
308
- // The current index into oldContent of the current node’s parent. We can get
309
- // the expected start of a node if none of the nodes before it were deleted
310
- // by finding the difference between oldIndex and relativeOldIndex. We can
311
- // compare this difference to the cached start information to detect
312
- // deletions.
313
- let relativeOldIndex = 0;
314
- let info = cache.get(root);
315
- if (info === undefined) {
316
- info = new NodeInfo(offset);
317
- cache.set(root, info);
318
- }
319
- // A stack to save some variables as we walk up and down the tree.
325
+ function clear(parent, cache) {
326
+ const walker = document.createTreeWalker(parent, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
327
+ for (let node = parent; node !== null; node = walker.nextNode()) {
328
+ cache.delete(node);
329
+ }
330
+ }
331
+ // TODO: custom newlines?
332
+ const NEWLINE = "\n";
333
+ // THIS IS THE MOST COMPLICATED FUNCTION IN THE LIBRARY!
334
+ /**
335
+ * This function both returns an edit which represents changes to the
336
+ * ContentAreaElement, and populates the cache with info about nodes for future
337
+ * reads.
338
+ */
339
+ function diff(_this, oldValue, oldSelectionStart) {
340
+ const walker = document.createTreeWalker(_this, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
341
+ const cache = _this[_cache];
320
342
  const stack = [];
321
- for (let node = root, descending = true; node !== null;) {
322
- // A loop to descend into the DOM tree.
323
- while (descending && !(info.flags & IS_VALID)) {
324
- if (node.nodeType === Node.TEXT_NODE ||
325
- // We treat elements with data-content attributes as opaque.
326
- node.hasAttribute("data-content")) {
327
- break;
328
- }
329
- // If the current node is a block-like element, and the previous
330
- // node/elements did not end with a newline, then the current element
331
- // would introduce a linebreak before its contents.
332
- // We check that the offset is non-zero so that the first child of a
333
- // parent does not introduce a newline before it.
334
- const prependsNewline = !!offset && !hasNewline && isBlocklikeElement(node);
335
- if (prependsNewline) {
336
- content += NEWLINE;
337
- hasNewline = true;
338
- offset += NEWLINE.length;
339
- info.offset += NEWLINE.length;
340
- info.flags |= PREPENDS_NEWLINE;
341
- }
342
- else {
343
- info.flags &= ~PREPENDS_NEWLINE;
344
- }
345
- if ((node = walker.firstChild())) {
346
- descending = true;
347
- }
348
- else {
349
- node = walker.currentNode;
350
- break;
351
- }
352
- stack.push({ relativeOldIndex, info });
353
- relativeOldIndex = oldIndex;
354
- offset = 0;
355
- // getNodeInfo
356
- info = cache.get(node);
357
- if (info === undefined) {
358
- info = new NodeInfo(offset);
359
- cache.set(node, info);
343
+ let nodeInfo;
344
+ let value = "";
345
+ for (let node = _this, descending = true,
346
+ /** the current offset relative to the parent */
347
+ offset = 0,
348
+ /** the index into the old string */
349
+ oldIndex = 0,
350
+ /** the index into the old string of the parent */
351
+ oldIndexRelative = 0,
352
+ /** Whether or not the value being built currently ends with a newline */
353
+ hasNewline = false;; node = walker.currentNode) {
354
+ if (descending) {
355
+ // PRE-ORDER LOGIC
356
+ nodeInfo = cache.get(node);
357
+ if (nodeInfo === undefined) {
358
+ cache.set(node, (nodeInfo = new NodeInfo(offset)));
359
+ if (isBlocklikeElement(node)) {
360
+ nodeInfo.f |= IS_BLOCKLIKE;
361
+ }
360
362
  }
361
363
  else {
362
- if (info.offset > 0) {
364
+ const expectedOffset = oldIndex - oldIndexRelative;
365
+ const deleteLength = nodeInfo.offset - expectedOffset;
366
+ if (deleteLength < 0) {
367
+ // this should never happen
368
+ throw new Error("cache offset error");
369
+ }
370
+ else if (deleteLength > 0) {
363
371
  // deletion detected
364
- oldIndex += info.offset;
372
+ oldIndex += deleteLength;
365
373
  }
366
- info.offset = offset;
367
- }
368
- }
369
- if (info.flags & IS_VALID) {
370
- // The node has been seen before.
371
- // Reading from oldContent because length hasn’t been invalidated.
372
- const length = info.size;
373
- if (oldIndex + info.size > oldContent.length) {
374
- // This should never happen
375
- throw new Error("String length mismatch");
374
+ nodeInfo.offset = offset;
376
375
  }
377
- const prependsNewline = !!offset && !hasNewline && isBlocklikeElement(node);
378
- if (prependsNewline) {
379
- content += NEWLINE;
376
+ if (offset && !hasNewline && nodeInfo.f & IS_BLOCKLIKE) {
377
+ // Block-like elements prepend a newline when they appear after text or
378
+ // inline elements.
380
379
  hasNewline = true;
381
380
  offset += NEWLINE.length;
382
- info.offset += NEWLINE.length;
383
- info.flags |= PREPENDS_NEWLINE;
381
+ value += NEWLINE;
382
+ if (nodeInfo.f & PREPENDS_NEWLINE) {
383
+ oldIndex += NEWLINE.length;
384
+ }
385
+ nodeInfo.f |= PREPENDS_NEWLINE;
384
386
  }
385
387
  else {
386
- info.flags &= ~PREPENDS_NEWLINE;
388
+ if (nodeInfo.f & PREPENDS_NEWLINE) {
389
+ // deletion detected
390
+ oldIndex += NEWLINE.length;
391
+ }
392
+ nodeInfo.f &= ~PREPENDS_NEWLINE;
387
393
  }
388
- const oldContent1 = oldContent.slice(oldIndex, oldIndex + length);
389
- content += oldContent1;
390
- offset += length;
391
- oldIndex += length;
392
- if (oldContent1.length) {
393
- hasNewline = oldContent1.endsWith(NEWLINE);
394
+ descending = false;
395
+ if (nodeInfo.f & IS_VALID) {
396
+ // The node and its children are unchanged, so we read from the length.
397
+ if (nodeInfo.length) {
398
+ value += oldValue.slice(oldIndex, oldIndex + nodeInfo.length);
399
+ oldIndex += nodeInfo.length;
400
+ offset += nodeInfo.length;
401
+ hasNewline =
402
+ oldValue.slice(Math.max(0, oldIndex - NEWLINE.length), oldIndex) ===
403
+ NEWLINE;
404
+ }
394
405
  }
395
- }
396
- else {
397
- // The node hasn’t been seen before.
398
- let appendsNewline = false;
399
- if (node.nodeType === Node.TEXT_NODE) {
400
- const content1 = node.data;
401
- content += content1;
402
- offset += content1.length;
403
- if (content1.length) {
404
- hasNewline = content1.endsWith(NEWLINE);
406
+ else if (node.nodeType === Node.TEXT_NODE) {
407
+ const text = node.data;
408
+ if (text.length) {
409
+ value += text;
410
+ offset += text.length;
411
+ hasNewline = text.endsWith(NEWLINE);
412
+ }
413
+ if (nodeInfo.f & IS_OLD) {
414
+ oldIndex += nodeInfo.length;
405
415
  }
406
416
  }
407
417
  else if (node.hasAttribute("data-content")) {
408
- const content1 = node.getAttribute("data-content") || "";
409
- content += content1;
410
- offset += content1.length;
411
- if (content1.length) {
412
- hasNewline = content1.endsWith(NEWLINE);
418
+ const text = node.getAttribute("data-content") || "";
419
+ if (text.length) {
420
+ value += text;
421
+ offset += text.length;
422
+ hasNewline = text.endsWith(NEWLINE);
423
+ }
424
+ if (nodeInfo.f & IS_OLD) {
425
+ oldIndex += nodeInfo.length;
413
426
  }
414
- }
415
- else if (!hasNewline && isBlocklikeElement(node)) {
416
- content += NEWLINE;
417
- offset += NEWLINE.length;
418
- hasNewline = true;
419
- appendsNewline = true;
420
427
  }
421
428
  else if (node.nodeName === "BR") {
422
- content += NEWLINE;
429
+ value += NEWLINE;
423
430
  offset += NEWLINE.length;
424
431
  hasNewline = true;
425
- }
426
- info.size = offset - info.offset;
427
- info.flags |= IS_VALID;
428
- info.flags = appendsNewline
429
- ? info.flags | APPENDS_NEWLINE
430
- : info.flags & ~APPENDS_NEWLINE;
431
- }
432
- if ((node = walker.nextSibling())) {
433
- descending = true;
434
- // getNodeInfo
435
- info = cache.get(node);
436
- if (info === undefined) {
437
- info = new NodeInfo(offset);
438
- cache.set(node, info);
432
+ if (nodeInfo.f & IS_OLD) {
433
+ oldIndex += nodeInfo.length;
434
+ }
439
435
  }
440
436
  else {
441
- const oldOffset = oldIndex - relativeOldIndex;
442
- if (info.offset > oldOffset) {
443
- // deletion detected
444
- oldIndex += info.offset - oldOffset;
445
- }
446
- else if (info.offset < oldOffset) {
447
- // This should never happen
448
- throw new Error("Offset is before old offset");
437
+ descending = !!walker.firstChild();
438
+ if (descending) {
439
+ stack.push({ nodeInfo, oldIndexRelative });
440
+ offset = 0;
441
+ oldIndexRelative = oldIndex;
449
442
  }
450
- info.offset = offset;
451
443
  }
452
444
  }
453
445
  else {
454
- descending = false;
455
- if (walker.currentNode !== root) {
456
- if (!stack.length) {
457
- // This should never happen
458
- throw new Error("Stack is empty");
446
+ if (!stack.length) {
447
+ // This should never happen.
448
+ throw new Error("Stack is empty");
449
+ }
450
+ // If the child node prepends a newline, add to offset to increase the
451
+ // length of the parent node.
452
+ if (nodeInfo.f & PREPENDS_NEWLINE) {
453
+ offset += NEWLINE.length;
454
+ }
455
+ ({ nodeInfo, oldIndexRelative } = stack.pop());
456
+ offset = nodeInfo.offset + offset;
457
+ }
458
+ if (!descending) {
459
+ // POST-ORDER LOGIC
460
+ if (!(nodeInfo.f & IS_VALID)) {
461
+ // TODO: Figure out if we should always recalculate APPENDS_NEWLINE???
462
+ if (!hasNewline && nodeInfo.f & IS_BLOCKLIKE) {
463
+ value += NEWLINE;
464
+ offset += NEWLINE.length;
465
+ hasNewline = true;
466
+ nodeInfo.f |= APPENDS_NEWLINE;
467
+ }
468
+ else {
469
+ nodeInfo.f &= ~APPENDS_NEWLINE;
470
+ }
471
+ nodeInfo.length = offset - nodeInfo.offset;
472
+ nodeInfo.f |= IS_VALID;
473
+ }
474
+ nodeInfo.f |= IS_OLD;
475
+ descending = !!walker.nextSibling();
476
+ if (!descending) {
477
+ if (walker.currentNode === _this) {
478
+ break;
459
479
  }
460
- ({ relativeOldIndex, info } = stack.pop());
461
- offset = info.offset + offset;
462
- node = walker.parentNode();
480
+ walker.parentNode();
463
481
  }
464
482
  }
483
+ if (oldIndex > oldValue.length) {
484
+ // This should never happen.
485
+ throw new Error("cache length error");
486
+ }
465
487
  }
466
- return content;
488
+ const selectionStart = getSelectionRange(_this).start;
489
+ // TODO: Doing a diff over the entirety of both oldValue and value is a
490
+ // performance bottleneck. Figure out how to reduce the search for changed
491
+ // values.
492
+ return Edit.diff(oldValue, value, Math.min(oldSelectionStart, selectionStart));
467
493
  }
494
+ const BLOCKLIKE_DISPLAYS = new Set([
495
+ "block",
496
+ "flex",
497
+ "grid",
498
+ "flow-root",
499
+ "list-item",
500
+ "table",
501
+ "table-row-group",
502
+ "table-header-group",
503
+ "table-footer-group",
504
+ "table-row",
505
+ "table-caption",
506
+ ]);
468
507
  function isBlocklikeElement(node) {
469
- return (node.nodeType === Node.ELEMENT_NODE && BLOCKLIKE_ELEMENTS.has(node.nodeName));
470
- }
471
- /**
472
- * For a given parent node and node info cache, clear info for the node and all
473
- * child nodes from the cache.
474
- */
475
- function clear(parent, cache) {
476
- const walker = document.createTreeWalker(parent, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
477
- for (let node = parent; node !== null; node = walker.nextNode()) {
478
- cache.delete(node);
479
- }
508
+ return (node.nodeType === Node.ELEMENT_NODE &&
509
+ BLOCKLIKE_DISPLAYS.has(
510
+ // handle two-value display syntax like `display: block flex`
511
+ getComputedStyle(node).display.split(" ")[0]));
480
512
  }
513
+ /***********************/
514
+ /*** Selection Logic ***/
515
+ /***********************/
481
516
  /**
482
517
  * Finds the string index of a node and offset pair provided by a browser API
483
- * like window.getSelection() for a given root and cache.
518
+ * like document.getSelection() for a given root and cache.
484
519
  */
485
- function indexAt(root, cache, node, offset) {
486
- if (node == null || !root.contains(node)) {
520
+ function indexAt(_this, node, offset) {
521
+ const cache = _this[_cache];
522
+ if (node == null || !_this.contains(node)) {
487
523
  return -1;
488
524
  }
489
525
  if (!cache.has(node)) {
490
- // If the node is not found in the cache but is contained in the root,
491
- // then it is probably the child of an element with a data-content
492
- // attribute.
493
- // TODO: Maybe a non-zero offset should put the index at the end of
494
- // the data-content node.
526
+ // If the node is not found in the cache but is contained in the root, then
527
+ // it is the child of an element with a data-content attribute.
495
528
  offset = 0;
496
529
  while (!cache.has(node)) {
497
530
  node = node.parentNode;
@@ -499,8 +532,8 @@ function indexAt(root, cache, node, offset) {
499
532
  }
500
533
  let index;
501
534
  if (node.nodeType === Node.TEXT_NODE) {
502
- const info = cache.get(node);
503
- index = offset + info.offset;
535
+ const nodeInfo = cache.get(node);
536
+ index = offset + nodeInfo.offset;
504
537
  node = node.parentNode;
505
538
  }
506
539
  else {
@@ -508,11 +541,11 @@ function indexAt(root, cache, node, offset) {
508
541
  index = 0;
509
542
  }
510
543
  else if (offset >= node.childNodes.length) {
511
- const info = cache.get(node);
512
- index = info.size;
513
- if (info.flags & APPENDS_NEWLINE) {
514
- index -= NEWLINE.length;
515
- }
544
+ const nodeInfo = cache.get(node);
545
+ index =
546
+ nodeInfo.f & APPENDS_NEWLINE
547
+ ? nodeInfo.length - NEWLINE.length
548
+ : nodeInfo.length;
516
549
  }
517
550
  else {
518
551
  let child = node.childNodes[offset];
@@ -524,17 +557,20 @@ function indexAt(root, cache, node, offset) {
524
557
  }
525
558
  else {
526
559
  node = child;
527
- const info = cache.get(node);
560
+ const nodeInfo = cache.get(node);
528
561
  // If the offset references an element which prepends a newline
529
562
  // ("hello<div>world</div>"), we have to start from -1 because the
530
563
  // element’s info.offset will not account for the newline.
531
- index = info.flags & PREPENDS_NEWLINE ? -1 : 0;
564
+ index = nodeInfo.f & PREPENDS_NEWLINE ? -1 : 0;
532
565
  }
533
566
  }
534
567
  }
535
- for (; node !== root; node = node.parentNode) {
536
- const info = cache.get(node);
537
- index += info.offset;
568
+ for (; node !== _this; node = node.parentNode) {
569
+ const nodeInfo = cache.get(node);
570
+ index += nodeInfo.offset;
571
+ if (nodeInfo.f & PREPENDS_NEWLINE) {
572
+ index += NEWLINE.length;
573
+ }
538
574
  }
539
575
  return index;
540
576
  }
@@ -542,47 +578,40 @@ function indexAt(root, cache, node, offset) {
542
578
  * Finds the node and offset pair to use with browser APIs like
543
579
  * selection.collapse() from a given string index.
544
580
  */
545
- function nodeOffsetAt(root, cache, index) {
546
- const [node, offset] = findNodeOffset(root, cache, index);
581
+ function nodeOffsetAt(_this, index) {
582
+ if (index < 0) {
583
+ return [null, 0];
584
+ }
585
+ const [node, offset] = findNodeOffset(_this, index);
547
586
  if (node && node.nodeName === "BR") {
548
- // Different browsers can have trouble when calling `selection.collapse()`
587
+ // Some browsers seem to have trouble when calling `selection.collapse()`
549
588
  // with a BR element, so we try to avoid returning them from this function.
550
589
  return nodeOffsetFromChild(node);
551
590
  }
552
591
  return [node, offset];
553
592
  }
554
- function findNodeOffset(root, cache, index) {
555
- if (index < 0) {
556
- return [null, 0];
557
- }
558
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
559
- for (let node = root; node !== null;) {
560
- const info = cache.get(node);
561
- if (info == null) {
593
+ // TODO: Can this function be inlined?
594
+ function findNodeOffset(_this, index) {
595
+ const cache = _this[_cache];
596
+ const walker = document.createTreeWalker(_this, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
597
+ for (let node = _this; node !== null;) {
598
+ const nodeInfo = cache.get(node);
599
+ if (nodeInfo == null) {
562
600
  return nodeOffsetFromChild(node, index > 0);
563
601
  }
564
- else if (info.flags & PREPENDS_NEWLINE) {
565
- index -= NEWLINE.length;
566
- }
567
- if (index < 0) {
568
- // This branch should only run when an element prepends an newline
569
- const previousSibling = walker.previousSibling();
570
- if (!previousSibling) {
571
- // This should never happen
572
- throw new Error("Previous sibling missing");
573
- }
574
- return [previousSibling, getNodeLength(previousSibling)];
602
+ if (nodeInfo.f & PREPENDS_NEWLINE) {
603
+ index -= 1;
575
604
  }
576
- else if (index === info.size && node.nodeType === Node.TEXT_NODE) {
605
+ if (index === nodeInfo.length && node.nodeType === Node.TEXT_NODE) {
577
606
  return [node, node.data.length];
578
607
  }
579
- else if (index >= info.size) {
580
- index -= info.size;
608
+ else if (index >= nodeInfo.length) {
609
+ index -= nodeInfo.length;
581
610
  const nextSibling = walker.nextSibling();
582
611
  if (nextSibling === null) {
583
612
  // This branch seems necessary mainly when working with data-content
584
613
  // nodes.
585
- if (node === root) {
614
+ if (node === _this) {
586
615
  return [node, getNodeLength(node)];
587
616
  }
588
617
  return nodeOffsetFromChild(walker.currentNode, true);
@@ -624,42 +653,41 @@ function nodeOffsetFromChild(node, after = false) {
624
653
  }
625
654
  return [parentNode, offset];
626
655
  }
627
- function getSelectionRange(root, cache) {
628
- const selection = window.getSelection();
656
+ function getSelectionRange(_this) {
657
+ const selection = document.getSelection();
629
658
  if (!selection) {
630
- return { selectionStart: 0, selectionEnd: 0, selectionDirection: "none" };
659
+ return { start: 0, end: 0, direction: "none" };
631
660
  }
632
661
  const { focusNode, focusOffset, anchorNode, anchorOffset, isCollapsed, } = selection;
633
- const focus = Math.max(0, indexAt(root, cache, focusNode, focusOffset));
662
+ const focus = Math.max(0, indexAt(_this, focusNode, focusOffset));
634
663
  const anchor = isCollapsed
635
664
  ? focus
636
- : Math.max(0, indexAt(root, cache, anchorNode, anchorOffset));
665
+ : Math.max(0, indexAt(_this, anchorNode, anchorOffset));
637
666
  return {
638
- selectionStart: Math.min(focus, anchor),
639
- selectionEnd: Math.max(focus, anchor),
640
- selectionDirection: focus < anchor ? "backward" : focus > anchor ? "forward" : "none",
667
+ start: Math.min(focus, anchor),
668
+ end: Math.max(focus, anchor),
669
+ direction: focus < anchor ? "backward" : focus > anchor ? "forward" : "none",
641
670
  };
642
671
  }
643
- function setSelectionRange(root, cache, selectionStart, selectionEnd, selectionDirection) {
644
- const selection = window.getSelection();
672
+ function setSelectionRange(_this, { start, end, direction }) {
673
+ const selection = document.getSelection();
645
674
  if (!selection) {
646
675
  return;
647
676
  }
648
- selectionStart = Math.max(0, selectionStart || 0);
649
- selectionEnd = Math.max(0, selectionEnd || 0);
650
- if (selectionEnd < selectionStart) {
651
- selectionStart = selectionEnd;
677
+ start = Math.max(0, start || 0);
678
+ end = Math.max(0, end || 0);
679
+ if (end < start) {
680
+ start = end;
652
681
  }
653
- const [focusIndex, anchorIndex] = selectionDirection === "backward"
654
- ? [selectionStart, selectionEnd]
655
- : [selectionEnd, selectionStart];
656
- if (focusIndex === anchorIndex) {
657
- const [node, offset] = nodeOffsetAt(root, cache, focusIndex);
682
+ // Focus is the side of the selection where the pointer is released.
683
+ const [focus, anchor] = direction === "backward" ? [start, end] : [end, start];
684
+ if (focus === anchor) {
685
+ const [node, offset] = nodeOffsetAt(_this, focus);
658
686
  selection.collapse(node, offset);
659
687
  }
660
688
  else {
661
- const [anchorNode, anchorOffset] = nodeOffsetAt(root, cache, anchorIndex);
662
- const [focusNode, focusOffset] = nodeOffsetAt(root, cache, focusIndex);
689
+ const [anchorNode, anchorOffset] = nodeOffsetAt(_this, anchor);
690
+ const [focusNode, focusOffset] = nodeOffsetAt(_this, focus);
663
691
  if (anchorNode === null && focusNode === null) {
664
692
  selection.collapse(null);
665
693
  }
@@ -670,7 +698,7 @@ function setSelectionRange(root, cache, selectionStart, selectionEnd, selectionD
670
698
  selection.collapse(anchorNode, anchorOffset);
671
699
  }
672
700
  else {
673
- // This is not a method in IE. We don’t care.
701
+ // NOTE: This method is not implemented in IE.
674
702
  selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset);
675
703
  }
676
704
  }