@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
package/README.md
CHANGED
|
@@ -1,3 +1,913 @@
|
|
|
1
|
-
#
|
|
1
|
+
# react-wordgard
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Under construction.** react-wordgard isn't ready for production yet, and its API may still
|
|
4
|
+
> change.
|
|
5
|
+
|
|
6
|
+
react-wordgard stays on 0.x for as long as Wordgard does: it follows Wordgard's API and reaches
|
|
7
|
+
into its internals, so it can't be more stable than Wordgard. Its 1.0 comes after Wordgard's.
|
|
8
|
+
|
|
9
|
+
A React view for [Wordgard](https://www.wordgard.net): React draws the Wordgard document in a
|
|
10
|
+
single tree, your app's, and any decoration that takes a shape, a wrapper or a widget can be a
|
|
11
|
+
React component. Wordgard keeps doing the rest (input, selection, composition), and the API stays
|
|
12
|
+
Wordgard's.
|
|
13
|
+
|
|
14
|
+
A React decoration renders its content as `{children}`, reads your app's contexts and stores, and
|
|
15
|
+
can re-render on its own without breaking the editor.
|
|
16
|
+
|
|
17
|
+
## Contents
|
|
18
|
+
|
|
19
|
+
- [Inspired by react-prosemirror](#inspired-by-react-prosemirror)
|
|
20
|
+
- [Install](#install)
|
|
21
|
+
- [Usage](#usage)
|
|
22
|
+
- [Accessing the state outside the editor](#accessing-the-state-outside-the-editor)
|
|
23
|
+
- [React decorations](#react-decorations)
|
|
24
|
+
- [Should I use a shape, a wrapper or both?](#should-i-use-a-shape-a-wrapper-or-both)
|
|
25
|
+
- [Accessibility](#accessibility)
|
|
26
|
+
- [Limits](#limits)
|
|
27
|
+
- [API](#api)
|
|
28
|
+
- [Components](#components)
|
|
29
|
+
- [Decoration factories](#decoration-factories)
|
|
30
|
+
- [Editor hooks](#editor-hooks)
|
|
31
|
+
- [Decoration hooks](#decoration-hooks)
|
|
32
|
+
- [Props types](#props-types)
|
|
33
|
+
- [Migrating from react-prosemirror](#migrating-from-react-prosemirror)
|
|
34
|
+
- [Migrating a node view](#migrating-a-node-view)
|
|
35
|
+
- [Effects and timing](#effects-and-timing)
|
|
36
|
+
- [AI coding agents](#ai-coding-agents)
|
|
37
|
+
- [Licence](#licence)
|
|
38
|
+
|
|
39
|
+
## Inspired by react-prosemirror
|
|
40
|
+
|
|
41
|
+
react-wordgard is derived from
|
|
42
|
+
[react-prosemirror](https://github.com/handlewithcarecollective/react-prosemirror), by
|
|
43
|
+
[Handle with Care Collective](https://github.com/handlewithcarecollective) and originally The New
|
|
44
|
+
York Times Company. We're hugely grateful for their work: the design here builds on it.
|
|
45
|
+
|
|
46
|
+
Marijn Haverbeke's Wordgard makes the bridge much easier to build. Most of react-prosemirror's
|
|
47
|
+
difficulties come from React rendering in two phases while ProseMirror's view updates in one: during
|
|
48
|
+
a render, components can see a newer state than the view
|
|
49
|
+
([react-prosemirror's README](https://github.com/handlewithcarecollective/react-prosemirror#the-problem)
|
|
50
|
+
explains the problem). Wordgard doesn't update the DOM when you dispatch, but in its flush, on the
|
|
51
|
+
next frame (or sooner, when something needs the layout). react-wordgard renders the document inside
|
|
52
|
+
that flush, in one synchronous render, and its hooks read the state of the last flush: components
|
|
53
|
+
never see a state the page doesn't show. Wordgard also tells its view which parts of the document
|
|
54
|
+
changed and reads its view tree as plain data, so react-wordgard can build that tree from the DOM
|
|
55
|
+
React drew.
|
|
56
|
+
|
|
57
|
+
What Wordgard doesn't give you is React in its decorations: a shape, wrapper or widget is DOM, and
|
|
58
|
+
a React component there would mean portals into DOM that Wordgard owns and rewrites. That's what
|
|
59
|
+
react-wordgard is for.
|
|
60
|
+
|
|
61
|
+
The API stays as close to react-prosemirror's as Wordgard allows.
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
npm install @stesura/react-wordgard wordgard react react-dom
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- `wordgard` 0.6.2 or a later 0.6 patch. A new minor (0.7) needs a react-wordgard release made
|
|
70
|
+
for it, as react-wordgard uses Wordgard's internals.
|
|
71
|
+
- `react` and `react-dom` 18 or 19.
|
|
72
|
+
- Only one copy of `wordgard` in your app: `npm ls wordgard` should list one version. With two, the
|
|
73
|
+
editor won't work.
|
|
74
|
+
- With TypeScript, `"skipLibCheck": true` in your `tsconfig.json`: Wordgard's own typings don't
|
|
75
|
+
type-check ([#120](https://code.haverbeke.berlin/wordgard/wordgard/issues/120)).
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
import { useState } from "react";
|
|
81
|
+
import { Decoration, Wordgard } from "wordgard/editor";
|
|
82
|
+
import { basicSchema, image } from "wordgard/schema";
|
|
83
|
+
import { Image } from "wordgard/types";
|
|
84
|
+
import {
|
|
85
|
+
DecorationRoot,
|
|
86
|
+
WordgardDoc,
|
|
87
|
+
WordgardEditor,
|
|
88
|
+
createShape,
|
|
89
|
+
useEditorStateSelector,
|
|
90
|
+
} from "@stesura/react-wordgard";
|
|
91
|
+
|
|
92
|
+
const ImageShape = createShape(function ImageShape() {
|
|
93
|
+
const [hover, setHover] = useState(false);
|
|
94
|
+
return (
|
|
95
|
+
// DecorationRoot is its root: the node type's element, an <img>, with the node's attributes (its
|
|
96
|
+
// `src` and `alt`, the selected node's class) and the component's own props.
|
|
97
|
+
<DecorationRoot
|
|
98
|
+
style={hover ? { outline: "2px solid" } : undefined}
|
|
99
|
+
onMouseEnter={() => setHover(true)}
|
|
100
|
+
onMouseLeave={() => setHover(false)}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const WordCount = () => {
|
|
106
|
+
const words = useEditorStateSelector((state) => state.doc.textContent().split(/\s+/).length);
|
|
107
|
+
return <output>{words} words</output>;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const Editor = () => {
|
|
111
|
+
return (
|
|
112
|
+
<WordgardEditor
|
|
113
|
+
doc='<p>Hello <strong>world</strong> <img src="/cat.png" alt="A cat"></p>'
|
|
114
|
+
config={[
|
|
115
|
+
basicSchema(),
|
|
116
|
+
image(),
|
|
117
|
+
Decoration.Tag.shape(Image, ImageShape),
|
|
118
|
+
Wordgard.contentAttributes.of({ "aria-label": "Document" }),
|
|
119
|
+
]}
|
|
120
|
+
>
|
|
121
|
+
<WordCount />
|
|
122
|
+
<WordgardDoc className="editor" />
|
|
123
|
+
</WordgardEditor>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`<WordgardEditor>` creates the editor and `<WordgardDoc>` puts it in the page; React decorations
|
|
129
|
+
are below, and every component and hook is under [API](#api).
|
|
130
|
+
|
|
131
|
+
### Accessing the state outside the editor
|
|
132
|
+
|
|
133
|
+
Wordgard's editor owns its state: there's no `setState` and no way to intercept its dispatches, so
|
|
134
|
+
the state can't be lifted into React and passed back in, as react-prosemirror's
|
|
135
|
+
`<ProseMirror state>` allows. To reach it from outside the document, to save it as JSON for
|
|
136
|
+
example, you have two options:
|
|
137
|
+
|
|
138
|
+
1. **Lift `<WordgardEditor>`.** It draws no DOM: the editor goes where `<WordgardDoc />` is. Wrap
|
|
139
|
+
everything that needs the editor in it:
|
|
140
|
+
|
|
141
|
+
```tsx
|
|
142
|
+
import { basicSchema } from "wordgard/schema";
|
|
143
|
+
import { WordgardDoc, WordgardEditor, useEditorEventCallback } from "@stesura/react-wordgard";
|
|
144
|
+
|
|
145
|
+
const SaveButton = () => {
|
|
146
|
+
const save = useEditorEventCallback((wg) => {
|
|
147
|
+
localStorage.setItem("doc", JSON.stringify(wg.state.doc.toJSON()));
|
|
148
|
+
});
|
|
149
|
+
return <button onClick={save}>Save</button>;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const Page = () => (
|
|
153
|
+
<WordgardEditor doc="<p>Hello</p>" config={[basicSchema()]}>
|
|
154
|
+
<header>
|
|
155
|
+
<SaveButton />
|
|
156
|
+
</header>
|
|
157
|
+
<main>
|
|
158
|
+
<WordgardDoc />
|
|
159
|
+
</main>
|
|
160
|
+
</WordgardEditor>
|
|
161
|
+
);
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
2. **Share the editor through a store.** Where `<WordgardEditor>` can't wrap the button (another
|
|
165
|
+
part of the app, a portal), a component under it hands the editor to a store from
|
|
166
|
+
`useEditorEffect`. This example uses [Jotai](https://jotai.org), but any store works.
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
import { atom, useAtomValue, useSetAtom } from "jotai";
|
|
170
|
+
import type { Wordgard } from "wordgard/editor";
|
|
171
|
+
import { basicSchema } from "wordgard/schema";
|
|
172
|
+
import { WordgardDoc, WordgardEditor, useEditorEffect } from "@stesura/react-wordgard";
|
|
173
|
+
|
|
174
|
+
const editorAtom = atom<Wordgard | null>(null);
|
|
175
|
+
|
|
176
|
+
const ShareEditor = () => {
|
|
177
|
+
const setEditor = useSetAtom(editorAtom);
|
|
178
|
+
useEditorEffect((wg) => {
|
|
179
|
+
setEditor(wg);
|
|
180
|
+
return () => setEditor(null);
|
|
181
|
+
}, []);
|
|
182
|
+
return null;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const SaveButton = () => {
|
|
186
|
+
const wg = useAtomValue(editorAtom);
|
|
187
|
+
const save = () => {
|
|
188
|
+
if (wg) localStorage.setItem("doc", JSON.stringify(wg.state.doc.toJSON()));
|
|
189
|
+
};
|
|
190
|
+
return (
|
|
191
|
+
<button onClick={save} disabled={!wg}>
|
|
192
|
+
Save
|
|
193
|
+
</button>
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const Editor = () => (
|
|
198
|
+
<WordgardEditor doc="<p>Hello</p>" config={[basicSchema()]}>
|
|
199
|
+
<ShareEditor />
|
|
200
|
+
<WordgardDoc />
|
|
201
|
+
</WordgardEditor>
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// The button is outside the editor's tree.
|
|
205
|
+
export const App = () => (
|
|
206
|
+
<>
|
|
207
|
+
<header>
|
|
208
|
+
<SaveButton />
|
|
209
|
+
</header>
|
|
210
|
+
<main>
|
|
211
|
+
<Editor />
|
|
212
|
+
</main>
|
|
213
|
+
</>
|
|
214
|
+
);
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### React decorations
|
|
218
|
+
|
|
219
|
+
Node decorations (shapes, wrappers, widgets) and mark decorations can be React components. Shapes,
|
|
220
|
+
wrappers and marks render their content as `children`, and can change the DOM around it on their
|
|
221
|
+
own, from their state, a context or a store. Call the factories at module level: a component
|
|
222
|
+
defined inside a render is new each time, and remounts everything it draws. The components don't
|
|
223
|
+
need memoizing.
|
|
224
|
+
|
|
225
|
+
```tsx
|
|
226
|
+
import { Decoration } from "wordgard/editor";
|
|
227
|
+
import { Blockquote, Link } from "wordgard/types";
|
|
228
|
+
import { DecorationRoot, createMarkShape, createShape } from "@stesura/react-wordgard";
|
|
229
|
+
|
|
230
|
+
// A blockquote drawn as a callout: a decorative icon, then the content.
|
|
231
|
+
const CalloutShape = createShape(function Callout({ nodeProps: { contentDOMRef }, children }) {
|
|
232
|
+
return (
|
|
233
|
+
<DecorationRoot>
|
|
234
|
+
<span aria-hidden="true">💡</span>
|
|
235
|
+
<div className="body" ref={contentDOMRef}>
|
|
236
|
+
{children}
|
|
237
|
+
</div>
|
|
238
|
+
</DecorationRoot>
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// A link, its target the mark's value.
|
|
243
|
+
const LinkMark = createMarkShape(
|
|
244
|
+
function LinkMark({ markProps: { mark, contentDOMRef }, children }) {
|
|
245
|
+
return (
|
|
246
|
+
<DecorationRoot href={String(mark.value)}>
|
|
247
|
+
<span ref={contentDOMRef}>{children}</span>
|
|
248
|
+
</DecorationRoot>
|
|
249
|
+
);
|
|
250
|
+
},
|
|
251
|
+
{ element: "a" }
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const config = [
|
|
255
|
+
Decoration.Tag.shape(Blockquote, CalloutShape),
|
|
256
|
+
Decoration.Mark.shape(Link, LinkMark),
|
|
257
|
+
];
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
- `createShape(component)`, for `Decoration.Tag.shape`: the component gets `nodeProps` (`node`,
|
|
261
|
+
`getPos`, `contentDOMRef`) and the content as `children`, which goes in the `contentDOMRef`
|
|
262
|
+
element, else in the root. Define it inline, as a named function, and its props are typed for you.
|
|
263
|
+
- The root is the node type's outer element (`h1` to `h6` for a heading, `img` for an image).
|
|
264
|
+
`<DecorationRoot>` draws it with Wordgard's attributes (the node's, the selection classes, the
|
|
265
|
+
decorations') and its ref; your props win, `className` and `style` are merged. Or draw it by
|
|
266
|
+
hand: below. Another tag logs an error.
|
|
267
|
+
- What's inside the root (a figure's `img`, a table's `tbody`) is the component's to draw, and
|
|
268
|
+
attributes aimed there land on the root: see
|
|
269
|
+
[Should I use a shape, a wrapper or both?](#should-i-use-a-shape-a-wrapper-or-both).
|
|
270
|
+
- An atom (an image, a mention) gets empty `children`, and a `contentDOMRef` throws.
|
|
271
|
+
- `shape.of(element, { atom? })` is the shape with another root, also for
|
|
272
|
+
`Decoration.Point.shape`: `CalloutShape.of("aside")`. A leaf needs `atom: true`.
|
|
273
|
+
- `createWrapper(component, { element? })`, for `Decoration.Tag.wrapper` and
|
|
274
|
+
`Decoration.Point.wrapper`: the same props without `contentDOMRef`, and what it wraps as
|
|
275
|
+
`children`. Its root is a `div` by default.
|
|
276
|
+
- `createMarkShape(component, { element? })`, for `Decoration.Mark.shape`: `markProps` (`mark`,
|
|
277
|
+
`getPos`, `contentDOMRef`) and `children`, one component per run of the mark. Its root is a
|
|
278
|
+
`span` by default.
|
|
279
|
+
- The chrome is uneditable unless it sets `contentEditable`.
|
|
280
|
+
|
|
281
|
+
#### A root drawn by hand
|
|
282
|
+
|
|
283
|
+
`DecorationRoot` applies all of Wordgard's attributes. To decide what reaches the root (drop one,
|
|
284
|
+
change one, join Wordgard's classes your way) or to type the root as its element, the component
|
|
285
|
+
draws the root itself. It gets Wordgard's attributes as props and a `ref` for the root.
|
|
286
|
+
|
|
287
|
+
On React 19, `ref` is a prop:
|
|
288
|
+
|
|
289
|
+
```tsx
|
|
290
|
+
import { createShape } from "@stesura/react-wordgard";
|
|
291
|
+
|
|
292
|
+
// An image whose root drops the `title` a decoration gives it and adds a class of its own.
|
|
293
|
+
const ImageShape = createShape<HTMLImageElement>(function ImageShape({
|
|
294
|
+
nodeProps,
|
|
295
|
+
children,
|
|
296
|
+
className,
|
|
297
|
+
title,
|
|
298
|
+
ref,
|
|
299
|
+
...props
|
|
300
|
+
}) {
|
|
301
|
+
return <img {...props} ref={ref} className={["picture", className].join(" ").trim()} />;
|
|
302
|
+
});
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
On React 18, the component needs `forwardRef` to get it:
|
|
306
|
+
|
|
307
|
+
```tsx
|
|
308
|
+
import { forwardRef } from "react";
|
|
309
|
+
import { type ShapeComponentProps, createShape } from "@stesura/react-wordgard";
|
|
310
|
+
|
|
311
|
+
const ImageShape = createShape(
|
|
312
|
+
forwardRef<HTMLImageElement, ShapeComponentProps<HTMLImageElement>>(function ImageShape(
|
|
313
|
+
{ nodeProps, children, className, title, ...props },
|
|
314
|
+
ref
|
|
315
|
+
) {
|
|
316
|
+
return <img {...props} ref={ref} className={["picture", className].join(" ").trim()} />;
|
|
317
|
+
})
|
|
318
|
+
);
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
- Draw the element the decoration says (the node type's for a shape, `element` for a wrapper or
|
|
322
|
+
mark), spread the props, and put `ref` on it.
|
|
323
|
+
- `className` holds Wordgard's classes (the selection's among them): join yours with them, or
|
|
324
|
+
they're lost.
|
|
325
|
+
- `style` holds Wordgard's style as an object: spread yours over it,
|
|
326
|
+
`style={{ ...style, color: "red" }}`, or it replaces Wordgard's.
|
|
327
|
+
- Use one or the other: spreading these props onto `<DecorationRoot>` applies Wordgard's
|
|
328
|
+
attributes twice (its classes appear twice).
|
|
329
|
+
- `createWrapper` and `createMarkShape` components work the same way (`WrapperComponentProps`,
|
|
330
|
+
`MarkComponentProps`).
|
|
331
|
+
|
|
332
|
+
#### React widgets
|
|
333
|
+
|
|
334
|
+
```tsx
|
|
335
|
+
import { Decoration } from "wordgard/editor";
|
|
336
|
+
import { Paragraph } from "wordgard/types";
|
|
337
|
+
import {
|
|
338
|
+
DecorationRoot,
|
|
339
|
+
type WidgetComponentProps,
|
|
340
|
+
createWidgetType,
|
|
341
|
+
} from "@stesura/react-wordgard";
|
|
342
|
+
|
|
343
|
+
// A pilcrow at the start of every paragraph.
|
|
344
|
+
const Pilcrow = createWidgetType({
|
|
345
|
+
component: function Pilcrow({ value }: WidgetComponentProps<string>) {
|
|
346
|
+
return <DecorationRoot aria-hidden="true">{value}</DecorationRoot>;
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const config = [Decoration.Tag.widget(Paragraph, "start", Pilcrow.of("¶"))];
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
- `createWidgetType(spec)`, for a widget: at a position (`Decoration.Point.widget`), beside a node
|
|
354
|
+
(`Decoration.Tag.widget`), inside an `Elt` shape. It takes `Widget.define`'s spec, `component`
|
|
355
|
+
in place of `render` (`eq`, `editable`, `inFlow`, `propagateEvent`, `connect`, `disconnect` keep
|
|
356
|
+
their meaning), and returns a real `Widget.Type`: its widgets are `type.of(value)`.
|
|
357
|
+
- The component gets the `value`. Its root is a `DecorationRoot`, a `span` unless the spec gives
|
|
358
|
+
another `element` (`createWidgetType({ component, element: "div" })`): Wordgard doesn't look at
|
|
359
|
+
a widget's element, so it's the widget's choice. Or the component draws its root by hand, with
|
|
360
|
+
the `ref` it gets (`forwardRef` on React 18), as a shape's can.
|
|
361
|
+
- Widgets are uneditable unless their spec sets `editable`, as in Wordgard.
|
|
362
|
+
- A widget can also draw a node, as Wordgard allows:
|
|
363
|
+
`Decoration.Tag.shape(nodeType, (tag) => type.of(tag.param))`. `DecorationRoot` puts the node's
|
|
364
|
+
attributes on its root (a root drawn by hand gets them as props). A React shape is the usual
|
|
365
|
+
way.
|
|
366
|
+
|
|
367
|
+
#### Hooks inside React decorations
|
|
368
|
+
|
|
369
|
+
A React shape's, wrapper's, mark's or widget's component can also use `useNodePos`,
|
|
370
|
+
`useStopEvent`, `useIgnoreMutation`, `useIsNodeSelected`, `useSelectNode` and
|
|
371
|
+
`useMergedDOMRefs`: see [Decoration hooks](#decoration-hooks).
|
|
372
|
+
|
|
373
|
+
### Should I use a shape, a wrapper or both?
|
|
374
|
+
|
|
375
|
+
A shape draws the node; a wrapper adds React around it. Wordgard places attributes (a mark's or a
|
|
376
|
+
decoration's `target`) on the elements of the node type's shape before React draws, so a React
|
|
377
|
+
shape that draws more than its root gets the attributes aimed inside it on its root: a figure's
|
|
378
|
+
`alt` and width end up on the `<figure>`, not its `img`. Always match the shape's root to the node
|
|
379
|
+
type's element: `<DecorationRoot>` does it automatically, or draw the root by hand (see
|
|
380
|
+
[A root drawn by hand](#a-root-drawn-by-hand)). So `<DecorationRoot>` belongs at the root of a
|
|
381
|
+
shape, and chrome around it goes in an additional wrapper decoration.
|
|
382
|
+
|
|
383
|
+
For example, an image with a resize handle: the shape draws the `img`, which keeps its `src`, `alt`
|
|
384
|
+
and width, and the wrapper draws the handle.
|
|
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
|
+
const ImageShape = createShape(function ImageShape() {
|
|
392
|
+
return <DecorationRoot className="image" />;
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const Resizer = createWrapper(
|
|
396
|
+
function Resizer({ children }) {
|
|
397
|
+
// The handle's pointer events are its own, not Wordgard's.
|
|
398
|
+
useStopEvent(() => true);
|
|
399
|
+
return (
|
|
400
|
+
<DecorationRoot className="resizer">
|
|
401
|
+
{children}
|
|
402
|
+
<span className="resize-handle" aria-hidden="true" />
|
|
403
|
+
</DecorationRoot>
|
|
404
|
+
);
|
|
405
|
+
},
|
|
406
|
+
{ element: "span" } // an image is inline
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const config = [Decoration.Tag.shape(Image, ImageShape), Decoration.Tag.wrapper(Image, Resizer)];
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Accessibility
|
|
413
|
+
|
|
414
|
+
React decorations are your components: make them accessible as you would any other. What
|
|
415
|
+
react-wordgard adds:
|
|
416
|
+
|
|
417
|
+
- A control in a decoration's chrome needs `useStopEvent`, or Wordgard handles its keys.
|
|
418
|
+
- `DecorationRoot` puts the node's attributes on its root, an image's `alt` among them; a root
|
|
419
|
+
drawn by hand gets them as props, so spread them onto it.
|
|
420
|
+
- The tests run axe-core over editors with React shapes, marks and widgets: react-wordgard breaks
|
|
421
|
+
no rule plain Wordgard keeps.
|
|
422
|
+
|
|
423
|
+
### Limits
|
|
424
|
+
|
|
425
|
+
- Keep `{children}` where it is: moving it under another element remounts the content, and the
|
|
426
|
+
browser loses a selection or an IME composition in it.
|
|
427
|
+
- A React wrapper can't wrap text, or a node a native widget draws.
|
|
428
|
+
- `useEditorEventListener` takes keyboard (`keydown`, `keyup`, `keypress`), input (`beforeinput`,
|
|
429
|
+
`input`, composition events), mouse (`mousedown`, `mouseup`, `mouseover`, `mouseout`, `click`,
|
|
430
|
+
`dblclick`, `contextmenu`), `focus`, `blur`, clipboard (`copy`, `cut`, `paste`) and
|
|
431
|
+
drag-and-drop events. Not touch, wheel, pointer events or `mousemove`.
|
|
432
|
+
|
|
433
|
+
## API
|
|
434
|
+
|
|
435
|
+
Everything is exported from `@stesura/react-wordgard`. Editor hooks work under `<WordgardEditor>`,
|
|
436
|
+
decoration hooks in a React decoration's component; elsewhere, they throw.
|
|
437
|
+
|
|
438
|
+
### Components
|
|
439
|
+
|
|
440
|
+
#### `WordgardEditor`
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
type WordgardEditor = (
|
|
444
|
+
props: Omit<Wordgard.Spec, "parent"> & { children?: ReactNode }
|
|
445
|
+
) => JSX.Element;
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
Creates a Wordgard editor from the spec (`doc`, `config`, `selection`, `state`, `scrollTo`) and
|
|
449
|
+
provides it to its children. It draws no DOM of its own. The spec is read once, on mount: from then
|
|
450
|
+
on the editor owns its state (see
|
|
451
|
+
[Accessing the state outside the editor](#accessing-the-state-outside-the-editor)). In React's
|
|
452
|
+
StrictMode (development only), it creates its editor twice and drops one, which is never put in the
|
|
453
|
+
page.
|
|
454
|
+
|
|
455
|
+
#### `WordgardDoc`
|
|
456
|
+
|
|
457
|
+
```ts
|
|
458
|
+
type WordgardDoc = (props: Omit<HTMLAttributes<HTMLDivElement>, "children">) => JSX.Element;
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Puts the editor in the page, inside a `div` taking the props, and renders the document through a
|
|
462
|
+
portal, in your React tree. It goes under its `<WordgardEditor>`, inside any other components, with
|
|
463
|
+
other children before or after it. One per editor: a second throws.
|
|
464
|
+
|
|
465
|
+
#### `DecorationRoot`
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
type DecorationRoot = (
|
|
469
|
+
props: AllHTMLAttributes<HTMLElement> & { children?: ReactNode; ref?: Ref<Element> }
|
|
470
|
+
) => JSX.Element;
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
The root element of a React shape, wrapper, mark or widget: the tag the decoration says, with
|
|
474
|
+
Wordgard's attributes merged with the props. See [React decorations](#react-decorations).
|
|
475
|
+
|
|
476
|
+
### Decoration factories
|
|
477
|
+
|
|
478
|
+
Call them at module level. Each returns Wordgard's own value, which you pass to its decorations;
|
|
479
|
+
the same component gives the same value. Details and examples:
|
|
480
|
+
[React decorations](#react-decorations).
|
|
481
|
+
|
|
482
|
+
#### `createShape`
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
type createShape = <E extends Element = HTMLElement>(
|
|
486
|
+
component: ComponentType<ShapeComponentProps<E>>
|
|
487
|
+
) => ReactShape;
|
|
488
|
+
|
|
489
|
+
type ReactShape = ((tag: Node.Tag) => Elt) & {
|
|
490
|
+
of: (element: string, options?: { atom?: boolean }) => Elt;
|
|
491
|
+
};
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
A node's shape, for `Decoration.Tag.shape(NodeType, shape)`. `shape.of(element)` is the shape with
|
|
495
|
+
another root element, also for `Decoration.Point.shape`.
|
|
496
|
+
|
|
497
|
+
#### `createWrapper`
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
type createWrapper = <E extends Element = HTMLElement>(
|
|
501
|
+
component: ComponentType<WrapperComponentProps<E>>,
|
|
502
|
+
options?: { element?: string } // "div" by default
|
|
503
|
+
) => Elt;
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
A wrapper, for `Decoration.Tag.wrapper` and `Decoration.Point.wrapper`.
|
|
507
|
+
|
|
508
|
+
#### `createMarkShape`
|
|
509
|
+
|
|
510
|
+
```ts
|
|
511
|
+
type createMarkShape = <E extends Element = HTMLElement>(
|
|
512
|
+
component: ComponentType<MarkComponentProps<E>>,
|
|
513
|
+
options?: { element?: string } // "span" by default
|
|
514
|
+
) => { element: string; attributes: Record<string, string> };
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
A mark's shape, for `Decoration.Mark.shape(MarkType, shape)` or a mark type's own `shape`.
|
|
518
|
+
|
|
519
|
+
#### `createWidgetType`
|
|
520
|
+
|
|
521
|
+
```ts
|
|
522
|
+
type createWidgetType = <Param, E extends Element = HTMLElement>(
|
|
523
|
+
spec: Omit<Widget.Spec<Param>, "render"> & {
|
|
524
|
+
component: ComponentType<WidgetComponentProps<Param, E>>;
|
|
525
|
+
element?: string; // "span" by default
|
|
526
|
+
}
|
|
527
|
+
) => Widget.Type<Param>;
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
A widget type whose widgets, `type.of(value)`, the component draws.
|
|
531
|
+
|
|
532
|
+
### Editor hooks
|
|
533
|
+
|
|
534
|
+
#### `useWordgard`
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
type useWordgard = () => Wordgard;
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
The editor.
|
|
541
|
+
|
|
542
|
+
#### `useEditorState`
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
type useEditorState = () => GardState;
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
The state as of the editor's last flush, rendered in the same React commit as the document. The
|
|
549
|
+
component re-renders on every flush: for a part of the state, use `useEditorStateSelector`; in a
|
|
550
|
+
handler, `wg.state` is the latest.
|
|
551
|
+
|
|
552
|
+
#### `useEditorStateSelector`
|
|
553
|
+
|
|
554
|
+
```ts
|
|
555
|
+
type useEditorStateSelector = <Result>(
|
|
556
|
+
selector: (state: GardState) => Result,
|
|
557
|
+
isEqual?: (a: Result, b: Result) => boolean
|
|
558
|
+
) => Result;
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
A part of the last flush's state, like Redux's `useSelector`: the component re-renders only when
|
|
562
|
+
the result changes, by `Object.is` or by `isEqual`. A selector building a new object or array each
|
|
563
|
+
time re-renders on every flush unless you pass `isEqual`.
|
|
564
|
+
|
|
565
|
+
```tsx
|
|
566
|
+
export const SelectionInfo = () => {
|
|
567
|
+
const empty = useEditorStateSelector((state) => state.selection.empty);
|
|
568
|
+
return <output>{empty ? "Cursor" : "Selection"}</output>;
|
|
569
|
+
};
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
#### `useEditorEffect`
|
|
573
|
+
|
|
574
|
+
```ts
|
|
575
|
+
type useEditorEffect = (
|
|
576
|
+
effect: (wg: Wordgard) => void | (() => void),
|
|
577
|
+
dependencies?: DependencyList
|
|
578
|
+
) => void;
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
Runs `effect` once the editor's DOM is up to date: after the flush under way or pending, else in a
|
|
582
|
+
microtask, never inside React's commit. It may read layout and dispatch; what it sets shows a frame
|
|
583
|
+
later. For focus, follow-up dispatches and menus. The dependencies work as React's. Don't read the
|
|
584
|
+
editor's layout from a plain React effect: it can run inside Wordgard's update, before the editor's
|
|
585
|
+
view of the new DOM exists.
|
|
586
|
+
|
|
587
|
+
```tsx
|
|
588
|
+
export const FocusOnMount = () => {
|
|
589
|
+
useEditorEffect((wg) => wg.focus(), []);
|
|
590
|
+
return null;
|
|
591
|
+
};
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
#### `useEditorLayoutEffect`
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
type useEditorLayoutEffect = <T>(effect: {
|
|
598
|
+
read: (wg: Wordgard) => T | undefined;
|
|
599
|
+
write: (value: T, wg: Wordgard) => void;
|
|
600
|
+
}) => void;
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
Runs inside every editor update, without re-rendering: `read` in Wordgard's read phase, then
|
|
604
|
+
`write` with its value in the write phase, on the same paint as the document. For what moves with
|
|
605
|
+
the text (markers in the margin, pagination). It can't dispatch: Wordgard throws during its
|
|
606
|
+
update. A `read` returning `undefined` skips its write, and when no read asks for a write, none is
|
|
607
|
+
scheduled, which spares Wordgard measuring again. Reads run in the order the effects were
|
|
608
|
+
registered.
|
|
609
|
+
|
|
610
|
+
```tsx
|
|
611
|
+
// A marker in the margin, level with the cursor.
|
|
612
|
+
export const CursorMarker = () => {
|
|
613
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
614
|
+
useEditorLayoutEffect({
|
|
615
|
+
read: (wg) => wg.coordsAtPos(wg.state.selection.head).top - wg.dom.getBoundingClientRect().top,
|
|
616
|
+
write: (top) => {
|
|
617
|
+
if (ref.current) ref.current.style.top = `${top}px`;
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
return <div ref={ref} className="cursor-marker" style={{ position: "absolute" }} />;
|
|
621
|
+
};
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
#### `useEditorEventCallback`
|
|
625
|
+
|
|
626
|
+
```ts
|
|
627
|
+
type useEditorEventCallback = <Args extends unknown[], Result>(
|
|
628
|
+
callback: (wg: Wordgard, ...args: Args) => Result
|
|
629
|
+
) => (...args: Args) => Result;
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
A stable function calling the latest `callback` with the editor first, for event handlers. There,
|
|
633
|
+
`wg.state` is the latest state, dispatches not yet drawn included. See the `SaveButton` in
|
|
634
|
+
[Accessing the state outside the editor](#accessing-the-state-outside-the-editor).
|
|
635
|
+
|
|
636
|
+
#### `useEditorEventListener`
|
|
637
|
+
|
|
638
|
+
```ts
|
|
639
|
+
type useEditorEventListener = <Type extends EditorEvent>(
|
|
640
|
+
type: Type,
|
|
641
|
+
handler: (wg: Wordgard, event: HTMLElementEventMap[Type]) => boolean | void
|
|
642
|
+
) => void;
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
Listens to the editor's DOM events of `type`, before Wordgard's handlers and keymaps. Returning
|
|
646
|
+
`true` handles the event: Wordgard calls `preventDefault` and skips its own handling. The event
|
|
647
|
+
types are listed under [Limits](#limits). Key events during a composition don't come; handling
|
|
648
|
+
`input`, `beforeinput` or a composition event skips Wordgard's own handling of it, so leave typing
|
|
649
|
+
to Wordgard.
|
|
650
|
+
|
|
651
|
+
```tsx
|
|
652
|
+
export const EscapeToBlur = () => {
|
|
653
|
+
useEditorEventListener("keydown", (wg, event) => {
|
|
654
|
+
if (event.key != "Escape") return false;
|
|
655
|
+
wg.contentDOM.blur();
|
|
656
|
+
return true;
|
|
657
|
+
});
|
|
658
|
+
return null;
|
|
659
|
+
};
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
### Decoration hooks
|
|
663
|
+
|
|
664
|
+
From a React shape's, wrapper's, mark's or widget's component.
|
|
665
|
+
|
|
666
|
+
#### `useNodePos`
|
|
667
|
+
|
|
668
|
+
```ts
|
|
669
|
+
type useNodePos = () => () => number;
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
A function giving the position of what the decoration draws, to call in handlers and effects.
|
|
673
|
+
|
|
674
|
+
#### `useStopEvent`
|
|
675
|
+
|
|
676
|
+
```ts
|
|
677
|
+
type useStopEvent = (stopEvent: (wg: Wordgard, event: Event) => boolean) => void;
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
Asked for events in the decoration's chrome (its root and the elements around its content element,
|
|
681
|
+
unless the root is the content element) and in React widgets; never for those in its content. For
|
|
682
|
+
an event it stops, Wordgard's handlers and keymaps are skipped and the browser's default goes
|
|
683
|
+
ahead: for a control in the chrome, such as an input.
|
|
684
|
+
|
|
685
|
+
#### `useIgnoreMutation`
|
|
686
|
+
|
|
687
|
+
```ts
|
|
688
|
+
type useIgnoreMutation = (ignore: (wg: Wordgard, mutation: MutationRecord) => boolean) => void;
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
Wordgard doesn't read the DOM mutations `ignore` returns `true` for, typing aside (Wordgard takes
|
|
692
|
+
it from `beforeinput`). Mutations in chrome and React widgets never reach Wordgard anyway.
|
|
693
|
+
|
|
694
|
+
#### `useIsNodeSelected`
|
|
695
|
+
|
|
696
|
+
```ts
|
|
697
|
+
type useIsNodeSelected = () => boolean;
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
Whether the node the decoration draws is the editor's node selection.
|
|
701
|
+
|
|
702
|
+
#### `useSelectNode`
|
|
703
|
+
|
|
704
|
+
```ts
|
|
705
|
+
type useSelectNode = (
|
|
706
|
+
selectNode: (wg: Wordgard) => void,
|
|
707
|
+
deselectNode?: (wg: Wordgard) => void
|
|
708
|
+
) => void;
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
Calls `selectNode` when the node becomes the node selection, and `deselectNode` when it stops being
|
|
712
|
+
it, once the editor's DOM is up to date (as `useEditorEffect`).
|
|
713
|
+
|
|
714
|
+
#### `useMergedDOMRefs`
|
|
715
|
+
|
|
716
|
+
```ts
|
|
717
|
+
type useMergedDOMRefs = <Value>(...refs: (Ref<Value> | undefined)[]) => RefCallback<Value>;
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
One ref setting several, as for a root that's also the content element (`ref` and
|
|
721
|
+
`contentDOMRef`).
|
|
722
|
+
|
|
723
|
+
### Props types
|
|
724
|
+
|
|
725
|
+
#### `ShapeComponentProps`
|
|
726
|
+
|
|
727
|
+
```ts
|
|
728
|
+
type ShapeComponentProps<E extends Element = HTMLElement> = AllHTMLAttributes<HTMLElement> & {
|
|
729
|
+
nodeProps: {
|
|
730
|
+
node: Node;
|
|
731
|
+
getPos: () => number;
|
|
732
|
+
contentDOMRef: RefCallback<Element>;
|
|
733
|
+
};
|
|
734
|
+
children: ReactNode;
|
|
735
|
+
} & RefAttributes<E>;
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
A React shape's props: `nodeProps`, the content as `children`, and Wordgard's attributes and `ref`
|
|
739
|
+
for its root. The content goes in the `contentDOMRef` element, else in the root; in an atom,
|
|
740
|
+
`children` is empty and a `contentDOMRef` throws. With `<DecorationRoot>` as the root, the
|
|
741
|
+
attributes and `ref` are taken care of; a root drawn by hand spreads them onto itself (see
|
|
742
|
+
[A root drawn by hand](#a-root-drawn-by-hand)).
|
|
743
|
+
|
|
744
|
+
#### `WrapperComponentProps`
|
|
745
|
+
|
|
746
|
+
```ts
|
|
747
|
+
type WrapperComponentProps<E extends Element = HTMLElement> = AllHTMLAttributes<HTMLElement> & {
|
|
748
|
+
nodeProps: {
|
|
749
|
+
node: Node;
|
|
750
|
+
getPos: () => number;
|
|
751
|
+
};
|
|
752
|
+
children: ReactNode;
|
|
753
|
+
} & RefAttributes<E>;
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
A React wrapper's props: as a shape's, without `contentDOMRef`. `children` is what it wraps.
|
|
757
|
+
|
|
758
|
+
#### `MarkComponentProps`
|
|
759
|
+
|
|
760
|
+
```ts
|
|
761
|
+
type MarkComponentProps<E extends Element = HTMLElement> = AllHTMLAttributes<HTMLElement> & {
|
|
762
|
+
markProps: {
|
|
763
|
+
mark: Mark;
|
|
764
|
+
getPos: () => number;
|
|
765
|
+
contentDOMRef: RefCallback<Element>;
|
|
766
|
+
};
|
|
767
|
+
children: ReactNode;
|
|
768
|
+
} & RefAttributes<E>;
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
A React mark's props, for each run of an equal mark: `markProps.mark` is the document's mark.
|
|
772
|
+
|
|
773
|
+
#### `WidgetComponentProps`
|
|
774
|
+
|
|
775
|
+
```ts
|
|
776
|
+
type WidgetComponentProps<Param, E extends Element = HTMLElement> = Omit<
|
|
777
|
+
AllHTMLAttributes<HTMLElement>,
|
|
778
|
+
"value"
|
|
779
|
+
> & { value: Param } & RefAttributes<E>;
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
A React widget's props: its `value`, and, for a widget drawing a node, the node's attributes for
|
|
783
|
+
its root.
|
|
784
|
+
|
|
785
|
+
Also exported: `DecorationRootProps`, `ReactShape`, `WrapperOptions`, `MarkShapeOptions`,
|
|
786
|
+
`WidgetTypeSpec` and `EditorLayoutEffect`, the types used above.
|
|
787
|
+
|
|
788
|
+
## Migrating from react-prosemirror
|
|
789
|
+
|
|
790
|
+
react-wordgard isn't a compatibility layer: it follows Wordgard's API, and most of a migration is
|
|
791
|
+
moving from ProseMirror's concepts to Wordgard's (see Wordgard's
|
|
792
|
+
[ProseMirror migration guide](https://wordgard.net/docs/prosemirror/)). The biggest change: there
|
|
793
|
+
are no node views. A node is drawn by decorations, its shape and wrappers, and a React component
|
|
794
|
+
becomes one with `createShape` or `createWrapper`. What changes on the React side:
|
|
795
|
+
|
|
796
|
+
| react-prosemirror | react-wordgard |
|
|
797
|
+
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
798
|
+
| `<ProseMirror state={…} dispatchTransaction={…}>` | `<WordgardEditor doc={…} config={…}>`, uncontrolled: the editor owns its state, changes go through `wg.dispatch` |
|
|
799
|
+
| `<ProseMirrorDoc />` | `<WordgardDoc />` |
|
|
800
|
+
| `plugins` prop | Wordgard extensions in `config`; to change them later, compartments or `GardState.appendConfig` |
|
|
801
|
+
| `nodeViews` prop and node view components | **decorations**: no node views; a node's React component is a tag decoration, `Decoration.Tag.shape(NodeType, createShape(component))`, plus `Decoration.Tag.wrapper(NodeType, createWrapper(component))` for React around it |
|
|
802
|
+
| `markViews` prop and mark view components | `createMarkShape` with `Decoration.Mark.shape` |
|
|
803
|
+
| `widget()` decorations with a component | `Decoration.Point.widget(MyReactWidget.of(value))` |
|
|
804
|
+
| `getPos` prop, `useNodePos` | `nodeProps.getPos`, `useNodePos()` (a function, not the position) |
|
|
805
|
+
| `useStopEvent` | the same name, for events in a React decoration's chrome, on its own elements or in a React widget, not in its content |
|
|
806
|
+
| `useIgnoreMutation` | the same name; chrome's and React widgets' mutations never reach Wordgard anyway |
|
|
807
|
+
| `useEditorState`, `useEditorStateSelector` | the same names, with the state of the last flush |
|
|
808
|
+
| `useEditorEffect` | the same name: runs after the editor's update; what it sets shows a frame later (below) |
|
|
809
|
+
| `useIsNodeSelected`, `useSelectNode` | the same names |
|
|
810
|
+
| `useMergedDOMRefs` | the same name |
|
|
811
|
+
| `useEditorEventCallback` | the same name, getting the editor |
|
|
812
|
+
| `useEditorEventListener` | the same name, before Wordgard's handlers and keymaps, for a fixed set of event types |
|
|
813
|
+
| `useLayoutGroupEffect` | `useEditorEffect`; `useEditorLayoutEffect` to measure, then write on the same paint |
|
|
814
|
+
| `reactKeys()` plugin | not needed: keys come from the changes Wordgard gives the view |
|
|
815
|
+
|
|
816
|
+
Not offered: `useIsComposingIn`, `useIsEditorStatic`.
|
|
817
|
+
|
|
818
|
+
### Migrating a node view
|
|
819
|
+
|
|
820
|
+
A react-prosemirror node view keeps its body: the props keep their names (`nodeProps`,
|
|
821
|
+
`contentDOMRef`, `children`, `ref`, the attributes). A paragraph that deletes the selection when
|
|
822
|
+
clicked, in react-prosemirror:
|
|
823
|
+
|
|
824
|
+
```tsx
|
|
825
|
+
function Paragraph({ children, nodeProps, ref, ...props }) {
|
|
826
|
+
const onClick = useEditorEventCallback((view) => view.dispatch(view.state.tr.deleteSelection()));
|
|
827
|
+
|
|
828
|
+
return (
|
|
829
|
+
<p {...props} ref={useMergedDOMRefs(ref, nodeProps.contentDOMRef)} onClick={onClick}>
|
|
830
|
+
{children}
|
|
831
|
+
</p>
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const nodeViews = { paragraph: Paragraph };
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
In react-wordgard:
|
|
839
|
+
|
|
840
|
+
```tsx
|
|
841
|
+
import { deleteSelection } from "wordgard/command";
|
|
842
|
+
import { Decoration } from "wordgard/editor";
|
|
843
|
+
import { Paragraph } from "wordgard/types";
|
|
844
|
+
import { DecorationRoot, createShape, useEditorEventCallback } from "@stesura/react-wordgard";
|
|
845
|
+
|
|
846
|
+
const ParagraphShape = createShape(function ParagraphShape({ children }) {
|
|
847
|
+
const onClick = useEditorEventCallback((wg) => {
|
|
848
|
+
const spec = deleteSelection(wg.state);
|
|
849
|
+
if (spec) wg.dispatch(spec);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
return <DecorationRoot onClick={onClick}>{children}</DecorationRoot>;
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
const config = [Decoration.Tag.shape(Paragraph, ParagraphShape)];
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
What changes:
|
|
859
|
+
|
|
860
|
+
- The props type: `ShapeComponentProps` (any node, atoms included), `WrapperComponentProps`,
|
|
861
|
+
`MarkComponentProps`.
|
|
862
|
+
- The root's tag isn't the component's choice: it's the node type's own element, what `toDOM`'s
|
|
863
|
+
outer element was (see
|
|
864
|
+
[Should I use a shape, a wrapper or both?](#should-i-use-a-shape-a-wrapper-or-both) for why).
|
|
865
|
+
- The root element, with its `{...props}` and `ref`, becomes `<DecorationRoot>`. Without a
|
|
866
|
+
`contentDOMRef`, the content goes in it, so `useMergedDOMRefs` isn't needed.
|
|
867
|
+
- `node.attrs` becomes the node's parameter (`node.tag.param`, `node.param` for a leaf) for what
|
|
868
|
+
identifies it, and marks on its tag (`node.tag.mark(Type)`) for data changed in place. `view`
|
|
869
|
+
becomes `wg`, Wordgard's editor.
|
|
870
|
+
- Registration: `Decoration.Tag.shape(NodeType, createShape(component))` in the editor's `config`
|
|
871
|
+
instead of `nodeViews`.
|
|
872
|
+
- Class names: `ProseMirror-selectednode` is `wg-selected-node`, a selected cell's class
|
|
873
|
+
`wg-selected-cell`.
|
|
874
|
+
- `useNodePos()` returns a function giving the position, not the position.
|
|
875
|
+
- `innerDecorations` has no equivalent.
|
|
876
|
+
|
|
877
|
+
### Effects and timing
|
|
878
|
+
|
|
879
|
+
In react-prosemirror, React drives: `useEditorEffect` is a React layout effect that runs after the
|
|
880
|
+
view's update, inside React's commit, so it can read layout, dispatch, and what it sets shows on the
|
|
881
|
+
same paint. In react-wordgard, Wordgard drives: React commits inside Wordgard's update, and those
|
|
882
|
+
properties split between two hooks.
|
|
883
|
+
|
|
884
|
+
In practice, React's own effects (`useLayoutEffect`, `useEffect`) can run inside Wordgard's update,
|
|
885
|
+
before Wordgard has caught up with the new DOM: reading layout there (`coordsAtPos`) throws ("ReactWordgard
|
|
886
|
+
can't update while React renders or commits"), and so does dispatching ("Cannot dispatch new
|
|
887
|
+
updates during the editor flush phase"). Use the editor
|
|
888
|
+
hooks instead. `useEditorEffect` runs after the update, so it can dispatch, but what it writes to
|
|
889
|
+
the DOM shows a frame late: a tooltip placed there trails the cursor. `useEditorLayoutEffect`
|
|
890
|
+
writes on the same paint, but can't dispatch. When porting, a react-prosemirror `useEditorEffect`
|
|
891
|
+
that positions something becomes `useEditorLayoutEffect`; one that dispatches or focuses stays
|
|
892
|
+
`useEditorEffect`.
|
|
893
|
+
|
|
894
|
+
| | react-prosemirror `useEditorEffect` | `useEditorEffect` | `useEditorLayoutEffect` |
|
|
895
|
+
| ----------------------------------------- | ----------------------------------- | ---------------------------------- | ---------------------------------------------------- |
|
|
896
|
+
| Reads layout after the update | yes | yes | yes, in Wordgard's read phase |
|
|
897
|
+
| May dispatch | yes | yes | no |
|
|
898
|
+
| What it sets shows on the same paint | yes | no, a frame later | yes |
|
|
899
|
+
| Runs on every update without re-rendering | no | no | yes |
|
|
900
|
+
| For | everything | menus, focus, follow-up dispatches | what moves with the text: margin markers, pagination |
|
|
901
|
+
|
|
902
|
+
## AI coding agents
|
|
903
|
+
|
|
904
|
+
The package ships a skill for building editors with react-wordgard: `skills/react-wordgard/` (in
|
|
905
|
+
`node_modules/@stesura/react-wordgard/`). Copy that folder into your project's `.claude/skills/`,
|
|
906
|
+
or point your agent to its `SKILL.md`.
|
|
907
|
+
|
|
908
|
+
## Licence
|
|
909
|
+
|
|
910
|
+
MIT, © 2026 Edoardo Caratti, see [LICENSE.txt](LICENSE.txt).
|
|
911
|
+
|
|
912
|
+
Includes code from react-prosemirror (© Handle with Care Collective and The New
|
|
913
|
+
York Times Company) and Wordgard (© Marijn Haverbeke); their notices are in [THIRD_PARTY_LICENSES.txt](THIRD_PARTY_LICENSES.txt).
|