@stesura/react-wordgard 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE.txt +21 -0
  3. package/README.md +450 -2
  4. package/THIRD_PARTY_LICENSES.txt +93 -0
  5. package/dist/ReactWordgard.d.ts +13 -0
  6. package/dist/ReactWordgard.js +96 -0
  7. package/dist/changes.d.ts +14 -0
  8. package/dist/changes.js +163 -0
  9. package/dist/components/ChildNodeViews.d.ts +8 -0
  10. package/dist/components/ChildNodeViews.js +38 -0
  11. package/dist/components/DecorationRoot.d.ts +7 -0
  12. package/dist/components/DecorationRoot.js +28 -0
  13. package/dist/components/ErrorBoundary.d.ts +18 -0
  14. package/dist/components/ErrorBoundary.js +16 -0
  15. package/dist/components/SeparatorHackView.d.ts +2 -0
  16. package/dist/components/SeparatorHackView.js +5 -0
  17. package/dist/components/ShapeView.d.ts +12 -0
  18. package/dist/components/ShapeView.js +26 -0
  19. package/dist/components/TextNodeView.d.ts +5 -0
  20. package/dist/components/TextNodeView.js +5 -0
  21. package/dist/components/TrailingHackView.d.ts +2 -0
  22. package/dist/components/TrailingHackView.js +5 -0
  23. package/dist/components/WidgetView.d.ts +10 -0
  24. package/dist/components/WidgetView.js +35 -0
  25. package/dist/components/WordgardDoc.d.ts +4 -0
  26. package/dist/components/WordgardDoc.js +34 -0
  27. package/dist/components/WordgardEditor.d.ts +7 -0
  28. package/dist/components/WordgardEditor.js +13 -0
  29. package/dist/components/WrapperView.d.ts +12 -0
  30. package/dist/components/WrapperView.js +26 -0
  31. package/dist/components/marks/MarkView.d.ts +11 -0
  32. package/dist/components/marks/MarkView.js +13 -0
  33. package/dist/components/marks/ReactMarkView.d.ts +10 -0
  34. package/dist/components/marks/ReactMarkView.js +32 -0
  35. package/dist/components/nodes/DefaultNodeView.d.ts +16 -0
  36. package/dist/components/nodes/DefaultNodeView.js +116 -0
  37. package/dist/components/nodes/DocNodeView.d.ts +7 -0
  38. package/dist/components/nodes/DocNodeView.js +6 -0
  39. package/dist/composition.d.ts +23 -0
  40. package/dist/composition.js +97 -0
  41. package/dist/contexts/DecorationContext.d.ts +15 -0
  42. package/dist/contexts/DecorationContext.js +38 -0
  43. package/dist/contexts/DecorationRootContext.d.ts +6 -0
  44. package/dist/contexts/DecorationRootContext.js +6 -0
  45. package/dist/contexts/EditorContext.d.ts +26 -0
  46. package/dist/contexts/EditorContext.js +87 -0
  47. package/dist/contexts/RenderStoreContext.d.ts +15 -0
  48. package/dist/contexts/RenderStoreContext.js +43 -0
  49. package/dist/contexts/editorEvents.d.ts +12 -0
  50. package/dist/contexts/editorEvents.js +95 -0
  51. package/dist/decorations/DecoIterator.d.ts +43 -0
  52. package/dist/decorations/DecoIterator.js +462 -0
  53. package/dist/decorations/ReactMarkType.d.ts +30 -0
  54. package/dist/decorations/ReactMarkType.js +48 -0
  55. package/dist/decorations/ReactShapeType.d.ts +49 -0
  56. package/dist/decorations/ReactShapeType.js +204 -0
  57. package/dist/decorations/ReactWidgetType.d.ts +19 -0
  58. package/dist/decorations/ReactWidgetType.js +69 -0
  59. package/dist/decorations/ReactWrapperType.d.ts +22 -0
  60. package/dist/decorations/ReactWrapperType.js +44 -0
  61. package/dist/decorations/iterDeco.d.ts +82 -0
  62. package/dist/decorations/iterDeco.js +409 -0
  63. package/dist/helpers/attributes.d.ts +2 -0
  64. package/dist/helpers/attributes.js +11 -0
  65. package/dist/helpers/index.d.ts +2 -0
  66. package/dist/helpers/index.js +2 -0
  67. package/dist/helpers/props.d.ts +8 -0
  68. package/dist/helpers/props.js +248 -0
  69. package/dist/helpers/refs.d.ts +6 -0
  70. package/dist/helpers/refs.js +20 -0
  71. package/dist/hooks/useEditorEffect.d.ts +3 -0
  72. package/dist/hooks/useEditorEffect.js +24 -0
  73. package/dist/hooks/useEditorEventCallback.d.ts +2 -0
  74. package/dist/hooks/useEditorEventCallback.js +13 -0
  75. package/dist/hooks/useEditorEventListener.d.ts +3 -0
  76. package/dist/hooks/useEditorEventListener.js +26 -0
  77. package/dist/hooks/useEditorLayoutEffect.d.ts +6 -0
  78. package/dist/hooks/useEditorLayoutEffect.js +38 -0
  79. package/dist/hooks/useEditorState.d.ts +1 -0
  80. package/dist/hooks/useEditorState.js +10 -0
  81. package/dist/hooks/useEditorStateSelector.d.ts +2 -0
  82. package/dist/hooks/useEditorStateSelector.js +6 -0
  83. package/dist/hooks/useIgnoreMutation.d.ts +2 -0
  84. package/dist/hooks/useIgnoreMutation.js +18 -0
  85. package/dist/hooks/useIsNodeSelected.d.ts +1 -0
  86. package/dist/hooks/useIsNodeSelected.js +6 -0
  87. package/dist/hooks/useMergedDOMRefs.d.ts +2 -0
  88. package/dist/hooks/useMergedDOMRefs.js +17 -0
  89. package/dist/hooks/useNodePos.d.ts +1 -0
  90. package/dist/hooks/useNodePos.js +8 -0
  91. package/dist/hooks/useSelectNode.d.ts +2 -0
  92. package/dist/hooks/useSelectNode.js +24 -0
  93. package/dist/hooks/useStopEvent.d.ts +2 -0
  94. package/dist/hooks/useStopEvent.js +20 -0
  95. package/dist/hooks/useWordgard.d.ts +1 -0
  96. package/dist/hooks/useWordgard.js +4 -0
  97. package/dist/index.d.ts +20 -0
  98. package/dist/index.js +20 -0
  99. package/dist/reactKeys.d.ts +39 -0
  100. package/dist/reactKeys.js +188 -0
  101. package/dist/tile.d.ts +32 -0
  102. package/dist/tile.js +1324 -0
  103. package/dist/viewRoots.d.ts +2 -0
  104. package/dist/viewRoots.js +8 -0
  105. package/dist/wordgardInternals.d.ts +211 -0
  106. package/dist/wordgardInternals.js +85 -0
  107. package/package.json +119 -4
  108. package/skills/react-wordgard/SKILL.md +397 -0
  109. package/skills/react-wordgard/references/wordgard.md +189 -0
package/dist/tile.js ADDED
@@ -0,0 +1,1324 @@
1
+ // Takes the role of Wordgard's own DocTile (src/editor/tile.ts in its repo):
2
+ // React draws the document and the tiles are built over React's DOM, as
3
+ // react-prosemirror's view descriptions are built over the DOM its components
4
+ // render. Native widgets aren't React's: their DOM is rendered, reused,
5
+ // connected and placed here, following Wordgard's ContentUpdate;
6
+ // `setUneditable` is copied from it. React widgets are drawn by React, and
7
+ // connected here. React commits into a root of its own, or, for an editor
8
+ // WordgardEditor hosts, a portal in the app's tree (WordgardDoc).
9
+ import { createElement } from "react";
10
+ import { flushSync } from "react-dom";
11
+ import { createRoot } from "react-dom/client";
12
+ import { Elt, Leaf, } from "wordgard/doc";
13
+ import { Widget } from "wordgard/editor";
14
+ import { attrsToRecord } from "./helpers/index.js";
15
+ import { ErrorBoundary } from "./components/ErrorBoundary.js";
16
+ import { DocNodeView } from "./components/nodes/DocNodeView.js";
17
+ import { stopsEvent } from "./contexts/DecorationContext.js";
18
+ import { EditorContext, editorOf } from "./contexts/EditorContext.js";
19
+ import { RenderStoreContext, publishCells, } from "./contexts/RenderStoreContext.js";
20
+ import { viewRoots } from "./viewRoots.js";
21
+ import { contentOf, isReactShape, shapeOfRoot, sortRecords } from "./decorations/ReactShapeType.js";
22
+ import { attributedWidget, isReactWidget, widgetRoots } from "./decorations/ReactWidgetType.js";
23
+ import { isReactWrapper } from "./decorations/ReactWrapperType.js";
24
+ import { markOfTile, markTiles } from "./decorations/ReactMarkType.js";
25
+ import { DecoIterator, getDecoSet } from "./decorations/DecoIterator.js";
26
+ import { iterDeco, sameNodeView, uneditable, } from "./decorations/iterDeco.js";
27
+ import { pointMapper, redrawn } from "./changes.js";
28
+ import { browserElt, compositionView } from "./composition.js";
29
+ import { addFreshKeys, childViewKeys, mapKeys } from "./reactKeys.js";
30
+ import { DocTile, Reused, TileFlag, getTileClasses, } from "./wordgardInternals.js";
31
+ // One React tree and render store per content element. Wordgard's tests also
32
+ // render into elements of their own, without an editor.
33
+ // `commit` renders an element there (inside flushSync), `portal` is set for a
34
+ // hosted editor, with `reached` telling whether an element got committed and
35
+ // `revert` handing React back the last one committed, `last` is what was last
36
+ // rendered (for the portal to mount).
37
+ // `rendered` records what React committed into text nodes and wrapper
38
+ // elements, `layouts` each plot's content as last laid out, and `composition`
39
+ // the last render's composition.
40
+ const roots = new WeakMap();
41
+ const rootOf = (dom) => {
42
+ const found = roots.get(dom);
43
+ if (!found)
44
+ throw new Error("No React root for this element");
45
+ return found;
46
+ };
47
+ // The layout each node tile was built for, so a node React skipped keeps its
48
+ // tiles, and the tile built for each node view.
49
+ const tileViews = new WeakMap();
50
+ const viewTiles = new WeakMap();
51
+ // For tests: the layout a node's tile was built for.
52
+ export const nodeViewOf = (tile) => tileViews.get(tile);
53
+ // Plots already checked: they never change.
54
+ const renderable = new WeakSet();
55
+ // Throws for what React can't render yet, rather than drawing it wrong.
56
+ const checkRenderable = (plot) => {
57
+ if (renderable.has(plot))
58
+ return;
59
+ for (const node of plot.content) {
60
+ // A checked plot's shape was checked with it.
61
+ if (node.is(Leaf.Text) || (node.isPlot && renderable.has(node)))
62
+ continue;
63
+ const shape = node.isPlot
64
+ ? node.type.shape.create(node.tag.param)
65
+ : node.type.shape.create(node.param);
66
+ checkShape(node.name, shape);
67
+ if (node.isPlot && shape.hasContent)
68
+ checkRenderable(node);
69
+ }
70
+ renderable.add(plot);
71
+ };
72
+ // React can't draw an empty text node, nor namespaced elements from a tag name.
73
+ const checkShape = (name, shape) => {
74
+ if (/^(svg|math):/.test(shape.tagName))
75
+ throw new Error(`react-wordgard can't render the shape of ${name} yet`);
76
+ for (const child of shape.children) {
77
+ if (child === "")
78
+ throw new Error(`react-wordgard can't render the shape of ${name} yet`);
79
+ if (child instanceof Elt)
80
+ checkShape(name, child);
81
+ }
82
+ };
83
+ // DOM the tile builder owns: widgets, which React draws nothing for.
84
+ const widgetDOM = new WeakSet();
85
+ // Puts the widget DOM in `order` where it goes, leaving React's nodes where
86
+ // React put them: their order is right, and moving a connected node blurs it
87
+ // and fires its custom element callbacks. Returns the widget DOM left over.
88
+ const placeWidgets = (dom, order) => {
89
+ const wanted = new Set(order);
90
+ let at = dom.firstChild;
91
+ for (const node of order) {
92
+ while (at && !wanted.has(at))
93
+ at = at.nextSibling;
94
+ if (at == node)
95
+ at = node.nextSibling;
96
+ else if (widgetDOM.has(node))
97
+ dom.insertBefore(node, at);
98
+ // React's node is further on: the widgets before it come later in `order`.
99
+ else
100
+ at = node.nextSibling;
101
+ }
102
+ return [...dom.childNodes].filter((child) => !wanted.has(child));
103
+ };
104
+ // Elements that held widget DOM after the last build.
105
+ const holdsWidgets = new WeakSet();
106
+ const noChrome = new Set();
107
+ // Hands out the children React drew in an element, in order, past widget DOM
108
+ // and a React shape's `chrome` beside its content, and collects the order all
109
+ // its children must have.
110
+ const childCursor = (dom, chrome = noChrome) => {
111
+ let last = null;
112
+ let widgets = false;
113
+ const order = [];
114
+ const after = () => (last ? last.nextSibling : dom.firstChild);
115
+ return {
116
+ // Whether `skip` may be used: with widget DOM among the children, which
117
+ // React doesn't draw, the cursor has to walk them to find what goes.
118
+ canSkip: !holdsWidgets.has(dom) && !chrome.size,
119
+ // Takes a node React left where it was, without reading the DOM.
120
+ skip: (node) => {
121
+ last = node;
122
+ order.push(node);
123
+ },
124
+ next: () => {
125
+ let child = after();
126
+ for (; child && (widgetDOM.has(child) || chrome.has(child)); child = child.nextSibling) {
127
+ if (widgetDOM.has(child)) {
128
+ widgets = true;
129
+ }
130
+ else {
131
+ last = child;
132
+ order.push(child);
133
+ }
134
+ }
135
+ if (!child)
136
+ throw new Error("React's DOM is missing a node");
137
+ last = child;
138
+ order.push(child);
139
+ return child;
140
+ },
141
+ place: (node) => {
142
+ widgets = true;
143
+ order.push(node);
144
+ },
145
+ // Checks React drew no more, puts the widget DOM in place and returns what
146
+ // the element no longer holds.
147
+ done: () => {
148
+ for (let child = after(); child; child = child.nextSibling) {
149
+ if (chrome.has(child)) {
150
+ order.push(child);
151
+ continue;
152
+ }
153
+ if (!widgetDOM.has(child))
154
+ throw new Error("React's DOM has extra nodes");
155
+ widgets = true;
156
+ }
157
+ if (!widgets) {
158
+ holdsWidgets.delete(dom);
159
+ return [];
160
+ }
161
+ holdsWidgets.add(dom);
162
+ return placeWidgets(dom, order);
163
+ },
164
+ };
165
+ };
166
+ const elementOf = (dom, what) => {
167
+ if (!(dom instanceof Element))
168
+ throw new Error(`Expected an element for ${what}`);
169
+ return dom;
170
+ };
171
+ const widgetPool = (old) => ({ old, byPos: null });
172
+ const widgetsAt = (pool, context, pos) => {
173
+ if (!pool.byPos) {
174
+ const byPos = new Map();
175
+ const { old } = pool;
176
+ if (old && context.changes) {
177
+ const map = pointMapper(context.changes);
178
+ const collect = (tile, start) => {
179
+ let pos = start;
180
+ for (const child of tile.children) {
181
+ const { dom } = child;
182
+ if (widgetDOM.has(dom) ||
183
+ (dom instanceof Element && widgetRoots.has(dom) && !child.tag)) {
184
+ for (const at of map(pos)) {
185
+ const found = byPos.get(at);
186
+ if (found)
187
+ found.push(child);
188
+ else
189
+ byPos.set(at, [child]);
190
+ }
191
+ }
192
+ else if (!child.tag && !child.isNodeInner) {
193
+ collect(child, pos);
194
+ }
195
+ pos += child.length;
196
+ }
197
+ };
198
+ collect(old, old.posAtStart);
199
+ }
200
+ pool.byPos = byPos;
201
+ }
202
+ return pool.byPos.get(pos) ?? [];
203
+ };
204
+ // The first widget tile among `tiles`, not used yet, that `match` accepts.
205
+ const findWidget = (tiles, context, match) => {
206
+ const { WidgetTile } = getTileClasses();
207
+ for (const tile of tiles)
208
+ if (tile instanceof WidgetTile && !context.taken.has(tile.dom) && match(tile))
209
+ return tile;
210
+ return null;
211
+ };
212
+ // Records a widget tile's DOM as placed at the cursor, to connect when `fresh`.
213
+ const placeWidget = (tile, cursor, context, fresh) => {
214
+ widgetDOM.add(tile.dom);
215
+ context.taken.add(tile.dom);
216
+ cursor.place(tile.dom);
217
+ if (fresh)
218
+ context.toConnect.push(tile);
219
+ return synced(tile);
220
+ };
221
+ // Wordgard's setUneditable. It wraps a text node in a <span> its sync then
222
+ // takes the text back out of (docs/wordgard-upstream-bugs.md), so text is left
223
+ // as it is here.
224
+ const setUneditable = (dom) => {
225
+ if (dom instanceof HTMLElement &&
226
+ dom.contentEditable == "inherit" &&
227
+ !/^(br|hr|img|input|wbr)$/i.test(dom.nodeName))
228
+ dom.contentEditable = "false";
229
+ };
230
+ const pointSide = (side) => side < 0 ? TileFlag.PointBefore : side > 0 ? TileFlag.PointAfter : 0;
231
+ // A React widget's tile, over the root element its component forwarded its ref
232
+ // to. The tile there before is kept whole where `keep` says Wordgard would keep
233
+ // it; otherwise it's disconnected and the new one connected, over the same DOM
234
+ // when React re-rendered the component in place.
235
+ const reactWidgetTile = (dom, context, keep, create) => {
236
+ if (!(dom instanceof Element) || !widgetRoots.has(dom))
237
+ throw new Error("A React widget's component must forward its ref to its root element");
238
+ const { WidgetTile } = getTileClasses();
239
+ const old = dom.wgTile;
240
+ context.taken.add(dom);
241
+ if (old instanceof WidgetTile && keep(old)) {
242
+ context.reused.set(old, Reused.Full);
243
+ return old;
244
+ }
245
+ if (old)
246
+ context.reused.set(old, Reused.DOM);
247
+ const tile = create(dom);
248
+ context.toConnect.push(tile);
249
+ return synced(tile);
250
+ };
251
+ // Mirrors what Wordgard's ContentUpdate builds for the same content, from
252
+ // position `start`, over the DOM React committed for it, and puts widget DOM,
253
+ // which React doesn't draw, in place. `kept` when React kept the element the
254
+ // views are drawn in, in an update: its children are then as React left them.
255
+ const addChildTiles = (parent, plot, views, cursor, context, pool, start, kept) => {
256
+ const { EltTile, TextTile, WidgetTile } = getTileClasses();
257
+ let pos = start;
258
+ const add = (tile) => {
259
+ parent.addChild(tile);
260
+ pos += tile.length;
261
+ };
262
+ // Only text and wrappers record their keys: a list of blocks needs none.
263
+ let keyed = null;
264
+ const keyAt = (i) => (keyed ??= childViewKeys(views))[i]?.key ?? "";
265
+ for (let i = 0; i < views.length; i++) {
266
+ const view = views[i];
267
+ if (!view)
268
+ continue;
269
+ if (view.type == "composition" && !context.composition?.node)
270
+ continue;
271
+ if (view.type == "widget") {
272
+ // TilePointer.matchingWidget: an equal point widget on the same side,
273
+ // unchanged in the DOM, is kept whole. A React widget's old tile is the
274
+ // one over the element React kept, if its old place maps here: not
275
+ // inside a node moved here.
276
+ const { widget } = view;
277
+ const side = pointSide(view.side);
278
+ const matching = (tile) => !tile.tag &&
279
+ !tile.isNodeInner &&
280
+ (tile.flags & (TileFlag.PointBefore | TileFlag.PointAfter)) == side &&
281
+ !context.dirty.has(tile.dom) &&
282
+ tile.widget.eq(widget);
283
+ if (isReactWidget(widget)) {
284
+ const keep = (tile) => matching(tile) && widgetsAt(pool, context, pos).includes(tile);
285
+ const tile = reactWidgetTile(cursor.next(), context, keep, (dom) => {
286
+ if (!widget.type.editable)
287
+ setUneditable(dom);
288
+ return new WidgetTile(widget, null, TileFlag.Point | side, dom);
289
+ });
290
+ add(tile);
291
+ continue;
292
+ }
293
+ const old = findWidget(widgetsAt(pool, context, pos), context, matching);
294
+ if (old) {
295
+ context.reused.set(old, Reused.Full);
296
+ add(placeWidget(old, cursor, context, false));
297
+ continue;
298
+ }
299
+ const dom = widget.render(context.wg);
300
+ if (!widget.type.editable)
301
+ setUneditable(dom);
302
+ const tile = new WidgetTile(widget, null, TileFlag.Point | side, dom);
303
+ add(placeWidget(tile, cursor, context, true));
304
+ continue;
305
+ }
306
+ if (view.type == "node" && !(view.shape instanceof Elt)) {
307
+ // A node drawn as a widget (buildNodeShape): kept whole where React would
308
+ // skip it, else drawn with an equal widget's DOM and connected anew.
309
+ const { node, shape } = view;
310
+ const tag = node.isPlot ? node.tag : node;
311
+ if (isReactWidget(shape)) {
312
+ const keep = (old) => {
313
+ const oldView = tileViews.get(old);
314
+ return !!oldView && sameNodeView(oldView, view) && !redrawn(context.changes, pos);
315
+ };
316
+ const tile = reactWidgetTile(cursor.next(), context, keep, (dom) => {
317
+ if (!shape.type.editable)
318
+ setUneditable(dom);
319
+ return new WidgetTile(shape, tag, TileFlag.Atom, dom, node.length);
320
+ });
321
+ tileViews.set(tile, view);
322
+ add(tile);
323
+ continue;
324
+ }
325
+ const old = findWidget(widgetsAt(pool, context, pos), context, (tile) => !!tile.tag && tile.widget.eq(shape));
326
+ const oldView = old && tileViews.get(old);
327
+ if (old && oldView && sameNodeView(oldView, view) && !redrawn(context.changes, pos)) {
328
+ context.reused.set(old, Reused.Full);
329
+ add(placeWidget(old, cursor, context, false));
330
+ continue;
331
+ }
332
+ if (old)
333
+ context.reused.set(old, Reused.DOM);
334
+ const dom = old ? old.dom : shape.render(context.wg);
335
+ if (!shape.type.editable)
336
+ setUneditable(dom);
337
+ const tile = new WidgetTile(shape, tag, TileFlag.Atom, dom, node.length);
338
+ tileViews.set(tile, view);
339
+ add(placeWidget(tile, cursor, context, true));
340
+ continue;
341
+ }
342
+ // In a kept element, a view laid out before, the same object, is one React
343
+ // skipped (DefaultNodeView's memo): its DOM and tile are as they were.
344
+ const same = kept && cursor.canSkip && view.type == "node" ? viewTiles.get(view) : undefined;
345
+ const dom = same ? same.dom : cursor.next();
346
+ if (same)
347
+ cursor.skip(same.dom);
348
+ if (view.type == "text") {
349
+ if (!(dom instanceof Text))
350
+ throw new Error("Expected a text node");
351
+ context.rendered.texts.set(dom, { key: keyAt(i), text: view.text });
352
+ add(synced(new TextTile(view.text, dom)));
353
+ }
354
+ else if (view.type == "composition") {
355
+ if (!context.composition || dom !== context.composition.node)
356
+ throw new Error("Expected the composition's node");
357
+ add(compositionTile(dom, context.composition));
358
+ }
359
+ else if (view.type == "node") {
360
+ const { node, shape, children } = view;
361
+ // React skipped a node whose layout is the same (DefaultNodeView's memo),
362
+ // and `restore` already undid any edit the browser made in it, so its DOM,
363
+ // and the tiles over it, are as they were.
364
+ const old = same ?? dom.wgTile;
365
+ const oldView = old && tileViews.get(old);
366
+ if (old && oldView && sameNodeView(oldView, view) && !holdsChanged(old, context)) {
367
+ // An atom's tile holds its shape's widgets: reconnected where redrawn.
368
+ if (context.oldWidgets) {
369
+ if (!children && redrawn(context.changes, pos))
370
+ context.toConnect.push(old);
371
+ else
372
+ context.reused.set(old, Reused.Full);
373
+ }
374
+ viewTiles.set(view, old);
375
+ add(old);
376
+ continue;
377
+ }
378
+ if (!(shape instanceof Elt))
379
+ throw new Error("Expected an element shape");
380
+ const tag = node.isPlot ? node.tag : node;
381
+ // Wordgard rebuilds a node's shape only where the node itself is
382
+ // redrawn, not just its content: the widgets in a kept shape stay whole.
383
+ const oldTag = oldView && (oldView.node.isPlot ? oldView.node.tag : oldView.node);
384
+ const keepShape = !!oldTag?.eq(tag) && !!oldView?.shape.eq(shape) && !redrawn(context.changes, pos);
385
+ const contentStart = pos + 1;
386
+ const fill = (tile, content, pool) => {
387
+ // The element at the hole had a tile: React kept it.
388
+ const kept = !!context.changes && !!pool.old;
389
+ if (node.isPlot && children)
390
+ addChildTiles(tile, node, children, content, context, pool, contentStart, kept);
391
+ };
392
+ const tile = shapeTile(shape, tag, node.length, elementOf(dom, node.name), {
393
+ inEditable: true,
394
+ container: isContainer(node),
395
+ afterContent: 0,
396
+ keepShape,
397
+ fill,
398
+ context,
399
+ });
400
+ tileViews.set(tile, view);
401
+ viewTiles.set(view, tile);
402
+ add(tile);
403
+ }
404
+ else if (view.type == "wrapper") {
405
+ const flags = TileFlag.Wrapper | (view.spanning ? TileFlag.Spanning : 0);
406
+ const element = elementOf(dom, "a wrapper");
407
+ context.rendered.wrappers.set(element, keyAt(i));
408
+ const wrapperKept = kept && !!element.wgTile;
409
+ if (view.mark) {
410
+ const fill = (tile, content, contentKept) => addChildTiles(tile, plot, view.children, content, context, pool, pos, contentKept);
411
+ const tile = reactMarkTile(view, view.mark, flags, element, context, fill, wrapperKept);
412
+ markViews.set(tile, { view, plot });
413
+ add(tile);
414
+ continue;
415
+ }
416
+ const tile = EltTile.of(view.elt, null, flags, 0, element);
417
+ const content = childCursor(element);
418
+ addChildTiles(tile, plot, view.children, content, context, pool, pos, wrapperKept);
419
+ finish(content, context);
420
+ tile.length = tile.children.reduce((length, ch) => length + ch.length, 0);
421
+ add(synced(tile));
422
+ }
423
+ else if (view.type == "buffer") {
424
+ const side = view.side < 0 ? TileFlag.PointBefore : TileFlag.PointAfter;
425
+ const tile = new WidgetTile(Widget.img, null, TileFlag.Point | side, elementOf(dom, "a buffer"));
426
+ add(synced(tile));
427
+ }
428
+ else {
429
+ const tile = new WidgetTile(Widget.br, null, TileFlag.Point | TileFlag.PointAfter, elementOf(dom, "the trailing <br>"));
430
+ add(synced(tile));
431
+ }
432
+ }
433
+ };
434
+ // Done with an element: the widget DOM it no longer holds is removed at the end
435
+ // of the build, unless used elsewhere.
436
+ const finish = (cursor, context) => {
437
+ context.strays.push(...cursor.done());
438
+ };
439
+ // A tile for an element the browser put around composed text.
440
+ const browserTile = (dom) => getTileClasses().EltTile.of(browserElt(dom), null, 0, 0, dom);
441
+ // The composed text's tile, flagged as Wordgard flags it, inside tiles for the
442
+ // elements the browser put around it (ContentUpdate.composition).
443
+ const compositionTile = (dom, composition) => {
444
+ const { TextTile } = getTileClasses();
445
+ if (dom instanceof Text)
446
+ return synced(new TextTile(composition.text, dom, TileFlag.Composition));
447
+ const element = elementOf(dom, "the composition");
448
+ const inner = [...element.childNodes].find((child) => child.contains(composition.target));
449
+ if (!inner)
450
+ throw new Error("Expected the composition's node");
451
+ const tile = browserTile(element);
452
+ tile.addChild(compositionTile(inner, composition));
453
+ tile.length = composition.text.length;
454
+ return synced(tile);
455
+ };
456
+ // Wordgard's buildNodeShape: the node's own tile (with `tag`) or a tile for
457
+ // structure inside its shape (`tag` null).
458
+ const shapeTile = (shape, tag, length, dom, shapeContext) => {
459
+ if (isReactShape(shape) || isReactWrapper(shape))
460
+ return reactShapeTile(shape, tag, length, dom, shapeContext);
461
+ const attributed = attributedWidget(shape);
462
+ if (attributed)
463
+ return attributedWidgetTile(attributed, tag, length, dom, shapeContext);
464
+ const { inEditable, afterContent, keepShape, fill, context } = shapeContext;
465
+ const { EltTile, WidgetTile } = getTileClasses();
466
+ const previous = dom.wgTile ?? null;
467
+ const { shape: elt, inEditable: inner } = uneditable(shape, inEditable);
468
+ const flags = tag
469
+ ? elt.hasContent
470
+ ? 0
471
+ : TileFlag.Atom
472
+ : TileFlag.NodeInner | (elt.hasContent ? 0 : TileFlag.Point);
473
+ const tile = EltTile.of(elt, tag, flags | afterContent, tag ? length : 0, dom);
474
+ const cursor = childCursor(dom);
475
+ let after = 0;
476
+ for (const child of elt.children) {
477
+ if (child === 0) {
478
+ tile.flags |= TileFlag.PlotContent;
479
+ fill(tile, cursor, widgetPool(previous));
480
+ after = TileFlag.AfterContent;
481
+ }
482
+ else if (typeof child == "string") {
483
+ const text = cursor.next();
484
+ if (!(text instanceof Text))
485
+ throw new Error("Expected a text node in a shape");
486
+ const flags = TileFlag.Point | TileFlag.NodeInner | after;
487
+ tile.addChild(synced(new WidgetTile(Widget.text.of(child), null, flags, text)));
488
+ }
489
+ else if (child instanceof Elt) {
490
+ const inside = { ...shapeContext, inEditable: inner, afterContent: after };
491
+ tile.addChild(shapeTile(child, null, 0, elementOf(cursor.next(), child.tagName), inside));
492
+ }
493
+ else if (isReactWidget(child)) {
494
+ const flags = TileFlag.Point | TileFlag.NodeInner | after;
495
+ const keep = (old) => keepShape && old.isNodeInner && old.widget.eq(child);
496
+ const widget = reactWidgetTile(cursor.next(), context, keep, (dom) => {
497
+ if (inner && !child.type.editable)
498
+ setUneditable(dom);
499
+ return new WidgetTile(child, null, flags, dom);
500
+ });
501
+ tile.addChild(widget);
502
+ }
503
+ else {
504
+ // A widget in the shape: kept whole in a kept shape, else an equal one's
505
+ // DOM is used again (findReusableTile) and connected anew.
506
+ const old = findWidget(previous?.children ?? [], context, (tile) => tile.isNodeInner && tile.widget.eq(child));
507
+ if (old && keepShape) {
508
+ context.reused.set(old, Reused.Full);
509
+ tile.addChild(placeWidget(old, cursor, context, false));
510
+ continue;
511
+ }
512
+ if (old)
513
+ context.reused.set(old, Reused.DOM);
514
+ const dom = old ? old.dom : child.render(context.wg);
515
+ if (inner && !child.type.editable)
516
+ setUneditable(dom);
517
+ const flags = TileFlag.Point | TileFlag.NodeInner | after;
518
+ tile.addChild(placeWidget(new WidgetTile(child, null, flags, dom), cursor, context, true));
519
+ }
520
+ }
521
+ finish(cursor, context);
522
+ // What `sync()` computes for tiles whose length follows their children.
523
+ if (!(tile.flags & TileFlag.Atom))
524
+ tile.length = tile.children.reduce((sum, ch) => sum + ch.length, tile.boundary * 2);
525
+ return synced(tile);
526
+ };
527
+ // A React widget with the attributes Wordgard would wrap it in an element for:
528
+ // the view draws the widget alone, its attributes as props.
529
+ const attributedWidgetTile = (widget, tag, length, dom, { inEditable, afterContent, keepShape, context }) => {
530
+ const { WidgetTile } = getTileClasses();
531
+ const flags = (tag ? TileFlag.Atom : TileFlag.Point | TileFlag.NodeInner) | afterContent;
532
+ const keep = (old) => keepShape && old.widget.eq(widget);
533
+ return reactWidgetTile(dom, context, keep, (dom) => {
534
+ if (inEditable && !widget.type.editable)
535
+ setUneditable(dom);
536
+ return new WidgetTile(widget, tag, flags, dom, tag ? length : 0);
537
+ });
538
+ };
539
+ // What a React shape's component draws besides the content element and the
540
+ // elements around it. Its tiles are uneditable widgets, as a widget inside a
541
+ // shape is: Wordgard ignores mutations in them, and positions skip them.
542
+ const chrome = Widget.define({
543
+ render: () => {
544
+ throw new Error("React draws a shape's chrome");
545
+ },
546
+ propagateEvent: (event) => !stopsEvent(event),
547
+ }).of(null);
548
+ // Tiles over a React shape's elements: React's attributes, not the shape's.
549
+ const shapeTiles = new WeakSet();
550
+ // A React decoration's own element (its root, or structure down to its content
551
+ // element), whose events Wordgard asks it about as it walks up from their
552
+ // target (eventBelongsToEditor): useStopEvent decides for those targeting the
553
+ // element itself, as a widget's propagateEvent does for chrome. Not for the
554
+ // content element, whose events are the content's.
555
+ let ownTileClass = null;
556
+ const ownTile = (elt, tag, flags, length, dom) => {
557
+ ownTileClass ??= class extends getTileClasses().EltTile {
558
+ ignoreEvent(event) {
559
+ return event.target == this.dom && stopsEvent(event);
560
+ }
561
+ };
562
+ // EltTile.of's flags, as it can't make a subclass.
563
+ if (elt.hasContent) {
564
+ flags |= TileFlag.HasContent;
565
+ const hole = elt.children.indexOf(0);
566
+ if (hole > -1 && hole < elt.children.length - 1)
567
+ flags |= TileFlag.ContentNotLast;
568
+ }
569
+ return new ownTileClass(elt, tag, flags, length, dom);
570
+ };
571
+ const addChromeTile = (tile, child, flags) => {
572
+ if (!(child instanceof Element || child instanceof Text))
573
+ return;
574
+ if (child instanceof Element)
575
+ setUneditable(child);
576
+ const { WidgetTile } = getTileClasses();
577
+ tile.addChild(synced(new WidgetTile(chrome, null, flags, child)));
578
+ };
579
+ // The view and plot each React mark's tile was built for, and the tile holding
580
+ // its content.
581
+ const markViews = new WeakMap();
582
+ const markContents = new WeakMap();
583
+ // A React mark's tiles, over the DOM its component committed: a wrapper tile
584
+ // for its root, and for each element down to its content element, as Wordgard's
585
+ // `resolve` walks nested wrappers. Everything else is chrome, a point widget
586
+ // beside the content. The content element holds only the content: its text
587
+ // couldn't be told from chrome's. `fill` adds the content's tiles, `kept` when
588
+ // React kept that element.
589
+ const reactMarkTile = (view, mark, flags, dom, context, fill, kept) => {
590
+ const { EltTile } = getTileClasses();
591
+ const handle = shapeOfRoot(dom);
592
+ if (!handle || handle.wrapper)
593
+ throw new Error("A React mark's component must draw a DecorationRoot, or forward its ref to its root element");
594
+ if (dom.localName != view.elt.tagName)
595
+ warnElement("mark", view.elt.tagName, dom.localName);
596
+ const content = contentOf(handle) ?? dom;
597
+ if (!dom.contains(content))
598
+ throw new Error("A React mark's content element is outside it");
599
+ // Content React remounted in another element: its widget DOM, which React
600
+ // doesn't remove, comes out, to be reused from the pool or dropped.
601
+ const old = dom.wgTile && markContents.get(dom.wgTile);
602
+ const moved = !!old && old.dom != content;
603
+ if (old && moved)
604
+ removeWidgetDOM(old, content);
605
+ let contentTile = null;
606
+ const build = (element, elt, flags) => {
607
+ const tile = element == content
608
+ ? EltTile.of(elt, null, flags, 0, element)
609
+ : ownTile(elt, null, flags, 0, element);
610
+ shapeTiles.add(tile);
611
+ markTiles.add(tile);
612
+ if (element == content) {
613
+ const cursor = childCursor(element);
614
+ fill(tile, cursor, kept && !moved);
615
+ finish(cursor, context);
616
+ contentTile = tile;
617
+ }
618
+ else {
619
+ for (const child of [...element.childNodes]) {
620
+ if (child instanceof Element && child.contains(content))
621
+ tile.addChild(build(child, Elt.mk(child.localName, [0]), TileFlag.Wrapper));
622
+ else
623
+ addChromeTile(tile, child, TileFlag.Point);
624
+ }
625
+ }
626
+ tile.length = tile.children.reduce((sum, ch) => sum + ch.length, 0);
627
+ return synced(tile);
628
+ };
629
+ const tile = build(dom, view.elt, flags);
630
+ markOfTile.set(tile, mark);
631
+ if (contentTile)
632
+ markContents.set(tile, contentTile);
633
+ return tile;
634
+ };
635
+ // A React shape's or wrapper's tiles, over the DOM its component committed:
636
+ // walked from the DOM, as the component decides its structure. The elements
637
+ // from the root to the content element (a wrapper: to the element it wraps)
638
+ // are its structure, everything else is chrome.
639
+ const reactShapeTile = (elt, tag, length, dom, shapeContext) => {
640
+ const { afterContent, container, fill, context } = shapeContext;
641
+ const { EltTile, WidgetTile } = getTileClasses();
642
+ const handle = shapeOfRoot(dom);
643
+ const what = isReactWrapper(elt) ? "wrapper" : "shape";
644
+ if (!handle || handle.wrapper != (what == "wrapper"))
645
+ throw new Error(`A React ${what}'s component must draw a DecorationRoot, or forward its ref to its root element`);
646
+ if (dom.localName != elt.tagName)
647
+ warnElement(what, elt.tagName, dom.localName);
648
+ if (handle.wrapper && !handle.content)
649
+ throw new Error("A React wrapper's component must draw its children");
650
+ const atom = !handle.wrapper && !elt.hasContent;
651
+ if (atom && handle.content)
652
+ throw new Error("A React shape drawing an atom has no content: drop its contentDOMRef");
653
+ const content = atom ? null : (contentOf(handle) ?? dom);
654
+ if ((content && !dom.contains(content)) || (handle.wrapper && content == dom))
655
+ throw new Error(`A React ${what}'s ${handle.wrapper ? "children are" : "content element is"} outside it`);
656
+ // A wrapper around an atom: uneditable, as Wordgard makes a shape without a
657
+ // hole.
658
+ const { inEditable } = uneditable(elt, shapeContext.inEditable);
659
+ if (!inEditable && shapeContext.inEditable)
660
+ setUneditable(dom);
661
+ // Content React remounted in another element: the widget DOM placed in the
662
+ // old one, which React doesn't remove, comes out (to be reused from the
663
+ // pool, or dropped), and the old content's widgets make the pool.
664
+ const old = !handle.wrapper && dom.wgTile ? contentTile(dom.wgTile) : null;
665
+ const moved = old && old.dom != content ? old : null;
666
+ if (moved)
667
+ for (const child of moved.children)
668
+ if (widgetDOM.has(child.dom))
669
+ child.dom.remove();
670
+ // The same for what a wrapper wrapped: React may have remounted it, or made
671
+ // its element structure (same name, so React kept it, widget DOM included).
672
+ if (handle.wrapper && content && dom.wgTile)
673
+ removeWidgetDOM(dom.wgTile, content);
674
+ // What a wrapper wraps: the node's shape or an element in it, or the widget
675
+ // drawing the node.
676
+ const wrappedTile = (element, after) => {
677
+ const [child] = elt.children;
678
+ const inside = { ...shapeContext, inEditable, afterContent: after };
679
+ if (child instanceof Elt)
680
+ return shapeTile(child, null, 0, element, inside);
681
+ if (child === undefined || child === 0 || typeof child == "string" || !isReactWidget(child))
682
+ throw new Error("A React wrapper wraps an element or a React widget");
683
+ const flags = TileFlag.Point | TileFlag.NodeInner | after;
684
+ const keep = (old) => shapeContext.keepShape && old.isNodeInner && old.widget.eq(child);
685
+ return reactWidgetTile(element, context, keep, (dom) => {
686
+ if (inEditable && !child.type.editable)
687
+ setUneditable(dom);
688
+ return new WidgetTile(child, null, flags, dom);
689
+ });
690
+ };
691
+ const addChrome = (tile, child, after) => addChromeTile(tile, child, TileFlag.Point | TileFlag.NodeInner | after);
692
+ const build = (element, elt, tag, flags) => {
693
+ const previous = element.wgTile ?? null;
694
+ const tile = element == content && !handle.wrapper
695
+ ? EltTile.of(elt, tag, flags, tag ? length : 0, element)
696
+ : ownTile(elt, tag, flags, tag ? length : 0, element);
697
+ shapeTiles.add(tile);
698
+ if (element == content && !handle.wrapper) {
699
+ tile.flags |= TileFlag.PlotContent;
700
+ // In a container, what isn't a node's or a widget's DOM is chrome, before
701
+ // or after the content, which React draws as one run.
702
+ const children = [...element.childNodes];
703
+ const isChrome = (child) => container && !isContentDOM(child);
704
+ const first = children.findIndex((child) => !isChrome(child));
705
+ const last = children.findLastIndex((child) => !isChrome(child));
706
+ if (children.slice(first, last + 1).some(isChrome))
707
+ throw new Error("A React shape's chrome is between its content's nodes");
708
+ const before = first < 0 ? children : children.slice(0, first);
709
+ const after = first < 0 ? [] : children.slice(last + 1);
710
+ for (const child of before)
711
+ addChrome(tile, child, 0);
712
+ const cursor = childCursor(element, new Set([...before, ...after]));
713
+ fill(tile, cursor, widgetPool(moved ?? previous));
714
+ for (const child of after)
715
+ addChrome(tile, child, TileFlag.AfterContent);
716
+ finish(cursor, context);
717
+ }
718
+ else {
719
+ let after = 0;
720
+ for (const child of [...element.childNodes]) {
721
+ if (handle.wrapper && child == content) {
722
+ tile.addChild(wrappedTile(content, after));
723
+ after = TileFlag.AfterContent;
724
+ }
725
+ else if (child instanceof Element && child.contains(content)) {
726
+ const structure = Elt.mk(child.localName, hole ? [0] : []);
727
+ tile.addChild(build(child, structure, null, innerFlags | after));
728
+ after = TileFlag.AfterContent;
729
+ }
730
+ else {
731
+ addChrome(tile, child, after);
732
+ }
733
+ }
734
+ }
735
+ if (!(tile.flags & TileFlag.Atom))
736
+ tile.length = tile.children.reduce((sum, ch) => sum + ch.length, tile.boundary * 2);
737
+ return synced(tile);
738
+ };
739
+ // Without a hole (a wrapper around an atom), Wordgard's flags for a shape
740
+ // without content.
741
+ const hole = elt.hasContent;
742
+ const innerFlags = TileFlag.NodeInner | (hole ? 0 : TileFlag.Point);
743
+ const flags = tag ? (hole ? 0 : TileFlag.Atom) : innerFlags;
744
+ return build(dom, elt, tag, flags | afterContent);
745
+ };
746
+ // Takes the widget DOM under `tile` out of the page, unless it's in `keep`.
747
+ const removeWidgetDOM = (tile, keep) => {
748
+ for (const child of tile.children) {
749
+ if (!widgetDOM.has(child.dom))
750
+ removeWidgetDOM(child, keep);
751
+ else if (!keep.contains(child.dom))
752
+ child.dom.remove();
753
+ }
754
+ };
755
+ // A plot whose content is blocks: no text, so no browser edits in its content
756
+ // element itself.
757
+ const isContainer = (node) => node.isPlot && !node.isTextblock && !node.isInline;
758
+ // Whether a React shape's content is text, from the node its tile was built
759
+ // for (records only come once it's built).
760
+ // The node tile a React shape's or wrapper's root is drawn in.
761
+ const nodeTileOf = (root) => {
762
+ let tile = root.wgTile;
763
+ while (tile && !tile.tag)
764
+ tile = tile.parent ?? undefined;
765
+ return tile;
766
+ };
767
+ const holdsText = (root) => {
768
+ const tile = nodeTileOf(root);
769
+ const view = tile && tileViews.get(tile);
770
+ return !view || !isContainer(view.node);
771
+ };
772
+ // The DOM a container's content is made of: elements React drew for nodes,
773
+ // mark wrappers and React widgets, and native widgets' DOM.
774
+ const isContentDOM = (node) => widgetDOM.has(node) ||
775
+ (node instanceof Element &&
776
+ (viewRoots.has(node) || !!shapeOfRoot(node) || widgetRoots.has(node)));
777
+ const isChromeTile = (tile) => tile instanceof getTileClasses().WidgetTile && tile.widget == chrome;
778
+ const warned = new Set();
779
+ // A React shape, wrapper or mark whose component's root isn't the element its Elt
780
+ // names: `target` selectors match the Elt, and plain Wordgard draws it.
781
+ const warnElement = (what, expected, found) => {
782
+ const key = `${what}:${expected}>${found}`;
783
+ if (warned.has(key))
784
+ return;
785
+ warned.add(key);
786
+ const fix = {
787
+ shape: `draw the element its shape says (the node type's, or the one given to \`of\`), or pass <${found}> to \`of\``,
788
+ wrapper: "give createWrapper the element its component draws",
789
+ mark: "give createMarkShape the element its component draws",
790
+ }[what];
791
+ console.error(`A React ${what}'s component drew <${found}> where its ${what} says <${expected}>: ${fix}`);
792
+ };
793
+ // Whether a React shape whose structure changed is drawn in a tile's DOM: the
794
+ // tile can't be kept whole.
795
+ const holdsChanged = (tile, context) => {
796
+ for (const root of context.changedShapes)
797
+ if (tile.dom.contains(root))
798
+ return true;
799
+ return false;
800
+ };
801
+ // The flag Wordgard's `sync()` sets once a tile's DOM matches it.
802
+ const synced = (tile) => {
803
+ tile.flags |= TileFlag.Synced;
804
+ return tile;
805
+ };
806
+ const setAttributes = (dom, attrs) => {
807
+ const wanted = attrsToRecord(attrs);
808
+ for (const name of dom.getAttributeNames())
809
+ if (!Object.hasOwn(wanted, name))
810
+ dom.removeAttribute(name);
811
+ for (const [name, value] of Object.entries(wanted))
812
+ if (dom.getAttribute(name) != value)
813
+ dom.setAttribute(name, value);
814
+ };
815
+ // Puts back the DOM React committed where the browser or a script changed it
816
+ // (the tiles Wordgard's DOM observer marked Dirty), so React's diff applies to
817
+ // it. Wordgard's `sync()` restores children and text, not attributes, and
818
+ // keeps widget DOM, which the old tiles hold. A composition's node stays as
819
+ // the browser left it: removing it, even for a moment, would move the
820
+ // selection out of it. Returns the widgets that were changed, which `sync()`
821
+ // marks clean.
822
+ const restore = (tile, composing) => {
823
+ if (composing)
824
+ keepComposing(composing);
825
+ const { EltTile, WidgetTile } = getTileClasses();
826
+ const dirty = new Set();
827
+ const resetAttributes = (tile) => {
828
+ if (!(tile.flags & TileFlag.Dirty))
829
+ return;
830
+ if (tile instanceof EltTile && !shapeTiles.has(tile))
831
+ setAttributes(tile.dom, tile.elt.attrs);
832
+ else if (tile instanceof WidgetTile)
833
+ dirty.add(tile.dom);
834
+ tile.children.forEach(resetAttributes);
835
+ };
836
+ resetAttributes(tile);
837
+ tile.sync();
838
+ return dirty;
839
+ };
840
+ // Makes the old tree hold the composition's node as it is, so `sync()` leaves
841
+ // it: its tiles marked clean, or a clean tile of its own when the browser made
842
+ // it, where it sits among its siblings.
843
+ const keepComposing = (node) => {
844
+ const parent = node.parentNode?.wgTile;
845
+ const own = node.wgTile;
846
+ const clean = (tile) => {
847
+ tile.flags &= ~TileFlag.Dirty;
848
+ tile.children.forEach(clean);
849
+ };
850
+ if (own && own.parent == parent) {
851
+ clean(own);
852
+ return;
853
+ }
854
+ if (!(parent instanceof DocTile || parent instanceof getTileClasses().EltTile))
855
+ return;
856
+ let index = parent.children.length;
857
+ for (let next = node.nextSibling; next; next = next.nextSibling) {
858
+ const found = parent.children.findIndex((child) => child.dom == next);
859
+ if (found > -1) {
860
+ index = found;
861
+ break;
862
+ }
863
+ }
864
+ const { TextTile } = getTileClasses();
865
+ const tile = node instanceof Text
866
+ ? new TextTile(node.nodeValue ?? "", node, TileFlag.Composition)
867
+ : browserTile(elementOf(node, "the composition"));
868
+ parent.children.splice(index, 0, synced(tile));
869
+ tile.parent = parent;
870
+ };
871
+ const keepsAll = (changes) => {
872
+ for (let i = 1; i < changes.length; i += 2)
873
+ if (changes[i] != -1)
874
+ return false;
875
+ return true;
876
+ };
877
+ // Lays out the document from Wordgard's decorations, keeping the layout of
878
+ // plots outside the ranges `changes` redraws (all, without changes).
879
+ const layout = (state, decoSet, changes, entry, composition, replaced) => {
880
+ const previous = entry.composition;
881
+ const composing = new Set();
882
+ for (const view of [previous, composition])
883
+ if (view)
884
+ for (const plot of [state.doc, view.plot, ...view.path])
885
+ composing.add(plot);
886
+ return iterDeco({
887
+ deco: new DecoIterator(state, decoSet),
888
+ pass: {},
889
+ store: entry.store,
890
+ changes,
891
+ layouts: entry.layouts,
892
+ composition,
893
+ composing,
894
+ replaced,
895
+ }, state.doc, 0);
896
+ };
897
+ const newEntry = (commit) => ({
898
+ commit,
899
+ portal: null,
900
+ reached: () => true,
901
+ revert: () => { },
902
+ last: null,
903
+ store: { keys: new WeakMap(), pending: new Map() },
904
+ rendered: { texts: new WeakMap(), wrappers: new WeakMap() },
905
+ layouts: new WeakMap(),
906
+ composition: null,
907
+ error: null,
908
+ failures: 0,
909
+ });
910
+ const docElement = (wg, entry, views) => createElement(ErrorBoundary, {
911
+ key: entry.failures,
912
+ onError: (thrown) => {
913
+ entry.error = { thrown };
914
+ entry.failures++;
915
+ },
916
+ }, createElement(EditorContext.Provider, { value: editorOf(wg) }, createElement(RenderStoreContext.Provider, { value: entry.store }, createElement(DocNodeView, { views }))));
917
+ // Throws what React threw rendering the document, once.
918
+ const throwRenderError = (entry) => {
919
+ const { error } = entry;
920
+ entry.error = null;
921
+ if (error)
922
+ throw error.thrown;
923
+ };
924
+ // The first layout, keys included.
925
+ const firstLayout = (state, entry) => {
926
+ checkRenderable(state.doc);
927
+ addFreshKeys(entry.store.keys, state.doc);
928
+ const decoSet = getDecoSet(state);
929
+ return { state, decoSet, views: layout(state, decoSet, null, entry, null, new WeakMap()) };
930
+ };
931
+ // What `render` throws when React can't commit inside the flush (a flush from
932
+ // inside a React render or commit); ReactWordgard asks for another.
933
+ export class RefusedUpdate extends Error {
934
+ }
935
+ export const docPortal = (dom) => {
936
+ const { portal } = rootOf(dom);
937
+ if (!portal)
938
+ throw new Error("This editor isn't hosted by WordgardEditor");
939
+ return portal;
940
+ };
941
+ export class ReactDocTile extends DocTile {
942
+ // Whether this tree, or one before it, held widget DOM: those get
943
+ // disconnected when dropped.
944
+ widgets = false;
945
+ static create = (state, dom, wg) => {
946
+ if (!roots.has(dom)) {
947
+ // Wordgard's own tests build trees in elements already drawn.
948
+ dom.replaceChildren();
949
+ const root = createRoot(dom);
950
+ roots.set(dom, newEntry((element) => root.render(element)));
951
+ }
952
+ const { decoSet, views } = firstLayout(state, rootOf(dom));
953
+ return ReactDocTile.render(state, dom, decoSet, views, {
954
+ wg,
955
+ old: null,
956
+ changes: null,
957
+ dirty: new Set(),
958
+ });
959
+ };
960
+ // For an editor WordgardEditor hosts: React draws the document when
961
+ // WordgardDoc mounts its portal, which then calls `mount`. Until then the
962
+ // tree is empty; Wordgard doesn't flush an editor that isn't in the page.
963
+ static host = (state, dom, wg, mount) => {
964
+ dom.replaceChildren();
965
+ let element = null;
966
+ let committed = null;
967
+ const listeners = new Set();
968
+ let claims = 0;
969
+ const portal = {
970
+ get: () => {
971
+ if (!element)
972
+ throw new Error("Nothing to render yet");
973
+ return element;
974
+ },
975
+ subscribe: (listener) => {
976
+ listeners.add(listener);
977
+ return () => listeners.delete(listener);
978
+ },
979
+ committed: (element) => {
980
+ committed = element;
981
+ },
982
+ claim: () => {
983
+ claims++;
984
+ return () => {
985
+ claims--;
986
+ };
987
+ },
988
+ // The editor's element and content element exist once: a second
989
+ // WordgardDoc would move the first's element and draw the document
990
+ // twice into one content element.
991
+ mount: () => {
992
+ if (claims > 1)
993
+ throw new Error("This editor is already rendered by another WordgardDoc");
994
+ mount();
995
+ },
996
+ };
997
+ const entry = newEntry((next) => {
998
+ element = next;
999
+ for (const listener of listeners)
1000
+ listener();
1001
+ });
1002
+ entry.portal = portal;
1003
+ entry.reached = (element) => committed == element;
1004
+ entry.revert = () => {
1005
+ element = committed;
1006
+ };
1007
+ roots.set(dom, entry);
1008
+ const laid = firstLayout(state, entry);
1009
+ entry.last = laid;
1010
+ element = docElement(wg, entry, laid.views);
1011
+ return new ReactDocTile(state, dom, null, laid.decoSet);
1012
+ };
1013
+ // Builds the tiles over what the portal committed, for its first commit or
1014
+ // a commit after it was unmounted.
1015
+ static mount = (dom, wg, old) => {
1016
+ const entry = rootOf(dom);
1017
+ throwRenderError(entry);
1018
+ const { last } = entry;
1019
+ if (!last)
1020
+ throw new Error("Nothing rendered to mount");
1021
+ return ReactDocTile.build(last.state, dom, last.decoSet, last.views, {
1022
+ wg,
1023
+ old: old instanceof ReactDocTile ? old : null,
1024
+ changes: null,
1025
+ dirty: new Set(),
1026
+ });
1027
+ };
1028
+ // Runs inside Wordgard's flush, where its DOM observer ignores mutations, so
1029
+ // React commits synchronously here, with the state the hooks read. Then
1030
+ // builds the tiles.
1031
+ static render = (state, dom, decoSet, views, options) => {
1032
+ const entry = rootOf(dom);
1033
+ const editor = editorOf(options.wg);
1034
+ const element = docElement(options.wg, entry, views);
1035
+ const before = { state: editor.store.getState(), last: entry.last };
1036
+ entry.last = { state, decoSet, views };
1037
+ let takeBack = () => { };
1038
+ flushSync(() => {
1039
+ takeBack = publishCells(entry.store);
1040
+ editor.publish(state);
1041
+ entry.commit(element);
1042
+ });
1043
+ throwRenderError(entry);
1044
+ // flushSync defers inside a React render or commit, and the tiles can't
1045
+ // be built over DOM React hasn't committed. React renders what the stores
1046
+ // then hold, so it gets back what it last committed, and Wordgard, which
1047
+ // keeps the update pending, redraws on its next flush.
1048
+ if (!entry.reached(element)) {
1049
+ takeBack();
1050
+ entry.last = before.last;
1051
+ entry.revert();
1052
+ editor.publish(before.state);
1053
+ throw new RefusedUpdate("ReactWordgard can't update while React renders or commits: read the editor's layout from useEditorEffect or useEditorLayoutEffect, not from a React effect");
1054
+ }
1055
+ entry.store.pending.clear();
1056
+ return ReactDocTile.build(state, dom, decoSet, views, options);
1057
+ };
1058
+ // Builds the tiles over the DOM React committed, puts widget DOM in place,
1059
+ // and connects and disconnects widgets as Wordgard's updateRanges does.
1060
+ static build = (state, dom, decoSet, views, { wg, old, changes, dirty, composition = null }) => {
1061
+ const entry = rootOf(dom);
1062
+ entry.composition = composition;
1063
+ const tile = new ReactDocTile(state, dom, composition?.wrapCursor ?? null, decoSet);
1064
+ const cursor = childCursor(dom);
1065
+ const context = {
1066
+ keys: entry.store.keys,
1067
+ rendered: entry.rendered,
1068
+ composition,
1069
+ wg,
1070
+ changes,
1071
+ reused: new Map(),
1072
+ oldWidgets: !!old?.widgets,
1073
+ toConnect: [],
1074
+ taken: new Set(),
1075
+ strays: [],
1076
+ dirty,
1077
+ changedShapes: takeShapeChanges(wg),
1078
+ };
1079
+ // Wordgard's content element stays; React's children in it only in an
1080
+ // update (a mount draws them anew).
1081
+ addChildTiles(tile, state.doc, views, cursor, context, widgetPool(old), 0, !!changes && !!old);
1082
+ finish(cursor, context);
1083
+ for (const stray of context.strays)
1084
+ if (!context.taken.has(stray))
1085
+ stray.remove();
1086
+ // What the build itself moved (widget DOM, strays) isn't a shape's change.
1087
+ takeShapeChanges(wg);
1088
+ tile.length = state.doc.length;
1089
+ tile.widgets = context.oldWidgets || context.taken.size > 0;
1090
+ if (wg.connected) {
1091
+ if (old?.widgets)
1092
+ for (const child of old.children)
1093
+ child.disconnect(context.reused);
1094
+ for (const widget of context.toConnect)
1095
+ widget.connect();
1096
+ }
1097
+ return synced(tile);
1098
+ };
1099
+ // Wordgard's own `update` diffs the decorations and calls this with the
1100
+ // ranges to redraw, decoration changes included.
1101
+ updateRanges(state, decoSet, changes, wg, info) {
1102
+ const entry = rootOf(this.dom);
1103
+ const editor = editorOf(wg);
1104
+ if (editor.measures.size)
1105
+ wg.scheduleDOMRead(editor.readMeasures);
1106
+ const previous = entry.composition;
1107
+ if (keepsAll(changes) && !info && !previous) {
1108
+ flushSync(() => editor.publish(state));
1109
+ // React decorations drawing from the state committed there, and no build
1110
+ // follows to take their changes.
1111
+ rebuildRoots(takeShapeChanges(wg), wg);
1112
+ return this;
1113
+ }
1114
+ checkRenderable(state.doc);
1115
+ const composition = info ? compositionView(state.doc, info, changes, entry.rendered) : null;
1116
+ const replaced = mapKeys(entry.store.keys, this.state.doc, state.doc, changes);
1117
+ const views = layout(state, decoSet, changes, entry, composition, replaced);
1118
+ const node = composition?.node ?? null;
1119
+ const dirty = restore(this, node);
1120
+ // A composition that's over: React's node gets back what React committed
1121
+ // there, what the browser made goes.
1122
+ if (previous?.node && previous.node != node) {
1123
+ if (previous.rendered)
1124
+ previous.node.nodeValue = previous.rendered.text;
1125
+ else
1126
+ previous.node.remove();
1127
+ }
1128
+ return ReactDocTile.render(state, this.dom, decoSet, views, {
1129
+ wg,
1130
+ old: this,
1131
+ changes,
1132
+ dirty,
1133
+ composition,
1134
+ });
1135
+ }
1136
+ }
1137
+ // The tile holding a plot's content, inside a node tile's shape.
1138
+ const contentTile = (tile) => {
1139
+ if (tile.flags & TileFlag.PlotContent)
1140
+ return tile;
1141
+ for (const child of tile.children) {
1142
+ const found = child.isNodeInner ? contentTile(child) : null;
1143
+ if (found)
1144
+ return found;
1145
+ }
1146
+ return null;
1147
+ };
1148
+ // What a rebuild outside the flush builds with.
1149
+ const rebuildContext = (wg) => {
1150
+ const entry = rootOf(wg.contentDOM);
1151
+ const length = entry.last?.state.doc.length;
1152
+ return {
1153
+ keys: entry.store.keys,
1154
+ rendered: entry.rendered,
1155
+ composition: entry.composition,
1156
+ wg,
1157
+ // Nothing changed in the document: widgets map to where they were.
1158
+ changes: length === undefined ? null : [length, -1],
1159
+ reused: new Map(),
1160
+ oldWidgets: false,
1161
+ toConnect: [],
1162
+ taken: new Set(),
1163
+ strays: [],
1164
+ dirty: new Set(),
1165
+ changedShapes: new Set(),
1166
+ };
1167
+ };
1168
+ // Puts the content's old tiles in `tile` when React kept its element: they're
1169
+ // over the same DOM, in the same order.
1170
+ const keepContent = (old, tile, cursor, context) => {
1171
+ for (const child of old.children) {
1172
+ if (isChromeTile(child))
1173
+ continue;
1174
+ if (widgetDOM.has(child.dom))
1175
+ cursor.place(child.dom);
1176
+ else if (cursor.next() != child.dom)
1177
+ throw new Error("A React decoration's content moved");
1178
+ context.reused.set(child, Reused.Full);
1179
+ tile.addChild(child);
1180
+ }
1181
+ };
1182
+ // Puts a rebuilt tile in the old one's place, and connects and disconnects as
1183
+ // the build does.
1184
+ const replaceTile = (old, tile, context) => {
1185
+ const { parent } = old;
1186
+ if (!parent)
1187
+ return;
1188
+ parent.children[parent.children.indexOf(old)] = tile;
1189
+ tile.parent = parent;
1190
+ // Wordgard finds a tile from its DOM and checks the tree owns it: the old one
1191
+ // and its DOM, now React's elsewhere, are out of the tree.
1192
+ old.parent = null;
1193
+ for (const stray of context.strays)
1194
+ if (!context.taken.has(stray))
1195
+ stray.remove();
1196
+ // What wasn't kept is disconnected: the content React remounted, and the
1197
+ // widgets around it in a wrapped shape React remounted.
1198
+ if (context.wg.connected) {
1199
+ old.disconnect(context.reused);
1200
+ for (const widget of context.toConnect)
1201
+ widget.connect();
1202
+ }
1203
+ };
1204
+ // Builds a React shape's node tile again over React's DOM, outside the flush,
1205
+ // after its component committed on its own: the content's tiles are kept when
1206
+ // the content element is the same, built again from their views when React
1207
+ // remounted it.
1208
+ const rebuildShape = (old, wg) => {
1209
+ const view = tileViews.get(old);
1210
+ // Gone with content an earlier rebuild found remounted.
1211
+ if (!old.dom.isConnected)
1212
+ return;
1213
+ if (!view || !old.parent || !(view.shape instanceof Elt))
1214
+ return;
1215
+ const context = rebuildContext(wg);
1216
+ const { node, shape, children } = view;
1217
+ const oldContent = contentTile(old);
1218
+ const contentStart = old.posBefore + 1;
1219
+ const fill = (tile, cursor, pool) => {
1220
+ if (oldContent && oldContent.dom == tile.dom)
1221
+ keepContent(oldContent, tile, cursor, context);
1222
+ else if (node.isPlot && children)
1223
+ addChildTiles(tile, node, children, cursor, context, pool, contentStart, false);
1224
+ };
1225
+ const tag = node.isPlot ? node.tag : node;
1226
+ const tile = shapeTile(shape, tag, node.length, elementOf(old.dom, node.name), {
1227
+ inEditable: true,
1228
+ container: isContainer(node),
1229
+ afterContent: 0,
1230
+ keepShape: true,
1231
+ fill,
1232
+ context,
1233
+ });
1234
+ tileViews.set(tile, view);
1235
+ viewTiles.set(view, tile);
1236
+ replaceTile(old, tile, context);
1237
+ };
1238
+ // The same for a React mark's tile.
1239
+ const rebuildMark = (old, wg) => {
1240
+ const found = markViews.get(old);
1241
+ const mark = markOfTile.get(old);
1242
+ if (!old.dom.isConnected || !found || !mark || !old.parent)
1243
+ return;
1244
+ const { view, plot } = found;
1245
+ const context = rebuildContext(wg);
1246
+ const oldContent = markContents.get(old);
1247
+ const pos = old.posBefore;
1248
+ const fill = (tile, cursor) => {
1249
+ if (oldContent && oldContent.dom == tile.dom)
1250
+ keepContent(oldContent, tile, cursor, context);
1251
+ else
1252
+ addChildTiles(tile, plot, view.children, cursor, context, widgetPool(oldContent ?? null), pos, false);
1253
+ };
1254
+ const flags = old.flags & (TileFlag.Wrapper | TileFlag.Spanning);
1255
+ const tile = reactMarkTile(view, mark, flags, elementOf(old.dom, "a mark"), context, fill, false);
1256
+ markViews.set(tile, found);
1257
+ replaceTile(old, tile, context);
1258
+ };
1259
+ // Each ReactWordgard's DOM observer's MutationObserver, replaced so records
1260
+ // are sorted before Wordgard reads them (`sortRecords`), and the React shapes
1261
+ // whose structure changed.
1262
+ const shapeObservers = new WeakMap();
1263
+ export const observeShapes = (wg, queue) => {
1264
+ const changed = new Set();
1265
+ // Wordgard also takes records outside the flush (`focus`, through `clear`):
1266
+ // the shapes are rebuilt there too, before it reads the tiles.
1267
+ class ShapeObserver extends MutationObserver {
1268
+ takeRecords() {
1269
+ const kept = sortRecords(super.takeRecords(), changed, holdsText);
1270
+ rebuildShapes(wg);
1271
+ return kept;
1272
+ }
1273
+ // What the rebuild itself moved (widget DOM, strays), not an edit.
1274
+ discard() {
1275
+ super.takeRecords();
1276
+ }
1277
+ }
1278
+ const observer = new ShapeObserver((records) => {
1279
+ const kept = sortRecords(records, changed, holdsText);
1280
+ if (kept.length)
1281
+ queue(kept);
1282
+ rebuildShapes(wg);
1283
+ });
1284
+ shapeObservers.set(wg, { observer, changed, discard: () => observer.discard() });
1285
+ return observer;
1286
+ };
1287
+ // The React shapes whose structure changed, for the build in the flush.
1288
+ const takeShapeChanges = (wg) => {
1289
+ const found = shapeObservers.get(wg);
1290
+ if (!found)
1291
+ return new Set();
1292
+ found.observer.takeRecords();
1293
+ const changed = new Set(found.changed);
1294
+ found.changed.clear();
1295
+ return changed;
1296
+ };
1297
+ // Rebuilds, outside the flush, the tiles of the React shapes whose component
1298
+ // changed their structure; the flush's build does it inside.
1299
+ export const rebuildShapes = (wg) => {
1300
+ const found = shapeObservers.get(wg);
1301
+ if (!found || editorOf(wg).flushing || !roots.has(wg.contentDOM))
1302
+ return;
1303
+ if (!found.changed.size)
1304
+ return;
1305
+ rebuildRoots(found.changed, wg);
1306
+ found.changed.clear();
1307
+ found.discard();
1308
+ };
1309
+ // A node holding several changed roots (a React wrapper and the shape it
1310
+ // wraps) is rebuilt once, whole. A React mark's tile is rebuilt on its own.
1311
+ const rebuildRoots = (changed, wg) => {
1312
+ const tiles = new Set();
1313
+ for (const root of changed) {
1314
+ const mark = root.wgTile && markOfTile.has(root.wgTile) ? root.wgTile : undefined;
1315
+ const tile = root.isConnected ? (mark ?? nodeTileOf(root)) : undefined;
1316
+ if (tile)
1317
+ tiles.add(tile);
1318
+ }
1319
+ for (const tile of tiles)
1320
+ if (markOfTile.has(tile))
1321
+ rebuildMark(tile, wg);
1322
+ else
1323
+ rebuildShape(tile, wg);
1324
+ };