@stesura/react-wordgard 0.1.0 → 0.2.1
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 +12 -0
- package/README.md +699 -237
- package/dist/ReactWordgard.js +2 -4
- package/dist/components/ChildNodeViews.js +2 -4
- package/dist/components/DecorationRoot.d.ts +6 -0
- package/dist/components/DecorationRoot.js +7 -4
- package/dist/components/ErrorBoundary.js +3 -5
- package/dist/components/SeparatorHackView.js +1 -2
- package/dist/components/ShapeView.js +2 -4
- package/dist/components/TextNodeView.js +2 -3
- package/dist/components/TrailingHackView.js +2 -3
- package/dist/components/WidgetView.js +2 -5
- package/dist/components/WordgardDoc.d.ts +5 -0
- package/dist/components/WordgardDoc.js +8 -6
- package/dist/components/WordgardEditor.d.ts +8 -0
- package/dist/components/WordgardEditor.js +10 -6
- package/dist/components/WordgardScope.d.ts +14 -0
- package/dist/components/WordgardScope.js +14 -0
- package/dist/components/WrapperView.js +1 -4
- package/dist/components/marks/MarkView.js +3 -6
- package/dist/components/marks/ReactMarkView.js +2 -4
- package/dist/components/nodes/DefaultNodeView.js +3 -7
- package/dist/components/nodes/DocNodeView.js +2 -3
- package/dist/composition.js +4 -6
- package/dist/contexts/DecorationContext.js +2 -5
- package/dist/contexts/DecorationRootContext.js +2 -4
- package/dist/contexts/EditorContext.d.ts +1 -0
- package/dist/contexts/EditorContext.js +3 -3
- package/dist/contexts/RenderStoreContext.js +3 -5
- package/dist/contexts/editorEvents.js +3 -6
- package/dist/decorations/ReactMarkType.d.ts +11 -0
- package/dist/decorations/ReactMarkType.js +6 -7
- package/dist/decorations/ReactShapeType.d.ts +16 -0
- package/dist/decorations/ReactShapeType.js +9 -11
- package/dist/decorations/ReactWidgetType.d.ts +10 -0
- package/dist/decorations/ReactWidgetType.js +6 -8
- package/dist/decorations/ReactWrapperType.d.ts +9 -0
- package/dist/decorations/ReactWrapperType.js +3 -6
- package/dist/decorations/iterDeco.js +3 -6
- package/dist/hooks/useEditorEffect.d.ts +7 -0
- package/dist/hooks/useEditorEffect.js +11 -7
- package/dist/hooks/useEditorEventCallback.d.ts +6 -0
- package/dist/hooks/useEditorEventCallback.js +7 -3
- package/dist/hooks/useEditorEventListener.d.ts +7 -0
- package/dist/hooks/useEditorEventListener.js +11 -11
- package/dist/hooks/useEditorLayoutEffect.d.ts +9 -0
- package/dist/hooks/useEditorLayoutEffect.js +13 -17
- package/dist/hooks/useEditorState.d.ts +5 -0
- package/dist/hooks/useEditorState.js +6 -4
- package/dist/hooks/useEditorStateSelector.d.ts +5 -0
- package/dist/hooks/useEditorStateSelector.js +5 -0
- package/dist/hooks/useIgnoreMutation.d.ts +5 -1
- package/dist/hooks/useIgnoreMutation.js +11 -10
- package/dist/hooks/useIsNodeSelected.d.ts +1 -0
- package/dist/hooks/useIsNodeSelected.js +3 -4
- package/dist/hooks/useMergedDOMRefs.d.ts +4 -0
- package/dist/hooks/useMergedDOMRefs.js +5 -4
- package/dist/hooks/useNodePos.d.ts +3 -0
- package/dist/hooks/useNodePos.js +5 -6
- package/dist/hooks/useSelectNode.d.ts +4 -0
- package/dist/hooks/useSelectNode.js +5 -4
- package/dist/hooks/useStopEvent.d.ts +6 -0
- package/dist/hooks/useStopEvent.js +8 -7
- package/dist/hooks/useWordgard.d.ts +1 -0
- package/dist/hooks/useWordgard.js +1 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/tile.js +3 -8
- package/dist/viewRoots.js +2 -3
- package/dist/wordgardInternals.js +2 -5
- package/package.json +1 -1
- package/skills/react-wordgard/SKILL.md +138 -10
package/README.md
CHANGED
|
@@ -6,15 +6,59 @@
|
|
|
6
6
|
react-wordgard stays on 0.x for as long as Wordgard does: it follows Wordgard's API and reaches
|
|
7
7
|
into its internals, so it can't be more stable than Wordgard. Its 1.0 comes after Wordgard's.
|
|
8
8
|
|
|
9
|
-
A React view for [Wordgard](https://www.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
18
62
|
|
|
19
63
|
## Install
|
|
20
64
|
|
|
@@ -22,21 +66,19 @@ builds on.
|
|
|
22
66
|
npm install @stesura/react-wordgard wordgard react react-dom
|
|
23
67
|
```
|
|
24
68
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
Code.
|
|
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)).
|
|
33
76
|
|
|
34
77
|
## Usage
|
|
35
78
|
|
|
36
79
|
```tsx
|
|
37
80
|
import { useState } from "react";
|
|
38
81
|
import { Decoration, Wordgard } from "wordgard/editor";
|
|
39
|
-
// `image()` adds images to the schema; `Image` is their node type, which decorations name.
|
|
40
82
|
import { basicSchema, image } from "wordgard/schema";
|
|
41
83
|
import { Image } from "wordgard/types";
|
|
42
84
|
import {
|
|
@@ -47,12 +89,11 @@ import {
|
|
|
47
89
|
useEditorStateSelector,
|
|
48
90
|
} from "@stesura/react-wordgard";
|
|
49
91
|
|
|
50
|
-
// An image's shape, drawn by a React component with state of its own: hovering outlines it.
|
|
51
|
-
// DecorationRoot is its root: the node type's element, an <img>, with the node's attributes (its
|
|
52
|
-
// `src` and `alt`, the selected node's class) and the component's own props.
|
|
53
92
|
const ImageShape = createShape(function ImageShape() {
|
|
54
93
|
const [hover, setHover] = useState(false);
|
|
55
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.
|
|
56
97
|
<DecorationRoot
|
|
57
98
|
style={hover ? { outline: "2px solid" } : undefined}
|
|
58
99
|
onMouseEnter={() => setHover(true)}
|
|
@@ -61,12 +102,12 @@ const ImageShape = createShape(function ImageShape() {
|
|
|
61
102
|
);
|
|
62
103
|
});
|
|
63
104
|
|
|
64
|
-
|
|
105
|
+
const WordCount = () => {
|
|
65
106
|
const words = useEditorStateSelector((state) => state.doc.textContent().split(/\s+/).length);
|
|
66
107
|
return <output>{words} words</output>;
|
|
67
|
-
}
|
|
108
|
+
};
|
|
68
109
|
|
|
69
|
-
export
|
|
110
|
+
export const Editor = () => {
|
|
70
111
|
return (
|
|
71
112
|
<WordgardEditor
|
|
72
113
|
doc='<p>Hello <strong>world</strong> <img src="/cat.png" alt="A cat"></p>'
|
|
@@ -81,52 +122,105 @@ export function Editor() {
|
|
|
81
122
|
<WordgardDoc className="editor" />
|
|
82
123
|
</WordgardEditor>
|
|
83
124
|
);
|
|
84
|
-
}
|
|
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
|
+
);
|
|
85
162
|
```
|
|
86
163
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
```
|
|
122
216
|
|
|
123
217
|
### React decorations
|
|
124
218
|
|
|
125
|
-
|
|
126
|
-
wrappers and marks render
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
130
224
|
|
|
131
225
|
```tsx
|
|
132
226
|
import { Decoration } from "wordgard/editor";
|
|
@@ -163,59 +257,57 @@ const config = [
|
|
|
163
257
|
];
|
|
164
258
|
```
|
|
165
259
|
|
|
166
|
-
- `createShape(component)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
-
|
|
178
|
-
|
|
179
|
-
and a `contentDOMRef` throws.
|
|
180
|
-
- `shape.of(element, { atom? })` is the shape with `element` for its root, for
|
|
181
|
-
`Decoration.Point.shape` (which applies to whatever node follows, so no type says the element)
|
|
182
|
-
or a root other than the type's: `Decoration.Tag.shape(Blockquote, CalloutShape.of("aside"))`.
|
|
183
|
-
With `atom: true`, it has no hole: a leaf needs it (Wordgard refuses a hole in a leaf's shape),
|
|
184
|
-
and it draws a plot as an atom.
|
|
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`.
|
|
185
273
|
- `createWrapper(component, { element? })`, for `Decoration.Tag.wrapper` and
|
|
186
|
-
`Decoration.Point.wrapper`: the same props without `contentDOMRef
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
`
|
|
191
|
-
|
|
192
|
-
shape is a static spec, so the element can't be read from the mark type. A React mark replacing
|
|
193
|
-
an attribute-shaped mark makes it element-shaped.
|
|
194
|
-
- The root is the element the decoration says, and stays that element. Wordgard applies
|
|
195
|
-
decorations to a shape before anything is drawn (`target` selectors match its tag, attributes
|
|
196
|
-
merge onto its elements), so the tag has to be known up front.
|
|
197
|
-
- `<DecorationRoot>` draws it, in a shape's, wrapper's or mark's component: that tag, with
|
|
198
|
-
Wordgard's attributes (the node type's own, the selection classes `wg-selected-node` and
|
|
199
|
-
`wg-selected-cell`, tag, point and mark attributes, `contenteditable` on an atom) and the root's
|
|
200
|
-
ref. The component's own props win, except `className`, joined with Wordgard's, and `style`,
|
|
201
|
-
merged with it (the component's keys winning). Its `ref` gets the root too. Without a
|
|
202
|
-
`contentDOMRef`, the content goes in it. One per component, at its root.
|
|
203
|
-
- Or the component draws its root by hand, for more control over the decoration's attributes:
|
|
204
|
-
below.
|
|
205
|
-
- A root with another tag than the decoration's logs an error, once per tag.
|
|
206
|
-
- The chrome is made uneditable unless it sets `contentEditable` itself.
|
|
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`.
|
|
207
280
|
|
|
208
281
|
#### A root drawn by hand
|
|
209
282
|
|
|
210
283
|
`DecorationRoot` applies all of Wordgard's attributes. To decide what reaches the root (drop one,
|
|
211
284
|
change one, join Wordgard's classes your way) or to type the root as its element, the component
|
|
212
|
-
draws the root itself. It gets Wordgard's attributes as props and a `ref` for the root
|
|
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:
|
|
213
306
|
|
|
214
307
|
```tsx
|
|
215
308
|
import { forwardRef } from "react";
|
|
216
309
|
import { type ShapeComponentProps, createShape } from "@stesura/react-wordgard";
|
|
217
310
|
|
|
218
|
-
// An image whose root drops the `title` a decoration gives it and adds a class of its own.
|
|
219
311
|
const ImageShape = createShape(
|
|
220
312
|
forwardRef<HTMLImageElement, ShapeComponentProps<HTMLImageElement>>(function ImageShape(
|
|
221
313
|
{ nodeProps, children, className, title, ...props },
|
|
@@ -227,13 +319,13 @@ const ImageShape = createShape(
|
|
|
227
319
|
```
|
|
228
320
|
|
|
229
321
|
- Draw the element the decoration says (the node type's for a shape, `element` for a wrapper or
|
|
230
|
-
mark), spread the props, and put `ref` on it.
|
|
231
|
-
isn't needed: `createShape<HTMLImageElement>(function ImageShape({ ref, ...props }) {…})`.
|
|
322
|
+
mark), spread the props, and put `ref` on it.
|
|
232
323
|
- `className` holds Wordgard's classes (the selection's among them): join yours with them, or
|
|
233
324
|
they're lost.
|
|
234
325
|
- `style` holds Wordgard's style as an object: spread yours over it,
|
|
235
326
|
`style={{ ...style, color: "red" }}`, or it replaces Wordgard's.
|
|
236
|
-
-
|
|
327
|
+
- Use one or the other: spreading these props onto `<DecorationRoot>` applies Wordgard's
|
|
328
|
+
attributes twice (its classes appear twice).
|
|
237
329
|
- `createWrapper` and `createMarkShape` components work the same way (`WrapperComponentProps`,
|
|
238
330
|
`MarkComponentProps`).
|
|
239
331
|
|
|
@@ -274,153 +366,507 @@ const config = [Decoration.Tag.widget(Paragraph, "start", Pilcrow.of("¶"))];
|
|
|
274
366
|
|
|
275
367
|
#### Hooks inside React decorations
|
|
276
368
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
read with `node.tag.mark(Type)`, changed with `{ from: pos, add: Type.of(value) }` and
|
|
320
|
-
`{ from: pos, remove: Type.of(value) }`. That's Wordgard's model: a mark change merges in
|
|
321
|
-
collaboration and inverts in history, and the component re-renders. Wordgard's `ImageSize` is
|
|
322
|
-
one; the demo's callout keeps its kind and title so (`demo/schema.ts`).
|
|
323
|
-
- What identifies the node (an image's `src`, a heading's level) is its parameter. Change it by
|
|
324
|
-
retagging, replacing the node's opening token by one of the same type, as Wordgard's commands
|
|
325
|
-
do: `{ from: pos, to: pos + 1, insert: [Heading.of(2)] }`. The component re-renders, and its
|
|
326
|
-
content keeps its components.
|
|
327
|
-
- Replacing the node whole (`{ from, to, insert: [node] }`) or changing its type remounts it.
|
|
328
|
-
- A move keeps the component, and the components inside, when the node stays in its parent: in one
|
|
329
|
-
transaction, it's deleted and the same node object is inserted elsewhere in that parent. A copy
|
|
330
|
-
(a new node, or the same one inserted while the original stays) mounts anew, and so does a node
|
|
331
|
-
moved to another parent. React point widgets inside a moved node are drawn anew, as Wordgard
|
|
332
|
-
redraws them. React moves the node's DOM, and a browser reloads an `<iframe>` on any
|
|
333
|
-
DOM move. Wordgard's drag-and-drop rebuilds what it drops from HTML, so its moves remount.
|
|
334
|
-
|
|
335
|
-
ProseMirror's `setNodeAttribute` and `setNodeMarkup` port to one of the first two.
|
|
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
|
+
```
|
|
336
411
|
|
|
337
412
|
### Accessibility
|
|
338
413
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
- A
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
-
|
|
346
|
-
|
|
347
|
-
- `DecorationRoot` puts the node's attributes on its root, an image's `alt` among them, in a React
|
|
348
|
-
shape or a widget drawing a node. A root drawn by hand gets them as props: spread them onto it.
|
|
349
|
-
- The tests run axe-core over editors with React shapes, marks and widgets
|
|
350
|
-
(`src/__tests__/accessibility.browser.test.tsx`): nothing breaks a rule plain Wordgard keeps.
|
|
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.
|
|
351
422
|
|
|
352
423
|
### Limits
|
|
353
424
|
|
|
354
|
-
-
|
|
355
|
-
|
|
356
|
-
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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.
|
|
381
787
|
|
|
382
788
|
## Migrating from react-prosemirror
|
|
383
789
|
|
|
384
790
|
react-wordgard isn't a compatibility layer: it follows Wordgard's API, and most of a migration is
|
|
385
791
|
moving from ProseMirror's concepts to Wordgard's (see Wordgard's
|
|
386
|
-
[ProseMirror migration guide](https://wordgard.net/docs/prosemirror/)).
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
|
391
|
-
|
|
|
392
|
-
| `<
|
|
393
|
-
|
|
|
394
|
-
| `
|
|
395
|
-
| `
|
|
396
|
-
| `
|
|
397
|
-
| `
|
|
398
|
-
| `
|
|
399
|
-
| `
|
|
400
|
-
| `
|
|
401
|
-
| `
|
|
402
|
-
| `
|
|
403
|
-
| `
|
|
404
|
-
| `
|
|
405
|
-
| `
|
|
406
|
-
| `
|
|
407
|
-
| `
|
|
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 |
|
|
408
815
|
|
|
409
816
|
Not offered: `useIsComposingIn`, `useIsEditorStatic`.
|
|
410
817
|
|
|
411
818
|
### Migrating a node view
|
|
412
819
|
|
|
413
820
|
A react-prosemirror node view keeps its body: the props keep their names (`nodeProps`,
|
|
414
|
-
`contentDOMRef`, `children`, `ref`, the attributes).
|
|
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:
|
|
415
859
|
|
|
416
860
|
- The props type: `ShapeComponentProps` (any node, atoms included), `WrapperComponentProps`,
|
|
417
861
|
`MarkComponentProps`.
|
|
418
862
|
- The root's tag isn't the component's choice: it's the node type's own element, what `toDOM`'s
|
|
419
|
-
outer element was (see
|
|
420
|
-
|
|
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.
|
|
421
867
|
- `node.attrs` becomes the node's parameter (`node.tag.param`, `node.param` for a leaf) for what
|
|
422
|
-
identifies it, and marks on its tag (`node.tag.mark(Type)`) for data changed in place
|
|
423
|
-
|
|
868
|
+
identifies it, and marks on its tag (`node.tag.mark(Type)`) for data changed in place. `view`
|
|
869
|
+
becomes `wg`, Wordgard's editor.
|
|
424
870
|
- Registration: `Decoration.Tag.shape(NodeType, createShape(component))` in the editor's `config`
|
|
425
871
|
instead of `nodeViews`.
|
|
426
872
|
- Class names: `ProseMirror-selectednode` is `wg-selected-node`, a selected cell's class
|
|
@@ -430,11 +876,21 @@ A react-prosemirror node view keeps its body: the props keep their names (`nodeP
|
|
|
430
876
|
|
|
431
877
|
### Effects and timing
|
|
432
878
|
|
|
433
|
-
In react-prosemirror React drives: `useEditorEffect` is a React layout effect that runs after the
|
|
879
|
+
In react-prosemirror, React drives: `useEditorEffect` is a React layout effect that runs after the
|
|
434
880
|
view's update, inside React's commit, so it can read layout, dispatch, and what it sets shows on the
|
|
435
|
-
same paint. In react-wordgard Wordgard drives: React commits inside Wordgard's update, and those
|
|
881
|
+
same paint. In react-wordgard, Wordgard drives: React commits inside Wordgard's update, and those
|
|
436
882
|
properties split between two hooks.
|
|
437
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
|
+
|
|
438
894
|
| | react-prosemirror `useEditorEffect` | `useEditorEffect` | `useEditorLayoutEffect` |
|
|
439
895
|
| ----------------------------------------- | ----------------------------------- | ---------------------------------- | ---------------------------------------------------- |
|
|
440
896
|
| Reads layout after the update | yes | yes | yes, in Wordgard's read phase |
|
|
@@ -443,6 +899,12 @@ properties split between two hooks.
|
|
|
443
899
|
| Runs on every update without re-rendering | no | no | yes |
|
|
444
900
|
| For | everything | menus, focus, follow-up dispatches | what moves with the text: margin markers, pagination |
|
|
445
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
|
+
|
|
446
908
|
## Licence
|
|
447
909
|
|
|
448
910
|
MIT, © 2026 Edoardo Caratti, see [LICENSE.txt](LICENSE.txt).
|