@b9g/revise 0.1.0 → 0.1.2

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