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