@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.
- package/CHANGELOG.md +41 -0
- package/LICENSE.txt +21 -0
- package/README.md +912 -2
- package/THIRD_PARTY_LICENSES.txt +93 -0
- package/dist/ReactWordgard.d.ts +13 -0
- package/dist/ReactWordgard.js +94 -0
- package/dist/changes.d.ts +14 -0
- package/dist/changes.js +163 -0
- package/dist/components/ChildNodeViews.d.ts +8 -0
- package/dist/components/ChildNodeViews.js +36 -0
- package/dist/components/DecorationRoot.d.ts +13 -0
- package/dist/components/DecorationRoot.js +31 -0
- package/dist/components/ErrorBoundary.d.ts +18 -0
- package/dist/components/ErrorBoundary.js +14 -0
- package/dist/components/SeparatorHackView.d.ts +2 -0
- package/dist/components/SeparatorHackView.js +4 -0
- package/dist/components/ShapeView.d.ts +12 -0
- package/dist/components/ShapeView.js +24 -0
- package/dist/components/TextNodeView.d.ts +5 -0
- package/dist/components/TextNodeView.js +4 -0
- package/dist/components/TrailingHackView.d.ts +2 -0
- package/dist/components/TrailingHackView.js +4 -0
- package/dist/components/WidgetView.d.ts +10 -0
- package/dist/components/WidgetView.js +32 -0
- package/dist/components/WordgardDoc.d.ts +9 -0
- package/dist/components/WordgardDoc.js +36 -0
- package/dist/components/WordgardEditor.d.ts +15 -0
- package/dist/components/WordgardEditor.js +17 -0
- package/dist/components/WordgardScope.d.ts +14 -0
- package/dist/components/WordgardScope.js +14 -0
- package/dist/components/WrapperView.d.ts +12 -0
- package/dist/components/WrapperView.js +23 -0
- package/dist/components/marks/MarkView.d.ts +11 -0
- package/dist/components/marks/MarkView.js +10 -0
- package/dist/components/marks/ReactMarkView.d.ts +10 -0
- package/dist/components/marks/ReactMarkView.js +30 -0
- package/dist/components/nodes/DefaultNodeView.d.ts +16 -0
- package/dist/components/nodes/DefaultNodeView.js +112 -0
- package/dist/components/nodes/DocNodeView.d.ts +7 -0
- package/dist/components/nodes/DocNodeView.js +5 -0
- package/dist/composition.d.ts +23 -0
- package/dist/composition.js +95 -0
- package/dist/contexts/DecorationContext.d.ts +15 -0
- package/dist/contexts/DecorationContext.js +35 -0
- package/dist/contexts/DecorationRootContext.d.ts +6 -0
- package/dist/contexts/DecorationRootContext.js +4 -0
- package/dist/contexts/EditorContext.d.ts +27 -0
- package/dist/contexts/EditorContext.js +87 -0
- package/dist/contexts/RenderStoreContext.d.ts +15 -0
- package/dist/contexts/RenderStoreContext.js +41 -0
- package/dist/contexts/editorEvents.d.ts +12 -0
- package/dist/contexts/editorEvents.js +92 -0
- package/dist/decorations/DecoIterator.d.ts +43 -0
- package/dist/decorations/DecoIterator.js +462 -0
- package/dist/decorations/ReactMarkType.d.ts +41 -0
- package/dist/decorations/ReactMarkType.js +47 -0
- package/dist/decorations/ReactShapeType.d.ts +65 -0
- package/dist/decorations/ReactShapeType.js +202 -0
- package/dist/decorations/ReactWidgetType.d.ts +29 -0
- package/dist/decorations/ReactWidgetType.js +67 -0
- package/dist/decorations/ReactWrapperType.d.ts +31 -0
- package/dist/decorations/ReactWrapperType.js +41 -0
- package/dist/decorations/iterDeco.d.ts +82 -0
- package/dist/decorations/iterDeco.js +406 -0
- package/dist/helpers/attributes.d.ts +2 -0
- package/dist/helpers/attributes.js +11 -0
- package/dist/helpers/index.d.ts +2 -0
- package/dist/helpers/index.js +2 -0
- package/dist/helpers/props.d.ts +8 -0
- package/dist/helpers/props.js +248 -0
- package/dist/helpers/refs.d.ts +6 -0
- package/dist/helpers/refs.js +20 -0
- package/dist/hooks/useEditorEffect.d.ts +10 -0
- package/dist/hooks/useEditorEffect.js +28 -0
- package/dist/hooks/useEditorEventCallback.d.ts +8 -0
- package/dist/hooks/useEditorEventCallback.js +17 -0
- package/dist/hooks/useEditorEventListener.d.ts +10 -0
- package/dist/hooks/useEditorEventListener.js +26 -0
- package/dist/hooks/useEditorLayoutEffect.d.ts +15 -0
- package/dist/hooks/useEditorLayoutEffect.js +34 -0
- package/dist/hooks/useEditorState.d.ts +6 -0
- package/dist/hooks/useEditorState.js +12 -0
- package/dist/hooks/useEditorStateSelector.d.ts +7 -0
- package/dist/hooks/useEditorStateSelector.js +11 -0
- package/dist/hooks/useIgnoreMutation.d.ts +6 -0
- package/dist/hooks/useIgnoreMutation.js +19 -0
- package/dist/hooks/useIsNodeSelected.d.ts +2 -0
- package/dist/hooks/useIsNodeSelected.js +5 -0
- package/dist/hooks/useMergedDOMRefs.d.ts +6 -0
- package/dist/hooks/useMergedDOMRefs.js +18 -0
- package/dist/hooks/useNodePos.d.ts +4 -0
- package/dist/hooks/useNodePos.js +7 -0
- package/dist/hooks/useSelectNode.d.ts +6 -0
- package/dist/hooks/useSelectNode.js +25 -0
- package/dist/hooks/useStopEvent.d.ts +8 -0
- package/dist/hooks/useStopEvent.js +21 -0
- package/dist/hooks/useWordgard.d.ts +2 -0
- package/dist/hooks/useWordgard.js +3 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +21 -0
- package/dist/reactKeys.d.ts +39 -0
- package/dist/reactKeys.js +188 -0
- package/dist/tile.d.ts +32 -0
- package/dist/tile.js +1319 -0
- package/dist/viewRoots.d.ts +2 -0
- package/dist/viewRoots.js +7 -0
- package/dist/wordgardInternals.d.ts +211 -0
- package/dist/wordgardInternals.js +82 -0
- package/package.json +119 -4
- package/skills/react-wordgard/SKILL.md +525 -0
- package/skills/react-wordgard/references/wordgard.md +189 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: react-wordgard
|
|
3
|
+
description: Build rich text editors with Wordgard and react-wordgard in a React app. Use when creating or changing a Wordgard editor drawn by React - hosting it, drawing nodes, marks, wrappers or widgets as React components, reading the editor's state in components, positioning menus and popovers, handling the editor's events, or migrating react-prosemirror node views.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Building editors with Wordgard and react-wordgard
|
|
7
|
+
|
|
8
|
+
react-wordgard is a React view for [Wordgard](https://wordgard.net/docs/), a rich text editor.
|
|
9
|
+
Wordgard runs the editor (document, state, input, selection, IME composition, decorations,
|
|
10
|
+
commands); react-wordgard has React draw its document, inside Wordgard's own update, in the app's
|
|
11
|
+
React tree, so the app's contexts reach what it draws. **The API is Wordgard's**: a customisation
|
|
12
|
+
is a Wordgard extension or decoration, and react-wordgard adds functions that let a React
|
|
13
|
+
component be the decoration.
|
|
14
|
+
|
|
15
|
+
Wordgard's own API: its docs (https://wordgard.net/docs/) and its typings in
|
|
16
|
+
`node_modules/wordgard/dist/*.d.ts` (`wordgard/editor`, `wordgard/state`, `wordgard/doc`,
|
|
17
|
+
`wordgard/schema`, `wordgard/types`, `wordgard/command`). react-wordgard's: its README and
|
|
18
|
+
`node_modules/@stesura/react-wordgard/dist/index.d.ts`. Read them rather than guessing a signature.
|
|
19
|
+
|
|
20
|
+
**New to Wordgard, or porting ProseMirror code?** Read `references/wordgard.md`, next to this file:
|
|
21
|
+
Wordgard's model (plots, leaves, tags, marks on tags), changes, configuration, commands and the
|
|
22
|
+
editor, each against its ProseMirror counterpart, with the traps.
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm install @stesura/react-wordgard wordgard react react-dom
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- Peers: `wordgard` `~0.6.2` (react-wordgard uses its internals: only its patches), React 18 or 19.
|
|
31
|
+
The app must have **one copy** of `wordgard`, or Wordgard's `instanceof` checks fail.
|
|
32
|
+
- TypeScript: Wordgard's typings need `skipLibCheck: true`.
|
|
33
|
+
- Name the editor for screen readers with `Wordgard.contentAttributes.of({ "aria-label": "…" })`
|
|
34
|
+
in the config (`Wordgard.label` names the wrong element in 0.6.2).
|
|
35
|
+
|
|
36
|
+
## Hosting the editor
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { WordgardDoc, WordgardEditor } from "@stesura/react-wordgard";
|
|
40
|
+
import { basicSchema } from "wordgard/schema";
|
|
41
|
+
|
|
42
|
+
export const Editor = () => (
|
|
43
|
+
<WordgardEditor doc="<p>Hello</p>" config={[basicSchema()]}>
|
|
44
|
+
<WordgardDoc className="editor" />
|
|
45
|
+
</WordgardEditor>
|
|
46
|
+
);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `WordgardEditor` takes a `Wordgard.Spec` (`doc` as HTML, `config` as extensions, or a `state`),
|
|
50
|
+
**read once**: the editor is uncontrolled and owns its state. Change the document through
|
|
51
|
+
`wg.dispatch(…)`, the configuration through Wordgard's compartments or
|
|
52
|
+
`GardState.appendConfig`, never by re-rendering with new props.
|
|
53
|
+
- `WordgardDoc` puts the editor in the page; one per editor. Components inside `WordgardEditor`
|
|
54
|
+
(toolbars, menus, sidebars, next to `WordgardDoc`) can use the hooks: below. `WordgardEditor`
|
|
55
|
+
draws no DOM, so wrap as much of the page in it as needs the editor.
|
|
56
|
+
- Don't create editors with `Wordgard.create` for a React app: react-wordgard's editor is made by
|
|
57
|
+
`WordgardEditor`. In StrictMode (development) it creates the editor twice and drops one, which is
|
|
58
|
+
never put in the page.
|
|
59
|
+
|
|
60
|
+
## Reading and changing the editor from components
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
import { Command, toggleStrong } from "wordgard/command";
|
|
64
|
+
import { useEditorEventCallback, useEditorStateSelector } from "@stesura/react-wordgard";
|
|
65
|
+
|
|
66
|
+
const Toolbar = () => {
|
|
67
|
+
const empty = useEditorStateSelector((state) => state.selection.empty);
|
|
68
|
+
const bold = useEditorEventCallback((wg) => Command.dispatch(wg, toggleStrong));
|
|
69
|
+
return (
|
|
70
|
+
<button
|
|
71
|
+
disabled={empty}
|
|
72
|
+
onMouseDown={(e) => {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
bold();
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
Bold
|
|
78
|
+
</button>
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- `useWordgard()`: the editor (`wg`).
|
|
84
|
+
- `useEditorState()`, `useEditorStateSelector(selector, isEqual?)`: the state as of the editor's
|
|
85
|
+
last update (the one the DOM shows). Prefer the selector: the component re-renders only when its
|
|
86
|
+
result changes (`Object.is`, or `isEqual`).
|
|
87
|
+
- `useEditorEventCallback((wg, ...args) => …)`: a stable callback getting the editor, for event
|
|
88
|
+
handlers. Commands run through `Command.dispatch(wg, command, param?)`; plain changes through
|
|
89
|
+
`wg.dispatch({ changes: { from, to, insert }, selection })`.
|
|
90
|
+
- `useEditorEventListener(type, (wg, event) => handled)`: the editor's DOM events of `type`, before
|
|
91
|
+
Wordgard's handlers and keymaps; return `true` to handle the event (Wordgard then calls
|
|
92
|
+
`preventDefault`). Types: `keydown`, `keyup`, `keypress`, `beforeinput`, `input`, composition
|
|
93
|
+
events, `mousedown`, `mouseup`, `mouseover`, `mouseout`, `click`, `dblclick`, `contextmenu`,
|
|
94
|
+
`focus`, `blur`, `copy`, `cut`, `paste`, drag-and-drop events. Key events during an IME
|
|
95
|
+
composition don't come. Handling `input`, `beforeinput` or a composition event skips Wordgard's
|
|
96
|
+
own handling of it: leave typing to Wordgard. Touch, wheel, pointer events and `mousemove` aren't
|
|
97
|
+
offered (they'd make every editor's listener non-passive or busy): for those, add a
|
|
98
|
+
`Wordgard.domEventHandler` to `config`, or a listener on `wg.contentDOM` from `useEditorEffect`.
|
|
99
|
+
- `wg.state` in a callback is the latest state, dispatches not yet drawn included. To save the
|
|
100
|
+
document: `wg.state.doc.toJSON()`; to load it, `state.schema.docFromJSON(json)` as `doc`.
|
|
101
|
+
|
|
102
|
+
### Reaching the editor from outside its `WordgardEditor`
|
|
103
|
+
|
|
104
|
+
The editor owns its state: there's no controlled `state` prop to lift into React, as with
|
|
105
|
+
react-prosemirror. The hooks throw outside `WordgardEditor`. Two ways:
|
|
106
|
+
|
|
107
|
+
1. **Lift `WordgardEditor`** over everything that needs the editor (it draws no DOM; the document
|
|
108
|
+
goes where `WordgardDoc` is). Prefer this.
|
|
109
|
+
2. **Share the editor through a store** where it can't wrap the component (another part of the
|
|
110
|
+
app, a portal): a component under `WordgardEditor` puts the editor in the store from
|
|
111
|
+
`useEditorEffect`, and the component outside reads it and uses `wg` directly (not the hooks).
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
import { atom, useAtomValue, useSetAtom } from "jotai";
|
|
115
|
+
import type { Wordgard } from "wordgard/editor";
|
|
116
|
+
import { basicSchema } from "wordgard/schema";
|
|
117
|
+
import { WordgardDoc, WordgardEditor, useEditorEffect } from "@stesura/react-wordgard";
|
|
118
|
+
|
|
119
|
+
const editorAtom = atom<Wordgard | null>(null);
|
|
120
|
+
|
|
121
|
+
const ShareEditor = () => {
|
|
122
|
+
const setEditor = useSetAtom(editorAtom);
|
|
123
|
+
useEditorEffect((wg) => {
|
|
124
|
+
setEditor(wg);
|
|
125
|
+
return () => setEditor(null);
|
|
126
|
+
}, []);
|
|
127
|
+
return null;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const SaveButton = () => {
|
|
131
|
+
const wg = useAtomValue(editorAtom);
|
|
132
|
+
const save = () => {
|
|
133
|
+
if (wg) localStorage.setItem("doc", JSON.stringify(wg.state.doc.toJSON()));
|
|
134
|
+
};
|
|
135
|
+
return (
|
|
136
|
+
<button onClick={save} disabled={!wg}>
|
|
137
|
+
Save
|
|
138
|
+
</button>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const App = () => (
|
|
143
|
+
<>
|
|
144
|
+
<header>
|
|
145
|
+
<SaveButton />
|
|
146
|
+
</header>
|
|
147
|
+
<WordgardEditor doc="<p>Hello</p>" config={[basicSchema()]}>
|
|
148
|
+
<ShareEditor />
|
|
149
|
+
<WordgardDoc />
|
|
150
|
+
</WordgardEditor>
|
|
151
|
+
</>
|
|
152
|
+
);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Any store works (Jotai here, Zustand, `useSyncExternalStore`). A sub-editor (a header's, a note's)
|
|
156
|
+
is its own `WordgardEditor`, often drawn inside a React shape of the main one; a toolbar acting on
|
|
157
|
+
whichever editor has focus keeps the focused one in such a store, set from
|
|
158
|
+
`useEditorEventListener("focus", …)` in each.
|
|
159
|
+
|
|
160
|
+
To react to changes without re-rendering (autosave, a debounced copy), add a
|
|
161
|
+
`Wordgard.updateListener.of((update) => …)` to `config`; `update.docChanged` and `update.state`
|
|
162
|
+
tell what changed. `config` is read once, so it should write to a store, not close over a
|
|
163
|
+
component's state.
|
|
164
|
+
|
|
165
|
+
### Effects and layout
|
|
166
|
+
|
|
167
|
+
Wordgard drives the update and React draws inside it, so there are two effect hooks:
|
|
168
|
+
|
|
169
|
+
| Hook | Runs | May dispatch | What it sets shows | For |
|
|
170
|
+
| ------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------ | ------------------ | ---------------------------------------------------- |
|
|
171
|
+
| `useEditorEffect((wg) => cleanup?, deps)` | after the editor's update | yes | a frame later | focusing, follow-up dispatches, menus |
|
|
172
|
+
| `useEditorLayoutEffect({ read: (wg) => value, write: (value, wg) => … })` | inside every update: `read` in Wordgard's read phase, `write` in its write phase | **no** (Wordgard throws) | on the same paint | what moves with the text: margin markers, pagination |
|
|
173
|
+
|
|
174
|
+
- **Never read the editor's layout (`coordsAtPos`, `posAtCoords`) in a plain React `useEffect` or
|
|
175
|
+
`useLayoutEffect`**: it runs inside Wordgard's update, before the view of the new DOM exists, and
|
|
176
|
+
react-wordgard refuses the update. Use one of the two hooks above.
|
|
177
|
+
- `useEditorLayoutEffect`'s `read` runs on every update: return `undefined` when nothing changed,
|
|
178
|
+
which skips the write and, when no read asks for one, Wordgard's second measure. Reads run in
|
|
179
|
+
registration order; an engine with ordered parts (pagination) registers one effect and drives
|
|
180
|
+
them.
|
|
181
|
+
- Menus and popovers: position them with your popover library (Floating UI, Radix, Base UI) from a
|
|
182
|
+
virtual anchor built from `wg.coordsAtPos(pos)` in `useEditorEffect`; show or hide them from
|
|
183
|
+
`useEditorStateSelector`. Wordgard's own tooltips and panels also work, as plain DOM.
|
|
184
|
+
|
|
185
|
+
## Drawing parts of the document with React
|
|
186
|
+
|
|
187
|
+
Prefer Wordgard's declarative shapes (`Elt` shapes, `Decoration.Tag.attribute`, mark shapes) for
|
|
188
|
+
anything that needs no code: React draws them all already. Use a React component where a part needs
|
|
189
|
+
state, interaction or the app's contexts. Each factory below is **called at module level**, with a
|
|
190
|
+
component defined there, and returns a Wordgard value you pass in `config`. `createShape`,
|
|
191
|
+
`createWrapper` and `createMarkShape` return the same value for the same component, so composing
|
|
192
|
+
the config again (per editor) is safe; a component created inside a render is new each time. The
|
|
193
|
+
components need no memoizing: React draws a node again only when it changed, or from the
|
|
194
|
+
component's own state, contexts and hooks.
|
|
195
|
+
|
|
196
|
+
| Need | Factory | Passed to |
|
|
197
|
+
| ------------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
198
|
+
| A node, with content or an atom (callout, image, mention) | `createShape(component)` | `Decoration.Tag.shape(Type, shape)`; `shape.of(element, { atom? })` for `Point.shape` |
|
|
199
|
+
| An element around a node or part of its shape | `createWrapper(component, { element })` | `Decoration.Tag.wrapper(Type, wrapper, { target? })`, `Decoration.Point.wrapper` |
|
|
200
|
+
| A mark's element (link, comment highlight) | `createMarkShape(component, { element })` | `Decoration.Mark.shape(MarkType, shape)` |
|
|
201
|
+
| A widget at a position or beside a node (placeholder, badge) | `createWidgetType({ component, … })` | `Decoration.Point.widget(MyType.of(value), { side })`, `Decoration.Tag.widget` |
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
import { Decoration } from "wordgard/editor";
|
|
205
|
+
import { basicSchema, blockquote, image } from "wordgard/schema";
|
|
206
|
+
import { Blockquote, Image, Link } from "wordgard/types";
|
|
207
|
+
import { DecorationRoot, createMarkShape, createShape } from "@stesura/react-wordgard";
|
|
208
|
+
|
|
209
|
+
const CalloutShape = createShape(function Callout({ nodeProps: { contentDOMRef }, children }) {
|
|
210
|
+
return (
|
|
211
|
+
<DecorationRoot>
|
|
212
|
+
<span aria-hidden="true">!</span>
|
|
213
|
+
<div ref={contentDOMRef}>{children}</div>
|
|
214
|
+
</DecorationRoot>
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const LinkMark = createMarkShape(
|
|
219
|
+
function LinkMark({ markProps: { mark, contentDOMRef }, children }) {
|
|
220
|
+
return (
|
|
221
|
+
<DecorationRoot href={String(mark.value)}>
|
|
222
|
+
<span ref={contentDOMRef}>{children}</span>
|
|
223
|
+
</DecorationRoot>
|
|
224
|
+
);
|
|
225
|
+
},
|
|
226
|
+
{ element: "a" }
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// An atom: no content, everything it draws is chrome.
|
|
230
|
+
const PictureShape = createShape(function Picture() {
|
|
231
|
+
return <DecorationRoot />;
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
export const config = [
|
|
235
|
+
basicSchema(),
|
|
236
|
+
blockquote(),
|
|
237
|
+
image(),
|
|
238
|
+
Decoration.Tag.shape(Blockquote, CalloutShape),
|
|
239
|
+
Decoration.Mark.shape(Link, LinkMark),
|
|
240
|
+
Decoration.Tag.shape(Image, PictureShape),
|
|
241
|
+
];
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
An image's `src` is its type's own attribute and its `alt` the `ImageAlt` mark's: `DecorationRoot`
|
|
245
|
+
puts both on the `<img>`. Check the parameter types of the node and mark types you use in
|
|
246
|
+
`wordgard/types`.
|
|
247
|
+
|
|
248
|
+
### The component's contract
|
|
249
|
+
|
|
250
|
+
- **The root is the element the decoration says, and stays that element.** For a shape, that's
|
|
251
|
+
the node type's own outer element, per node (`h1` to `h6` for a heading); for `.of`,
|
|
252
|
+
`createWrapper` and `createMarkShape`, their `element` (`div` for wrappers, `span` for marks, by
|
|
253
|
+
default). Wordgard applies decorations to the shape before React draws (`target` selectors
|
|
254
|
+
match its tag), so the tag can't come from the component. Re-render freely, but don't swap the
|
|
255
|
+
root for another element.
|
|
256
|
+
- **Draw it with `<DecorationRoot>`**, once, at the component's root. It renders that tag with
|
|
257
|
+
Wordgard's attributes (the node type's own, the selection classes `wg-selected-node` and
|
|
258
|
+
`wg-selected-cell`, tag, point and mark attributes, `contenteditable` on an atom) and the root's
|
|
259
|
+
ref. Its own props win, except `className`, joined with Wordgard's, and `style`, merged (its
|
|
260
|
+
keys winning); its `ref` gets the root too. It works on React 18 and 19 without `forwardRef`.
|
|
261
|
+
Widgets use it too (see "React widgets").
|
|
262
|
+
- **Or draw the root by hand**, for more control over the decoration's attributes (drop or change
|
|
263
|
+
one, type the root as its element): the element the decoration says, `{...props}` (Wordgard's
|
|
264
|
+
attributes) and `ref` (`forwardRef` on React 18), never both ways at once. Join your classes with
|
|
265
|
+
`className` (Wordgard's, the selection's among them), and spread your style over `style`
|
|
266
|
+
(Wordgard's, as an object). On React 19, `ref` is a prop:
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
269
|
+
const ImageShape = createShape<HTMLImageElement>(function ImageShape({
|
|
270
|
+
nodeProps,
|
|
271
|
+
children,
|
|
272
|
+
className,
|
|
273
|
+
style,
|
|
274
|
+
ref,
|
|
275
|
+
...props
|
|
276
|
+
}) {
|
|
277
|
+
return (
|
|
278
|
+
<img
|
|
279
|
+
{...props}
|
|
280
|
+
ref={ref}
|
|
281
|
+
className={["picture", className].join(" ").trim()}
|
|
282
|
+
style={{ ...style, outline: "1px solid" }}
|
|
283
|
+
/>
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
On React 18, with `forwardRef`:
|
|
289
|
+
|
|
290
|
+
```tsx
|
|
291
|
+
const ImageShape = createShape(
|
|
292
|
+
forwardRef<HTMLImageElement, ShapeComponentProps<HTMLImageElement>>(function ImageShape(
|
|
293
|
+
{ nodeProps, children, className, style, ...props },
|
|
294
|
+
ref
|
|
295
|
+
) {
|
|
296
|
+
return (
|
|
297
|
+
<img
|
|
298
|
+
{...props}
|
|
299
|
+
ref={ref}
|
|
300
|
+
className={["picture", className].join(" ").trim()}
|
|
301
|
+
style={{ ...style, outline: "1px solid" }}
|
|
302
|
+
/>
|
|
303
|
+
);
|
|
304
|
+
})
|
|
305
|
+
);
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
- **Only the outer element comes from the node type.** The elements inside a structure shape (a
|
|
309
|
+
figure's `img`) are the component's to draw, from `nodeProps.node`; attributes aimed at them
|
|
310
|
+
arrive on the root (see "Should I use a shape, a wrapper or both?").
|
|
311
|
+
- **Content goes where `contentDOMRef` points**, else in the root. `children` is the content (a
|
|
312
|
+
wrapper's `children` is what it wraps; no `contentDOMRef` there).
|
|
313
|
+
- **An atom** (a leaf, or a plot whose shape has no hole) has no content: `children` is empty,
|
|
314
|
+
and a `contentDOMRef` throws. `.of(element, { atom: true })` has no hole: a leaf needs it (for
|
|
315
|
+
`Decoration.Point.shape` before a leaf), and it draws a plot as an atom.
|
|
316
|
+
- Everything else the component draws is **chrome**: made uneditable, never read as document
|
|
317
|
+
content, free to change from the component's state, a context or a store.
|
|
318
|
+
- In a textblock (a paragraph-like node) or a mark, the content element holds only the content.
|
|
319
|
+
Chrome beside a textblock's text (line numbers) is a React widget at its `"start"` or `"end"`:
|
|
320
|
+
`Decoration.Tag.widget(Paragraph, "start", LineNumber.of(null))` (see "React widgets").
|
|
321
|
+
- In a node holding blocks, chrome may sit before or after the content in its element, not
|
|
322
|
+
between the content's nodes.
|
|
323
|
+
- Moving `{children}` under another element remounts the content (a selection or IME
|
|
324
|
+
composition in it ends).
|
|
325
|
+
- Node parameters: `nodeProps.node.tag.param` for a node with content, `node.param` for a leaf;
|
|
326
|
+
`markProps.mark` is the document's mark (`mark.value`).
|
|
327
|
+
- `createWrapper` can't wrap a node a native widget draws (draw it with a React shape or widget), nor text.
|
|
328
|
+
- **Don't write attributes on document elements by hand.** On a React decoration's root they
|
|
329
|
+
survive only until React sets that attribute (a `classList.add` is lost when the selection
|
|
330
|
+
changes `className`); on elements Wordgard draws they're undone at the next update. Put the
|
|
331
|
+
state in the component (`data-resizing={resizing}`), or in a decoration from a state field.
|
|
332
|
+
|
|
333
|
+
### React widgets
|
|
334
|
+
|
|
335
|
+
```tsx
|
|
336
|
+
import { Decoration } from "wordgard/editor";
|
|
337
|
+
import { Paragraph } from "wordgard/types";
|
|
338
|
+
import {
|
|
339
|
+
DecorationRoot,
|
|
340
|
+
type WidgetComponentProps,
|
|
341
|
+
createWidgetType,
|
|
342
|
+
} from "@stesura/react-wordgard";
|
|
343
|
+
|
|
344
|
+
// The root is a DecorationRoot, a <span> unless the spec gives an `element`.
|
|
345
|
+
const Badge = createWidgetType({
|
|
346
|
+
component: function Badge({ value }: WidgetComponentProps<string>) {
|
|
347
|
+
return (
|
|
348
|
+
<DecorationRoot className="badge" aria-hidden="true">
|
|
349
|
+
{value}
|
|
350
|
+
</DecorationRoot>
|
|
351
|
+
);
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
export const badges = [
|
|
356
|
+
Decoration.Tag.widget(Paragraph, "start", Badge.of("¶")),
|
|
357
|
+
Decoration.Point.source.of(() =>
|
|
358
|
+
Decoration.Point.set([[Decoration.Point.widget(Badge.of("!")), 1]])
|
|
359
|
+
),
|
|
360
|
+
];
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
- `createWidgetType({ component, element?, eq?, editable?, inFlow?, propagateEvent?, connect?,
|
|
364
|
+
disconnect? })` returns a real `Widget.Type`: its widgets are `Type.of(value)`. The component
|
|
365
|
+
gets `value`.
|
|
366
|
+
- **Draw its root with `<DecorationRoot>`**: `element` (`span` by default), the ref, and the
|
|
367
|
+
node's attributes for a widget drawing a node. Or by hand: any element, with the `ref` the
|
|
368
|
+
component gets (`forwardRef` on React 18), which it **must** put on its root.
|
|
369
|
+
- Widgets are uneditable unless their spec sets `editable`, as in Wordgard. A React widget
|
|
370
|
+
re-renders freely while it keeps its root element.
|
|
371
|
+
- A widget drawing a node (`Decoration.Tag.shape(Type, (tag) => W.of(tag.param))`):
|
|
372
|
+
`DecorationRoot` puts the node's attributes on its root; a root drawn by hand gets them as
|
|
373
|
+
props, to spread. A React shape is the usual way to draw a node.
|
|
374
|
+
- A widget's component may read a context a React shape around it provides (line numbers beside a
|
|
375
|
+
paragraph's text, from the paragraph's shape).
|
|
376
|
+
|
|
377
|
+
### Should I use a shape, a wrapper or both?
|
|
378
|
+
|
|
379
|
+
A shape draws the node; a wrapper adds React around it. Unlike a ProseMirror node view, which picks
|
|
380
|
+
all its DOM, a node's outer element comes from its type's shape in the schema: Wordgard applies
|
|
381
|
+
marks and decorations to that shape (`target`, `preferTarget`) before React draws. A React shape
|
|
382
|
+
that draws more than its root gets the attributes aimed inside it on its root (a figure drawing its
|
|
383
|
+
own `img` gets `ImageAlt` and `ImageSize` on the `<figure>`). So the shape's root matches the node
|
|
384
|
+
type's element (`DecorationRoot`, or a root drawn by hand), and chrome around it is a wrapper:
|
|
385
|
+
|
|
386
|
+
```tsx
|
|
387
|
+
import { Decoration } from "wordgard/editor";
|
|
388
|
+
import { Image } from "wordgard/types";
|
|
389
|
+
import { DecorationRoot, createShape, createWrapper, useStopEvent } from "@stesura/react-wordgard";
|
|
390
|
+
|
|
391
|
+
// The shape draws the <img>, which keeps its src, alt and width.
|
|
392
|
+
const ImageShape = createShape(function ImageShape() {
|
|
393
|
+
return <DecorationRoot className="image" />;
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// The wrapper draws the resize handle around it.
|
|
397
|
+
const Resizer = createWrapper(
|
|
398
|
+
function Resizer({ children }) {
|
|
399
|
+
useStopEvent(() => true);
|
|
400
|
+
return (
|
|
401
|
+
<DecorationRoot className="resizer">
|
|
402
|
+
{children}
|
|
403
|
+
<span className="resize-handle" aria-hidden="true" />
|
|
404
|
+
</DecorationRoot>
|
|
405
|
+
);
|
|
406
|
+
},
|
|
407
|
+
{ element: "span" } // an image is inline
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
export const imageConfig = [
|
|
411
|
+
Decoration.Tag.shape(Image, ImageShape),
|
|
412
|
+
Decoration.Tag.wrapper(Image, Resizer),
|
|
413
|
+
];
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
- A wrapper can also aim at an element inside the type's shape:
|
|
417
|
+
`Decoration.Tag.wrapper(Figure, createWrapper(Frame), { target: "img" })`.
|
|
418
|
+
- A shape and wrappers on the same type apply to all its nodes. A table: a React shape drawing
|
|
419
|
+
`table > tbody > {children}`, plus a wrapper for a scroll container around it.
|
|
420
|
+
- **One `Decoration.Tag.shape` per node type** draws it: where several features draw the same type
|
|
421
|
+
(react-prosemirror apps merging node views), fold their components into one in the app's code
|
|
422
|
+
and register that, or give each feature a wrapper or a tag widget of its own.
|
|
423
|
+
- Porting a react-prosemirror node view that draws chrome around the node's own element: keep the
|
|
424
|
+
type's shape (or a React shape for the element), and move the chrome into a wrapper.
|
|
425
|
+
|
|
426
|
+
### Changing a node without remounting it
|
|
427
|
+
|
|
428
|
+
A component keeps its state while its node changes in place or moves within its parent; it
|
|
429
|
+
remounts, with everything inside, when the node is replaced whole, changes type or moves to another
|
|
430
|
+
parent.
|
|
431
|
+
|
|
432
|
+
- **Data an action changes in place** (size, alignment, a callout's kind, a table's widths): a mark
|
|
433
|
+
on the tag, `Mark.Type.define(name, { target: NodeType, … })`, read with
|
|
434
|
+
`node.tag.mark(Type)`, changed with `wg.dispatch({ changes: { from: pos, add: Type.of(v) } })`
|
|
435
|
+
or `{ from: pos, remove: Type.of(v) }`. It merges in collaboration and inverts in history.
|
|
436
|
+
- **What identifies the node** (an image's `src`, a heading's level): its parameter, changed by
|
|
437
|
+
retagging the opening token, `{ from: pos, to: pos + 1, insert: [Heading.of(2)] }`.
|
|
438
|
+
- **Never** replace the whole node (`{ from, to, insert: [node] }`) to change its data: that
|
|
439
|
+
remounts the component.
|
|
440
|
+
- ProseMirror's `setNodeAttribute` / `setNodeMarkup` port to one of the first two.
|
|
441
|
+
- **Moving a node**: delete it and insert the same node object elsewhere in the same parent, in one
|
|
442
|
+
transaction (`changes: [{ from, to: from + node.length }, { from: target, insert: [node] }]`).
|
|
443
|
+
A copy (a new object, or one inserted while the original stays) mounts anew. React moves the
|
|
444
|
+
DOM, so an `<iframe>` in a moved node reloads. Wordgard's drag-and-drop rebuilds what it drops
|
|
445
|
+
from HTML: those moves remount.
|
|
446
|
+
|
|
447
|
+
### Hooks inside React decorations
|
|
448
|
+
|
|
449
|
+
- `useNodePos()`: a function returning the position of what the decoration draws (call it in
|
|
450
|
+
handlers and effects, not during render).
|
|
451
|
+
- `useIsNodeSelected()`, `useSelectNode(select, deselect?)`: the node selection.
|
|
452
|
+
- `useStopEvent((wg, event) => stop)`: asked for events in the decoration's chrome (inputs,
|
|
453
|
+
buttons in a title bar), on its own elements (its root and the elements around its content
|
|
454
|
+
element: an atom's root, a frame's padding; not a root that is itself the content element) and
|
|
455
|
+
in React widgets. Never for events in the content, unlike react-prosemirror. For an event it
|
|
456
|
+
stops, Wordgard's handlers and keymaps are skipped and the browser's default goes ahead.
|
|
457
|
+
- `useIgnoreMutation((wg, record) => ignore)`: Wordgard doesn't read the DOM mutations it claims
|
|
458
|
+
(typing aside: Wordgard takes it from `beforeinput`).
|
|
459
|
+
- `useMergedDOMRefs(...refs)`: one ref setting several (the forwarded `ref` and `contentDOMRef`
|
|
460
|
+
on one element, a measuring ref).
|
|
461
|
+
|
|
462
|
+
## Accessibility
|
|
463
|
+
|
|
464
|
+
- The editable element has `role="textbox"` and `aria-multiline`; give it a name (Setup).
|
|
465
|
+
- Chrome is read inline with the document's text: `aria-hidden` on decorative parts, a name on
|
|
466
|
+
every control, controls focusable and wrapped in `useStopEvent` so Wordgard leaves their keys.
|
|
467
|
+
|
|
468
|
+
## Migrating react-prosemirror node views
|
|
469
|
+
|
|
470
|
+
There are no node views: a node is drawn by decorations (its shape and wrappers), and a node view
|
|
471
|
+
component becomes one with `createShape` or `createWrapper`.
|
|
472
|
+
|
|
473
|
+
| react-prosemirror | react-wordgard |
|
|
474
|
+
| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
|
|
475
|
+
| `<ProseMirror state dispatchTransaction>` | `<WordgardEditor doc config>`, uncontrolled; changes via `wg.dispatch` |
|
|
476
|
+
| `<ProseMirrorDoc />` | `<WordgardDoc />` |
|
|
477
|
+
| `nodeViews` | `Decoration.Tag.shape(Type, createShape(component))` in `config`, atoms included |
|
|
478
|
+
| `markViews` | `createMarkShape` with `Decoration.Mark.shape` |
|
|
479
|
+
| `NodeViewComponentProps` | `ShapeComponentProps` (same `nodeProps`, `contentDOMRef`, `children`, `ref`) |
|
|
480
|
+
| `node.attrs` | `node.tag.param` for identity, `node.tag.mark(Type)` for data changed in place |
|
|
481
|
+
| `view` in callbacks | `wg` |
|
|
482
|
+
| `ProseMirror-selectednode`, `selectedCell` | `wg-selected-node`, `wg-selected-cell` |
|
|
483
|
+
| `useNodePos()` returning a number | `useNodePos()` returning a function |
|
|
484
|
+
| `useEditorEffect` for same-paint layout | `useEditorLayoutEffect` |
|
|
485
|
+
| `useStopEvent`, `useIgnoreMutation`, `useSelectNode`, `useEditorEventListener` | the same names, the editor first in callbacks |
|
|
486
|
+
| `useIsNodeSelected`, `useMergedDOMRefs` | the same |
|
|
487
|
+
| `innerDecorations`, `useIsComposingIn`, `useIsEditorStatic` | none |
|
|
488
|
+
|
|
489
|
+
ProseMirror's model maps to Wordgard's in its migration guide:
|
|
490
|
+
https://wordgard.net/docs/prosemirror/
|
|
491
|
+
|
|
492
|
+
## When something fails
|
|
493
|
+
|
|
494
|
+
- "…must draw a DecorationRoot, or forward its ref to its root element": the component's root is
|
|
495
|
+
neither a `DecorationRoot` nor given `ref` (on React 18, a hand-written root needs `forwardRef`).
|
|
496
|
+
- "…drew <x> where its shape says <y>": draw the element the shape says (the node type's, or
|
|
497
|
+
`element`), or pass the drawn one to the shape's `of`.
|
|
498
|
+
- "React's DOM has extra nodes" or "…chrome is between its content's nodes": chrome sits in a
|
|
499
|
+
content element where it can't (see the contract).
|
|
500
|
+
- "A React widget's component must forward its ref to its root element": a widget drawing its
|
|
501
|
+
root by hand needs `forwardRef` (React 18) and `ref` on its root; or use `DecorationRoot`.
|
|
502
|
+
- "DecorationRoot is the root of a React shape, wrapper, mark or widget: use it in their
|
|
503
|
+
component": a `DecorationRoot` outside those components (in a toolbar, say).
|
|
504
|
+
- "A React wrapper's component must draw its children": a wrapper drew nothing where it wraps.
|
|
505
|
+
- "A React shape drawing an atom has no content: drop its contentDOMRef": the node type draws an
|
|
506
|
+
atom (a leaf, a plot without a hole), so its React shape has no content element.
|
|
507
|
+
- "A React shape can't wrap: use createWrapper", "A React wrapper isn't a shape: use createShape":
|
|
508
|
+
a factory's value went to the other decoration.
|
|
509
|
+
- "…is called outside a React shape, wrapper, mark or widget": a decoration hook (`useNodePos`,
|
|
510
|
+
`useStopEvent`, `useIgnoreMutation`, `useSelectNode`, `useIsNodeSelected`) used elsewhere.
|
|
511
|
+
"Used outside a Wordgard editor": an editor hook outside `WordgardEditor`.
|
|
512
|
+
- "This React shape or wrapper isn't drawn" (or widget, or mark): `useNodePos()`'s function called
|
|
513
|
+
during render or after unmount; call it in handlers and effects.
|
|
514
|
+
- "Leaf nodes shapes shouldn't have a content hole": a React shape's `.of(element)` given to a
|
|
515
|
+
leaf; use `.of(element, { atom: true })`.
|
|
516
|
+
- "A React wrapper can't wrap a native widget: draw … with a React widget", "A React wrapper
|
|
517
|
+
can't wrap text": a wrapper around a node a native widget draws, or around text.
|
|
518
|
+
- "This editor is already rendered by another WordgardDoc": one `WordgardDoc` per editor.
|
|
519
|
+
- "react-wordgard can't render part of …", "…can't render the shape of … yet": a shape or
|
|
520
|
+
decoration react-wordgard doesn't draw yet; the message names the node type.
|
|
521
|
+
- "ReactWordgard can't update while React renders or commits": layout was read from a plain React
|
|
522
|
+
effect; use `useEditorEffect` or `useEditorLayoutEffect`.
|
|
523
|
+
- "Cannot dispatch new updates during the editor flush phase": a dispatch from
|
|
524
|
+
`useEditorLayoutEffect` or a render; dispatch from an event handler or `useEditorEffect`.
|
|
525
|
+
- Two copies of `wordgard` (`npm ls wordgard`) break `instanceof` checks in odd places.
|