@pyreon/rich-text 0.46.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/LICENSE +21 -0
- package/README.md +107 -0
- package/lib/index.js +346 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +210 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Vit Bokisch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# @pyreon/rich-text
|
|
2
|
+
|
|
3
|
+
Reactive WYSIWYG rich-text editor for Pyreon — a thin, signal-backed layer over
|
|
4
|
+
[TipTap](https://tiptap.dev) (the MIT, framework-agnostic headless editor
|
|
5
|
+
framework built on [ProseMirror](https://prosemirror.net)). Same adapter shape
|
|
6
|
+
as `@pyreon/code` (CodeMirror) and `@pyreon/charts` (ECharts): a best-in-class
|
|
7
|
+
engine, wrapped in Pyreon's fine-grained reactivity.
|
|
8
|
+
|
|
9
|
+
- **Signal-backed document** — `editor.json` is a writable `Signal<JSONContent>`;
|
|
10
|
+
`html` / `text` / `isEmpty` / `characterCount` / `wordCount` / `canUndo` /
|
|
11
|
+
`canRedo` are computed signals; `editable` is a writable read-only toggle.
|
|
12
|
+
`characterCount` / `wordCount` / `isEmpty` derive from the document JSON, so
|
|
13
|
+
they report accurately **before the engine mounts** (stored-JSON draft lists),
|
|
14
|
+
count visible characters, and — like every content computed — never re-run on
|
|
15
|
+
a pure cursor move (only `isActive` tracks the selection).
|
|
16
|
+
- **Toolbar-ready** — `editor.isActive('bold')` is a reactive accessor for
|
|
17
|
+
active-state highlighting; commands run through `editor.chain()` plus
|
|
18
|
+
`undo` / `redo` / `focus` / `blur` helpers.
|
|
19
|
+
- **Lazy engine** — `@tiptap/*` is dynamically imported on mount, so it stays
|
|
20
|
+
out of the initial bundle (a ~1.5 KB gz wrapper; the engine is a lazy chunk).
|
|
21
|
+
A re-mount keeps the current document, disposing mid-load is leak-safe, and a
|
|
22
|
+
mount failure routes to `onError`.
|
|
23
|
+
- **Accessible** — the content area is a labeled `role="textbox"` multiline
|
|
24
|
+
region.
|
|
25
|
+
- **MIT throughout** — TipTap + ProseMirror are MIT; collaboration composes
|
|
26
|
+
with `@pyreon/sync` (no paid cloud).
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
bun add @pyreon/rich-text
|
|
32
|
+
# peer: @pyreon/runtime-dom (the <RichText> JSX emits _tpl()/_bind())
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
import { createRichTextEditor, RichText, bindRichTextToSignal } from '@pyreon/rich-text'
|
|
39
|
+
import { signal } from '@pyreon/reactivity'
|
|
40
|
+
|
|
41
|
+
const editor = createRichTextEditor({
|
|
42
|
+
content: '<p>Hello <strong>world</strong></p>',
|
|
43
|
+
ariaLabel: 'Post body',
|
|
44
|
+
onChange: (json) => console.log('user edit:', json),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// editor.json is a writable Signal<JSONContent>
|
|
48
|
+
editor.json() // reactive read — tracks in effects/JSX
|
|
49
|
+
editor.json.set(draft) // replace content (loop-safe)
|
|
50
|
+
editor.text() // computed plain text
|
|
51
|
+
editor.characterCount() // computed number
|
|
52
|
+
editor.canUndo() // computed boolean
|
|
53
|
+
|
|
54
|
+
// Run a TipTap command:
|
|
55
|
+
editor.chain()?.toggleBold().run()
|
|
56
|
+
|
|
57
|
+
// Mount (lazy-loads TipTap):
|
|
58
|
+
<RichText instance={editor} style="min-height: 12rem" />
|
|
59
|
+
|
|
60
|
+
// Tear down (user-owned lifecycle):
|
|
61
|
+
// onCleanup(() => editor.dispose())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Toolbar
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
// Command + reactive active-state (call isActive inside a reactive scope):
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
class={() => (editor.isActive('bold') ? 'active' : '')}
|
|
71
|
+
onClick={() => editor.chain()?.toggleBold().run()}
|
|
72
|
+
>
|
|
73
|
+
Bold
|
|
74
|
+
</button>
|
|
75
|
+
|
|
76
|
+
<button disabled={() => !editor.canUndo()} onClick={() => editor.undo()}>Undo</button>
|
|
77
|
+
|
|
78
|
+
// Runtime read-only toggle:
|
|
79
|
+
editor.editable.set(false) // read-only
|
|
80
|
+
editor.editable() // reactive boolean
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Two-way binding
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const draft = signal(editor.json())
|
|
87
|
+
const binding = bindRichTextToSignal({ editor, signal: draft })
|
|
88
|
+
// HTML form instead: bindRichTextToSignal({ editor, signal: htmlStr, format: 'html' })
|
|
89
|
+
// onCleanup(() => binding.dispose())
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`bindRichTextToSignal` is the editor mirror of `@pyreon/code`'s
|
|
93
|
+
`bindEditorToSignal` — internal flags break the echo loop, and a value
|
|
94
|
+
compare short-circuits no-op writes.
|
|
95
|
+
|
|
96
|
+
## Collaboration (with `@pyreon/sync`)
|
|
97
|
+
|
|
98
|
+
Real-time collaboration composes with `@pyreon/sync` rather than a paid cloud:
|
|
99
|
+
bind the editor to the same `Y.Doc` XML fragment via TipTap's collaboration
|
|
100
|
+
extension, and reuse `@pyreon/sync` transports + `syncedAwareness` for live
|
|
101
|
+
cursors. See `docs/` for the full pattern.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT. TipTap (`@tiptap/core`, `@tiptap/starter-kit`, `@tiptap/pm`) and
|
|
106
|
+
ProseMirror are MIT. The TipTap Pro modules (Cloud, Comments, Content AI,
|
|
107
|
+
document-conversion) are paid/commercially-licensed and are **not** used.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { computed, effect, registerSingleton, signal, wrapSignal } from "@pyreon/reactivity";
|
|
2
|
+
import { cx } from "@pyreon/core";
|
|
3
|
+
import { jsx } from "@pyreon/core/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
//#region package.json
|
|
6
|
+
var name = "@pyreon/rich-text";
|
|
7
|
+
var version = "0.46.0";
|
|
8
|
+
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/editor.ts
|
|
11
|
+
const EMPTY_DOC = {
|
|
12
|
+
type: "doc",
|
|
13
|
+
content: []
|
|
14
|
+
};
|
|
15
|
+
/** Push every text node's string (depth-first) into `acc`. */
|
|
16
|
+
function collectTextNodes(node, acc) {
|
|
17
|
+
if (node.type === "text") {
|
|
18
|
+
if (node.text) acc.push(node.text);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const kids = node.content;
|
|
22
|
+
if (kids) for (const child of kids) collectTextNodes(child, acc);
|
|
23
|
+
}
|
|
24
|
+
/** Total VISIBLE character count — sum of every text node's length. */
|
|
25
|
+
function countChars(node) {
|
|
26
|
+
const acc = [];
|
|
27
|
+
collectTextNodes(node, acc);
|
|
28
|
+
let total = 0;
|
|
29
|
+
for (const s of acc) total += s.length;
|
|
30
|
+
return total;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Concatenate each textblock's inline text (marks joined without a separator,
|
|
34
|
+
* so a mark-split word stays one word), one string per block.
|
|
35
|
+
*/
|
|
36
|
+
function collectBlockTexts(node, out) {
|
|
37
|
+
const kids = node.content;
|
|
38
|
+
if (!kids || kids.length === 0) return;
|
|
39
|
+
if (kids.some((c) => c.type === "text")) {
|
|
40
|
+
const acc = [];
|
|
41
|
+
collectTextNodes(node, acc);
|
|
42
|
+
out.push(acc.join(""));
|
|
43
|
+
} else for (const child of kids) collectBlockTexts(child, out);
|
|
44
|
+
}
|
|
45
|
+
/** Whitespace-delimited word count (block boundaries never merge words). */
|
|
46
|
+
function countWords(node) {
|
|
47
|
+
const blocks = [];
|
|
48
|
+
collectBlockTexts(node, blocks);
|
|
49
|
+
let total = 0;
|
|
50
|
+
for (const b of blocks) {
|
|
51
|
+
const t = b.trim();
|
|
52
|
+
if (t !== "") total += t.split(/\s+/).length;
|
|
53
|
+
}
|
|
54
|
+
return total;
|
|
55
|
+
}
|
|
56
|
+
/** Best-effort plain text — blocks joined with `\n\n` (matches `getText`). */
|
|
57
|
+
function extractText(node) {
|
|
58
|
+
const blocks = [];
|
|
59
|
+
collectBlockTexts(node, blocks);
|
|
60
|
+
return blocks.join("\n\n");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Create a reactive WYSIWYG rich-text editor instance.
|
|
64
|
+
*
|
|
65
|
+
* The document state (`json` / `html` / `text` / counts / undo-redo) is
|
|
66
|
+
* backed by signals; the TipTap `Editor` (over ProseMirror) is created
|
|
67
|
+
* lazily when the instance is mounted via `<RichText>` — so `@tiptap/*`
|
|
68
|
+
* stays out of the initial bundle (same lazy-load shape as
|
|
69
|
+
* `@pyreon/code`'s languages and `@pyreon/charts`' ECharts).
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```tsx
|
|
73
|
+
* const editor = createRichTextEditor({ content: '<p>Hello</p>' })
|
|
74
|
+
*
|
|
75
|
+
* editor.json() // reactive ProseMirror JSON
|
|
76
|
+
* editor.json.set(draft) // replace content (loop-safe)
|
|
77
|
+
* editor.text() // reactive plain text
|
|
78
|
+
* editor.chain()?.toggleBold().run()
|
|
79
|
+
*
|
|
80
|
+
* <RichText instance={editor} style="min-height: 12rem" />
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
function createRichTextEditor(config = {}) {
|
|
84
|
+
const { content = "", editable: initialEditable = true, ariaLabel = "Rich text editor", starterKit = true, extensions: userExtensions = [], autofocus = false, onChange, onError } = config;
|
|
85
|
+
const baseJson = signal(typeof content === "object" && content ? content : EMPTY_DOC);
|
|
86
|
+
const focused = signal(false);
|
|
87
|
+
const view = signal(null);
|
|
88
|
+
const baseEditable = signal(initialEditable);
|
|
89
|
+
const docVersion = signal(0);
|
|
90
|
+
const selectionVersion = signal(0);
|
|
91
|
+
const editable = wrapSignal(baseEditable, { set: (next) => {
|
|
92
|
+
view.peek()?.setEditable(next);
|
|
93
|
+
baseEditable.set(next);
|
|
94
|
+
} });
|
|
95
|
+
let applyingExternal = false;
|
|
96
|
+
let pendingContent = content;
|
|
97
|
+
let mountToken = 0;
|
|
98
|
+
let hasMountedOnce = false;
|
|
99
|
+
const json = wrapSignal(baseJson, { set: (next) => {
|
|
100
|
+
const e = view.peek();
|
|
101
|
+
if (e) {
|
|
102
|
+
applyingExternal = true;
|
|
103
|
+
e.commands.setContent(next);
|
|
104
|
+
applyingExternal = false;
|
|
105
|
+
} else pendingContent = next;
|
|
106
|
+
baseJson.set(next);
|
|
107
|
+
} });
|
|
108
|
+
const readEditor = () => {
|
|
109
|
+
docVersion();
|
|
110
|
+
return view.peek();
|
|
111
|
+
};
|
|
112
|
+
const html = computed(() => readEditor()?.getHTML() ?? (typeof content === "string" ? content : ""));
|
|
113
|
+
const canUndo = computed(() => readEditor()?.can().undo() ?? false);
|
|
114
|
+
const canRedo = computed(() => readEditor()?.can().redo() ?? false);
|
|
115
|
+
const text = computed(() => readEditor()?.getText() ?? extractText(baseJson()));
|
|
116
|
+
const characterCount = computed(() => countChars(baseJson()));
|
|
117
|
+
const wordCount = computed(() => countWords(baseJson()));
|
|
118
|
+
const isEmpty = computed(() => countChars(baseJson()) === 0);
|
|
119
|
+
/**
|
|
120
|
+
* Whether a mark/node is active at the current selection — reactive (reads
|
|
121
|
+
* the transaction counter, so it re-derives on every edit + selection
|
|
122
|
+
* move). The toolbar primitive: `editor.isActive('bold')`,
|
|
123
|
+
* `editor.isActive('heading', { level: 2 })`. Read it inside a reactive
|
|
124
|
+
* scope (a `() => …` thunk in JSX, an `effect`, a `computed`).
|
|
125
|
+
*/
|
|
126
|
+
const isActive = (name, attrs) => {
|
|
127
|
+
docVersion();
|
|
128
|
+
selectionVersion();
|
|
129
|
+
const e = view.peek();
|
|
130
|
+
if (!e) return false;
|
|
131
|
+
return typeof name === "string" ? e.isActive(name, attrs) : e.isActive(name);
|
|
132
|
+
};
|
|
133
|
+
const chain = () => view.peek()?.chain() ?? null;
|
|
134
|
+
const focus = () => {
|
|
135
|
+
view.peek()?.commands.focus();
|
|
136
|
+
};
|
|
137
|
+
const blur = () => {
|
|
138
|
+
view.peek()?.commands.blur();
|
|
139
|
+
};
|
|
140
|
+
const undo = () => {
|
|
141
|
+
view.peek()?.chain().undo().run();
|
|
142
|
+
};
|
|
143
|
+
const redo = () => {
|
|
144
|
+
view.peek()?.chain().redo().run();
|
|
145
|
+
};
|
|
146
|
+
const dispose = () => {
|
|
147
|
+
mountToken++;
|
|
148
|
+
const e = view.peek();
|
|
149
|
+
if (e) {
|
|
150
|
+
e.destroy();
|
|
151
|
+
view.set(null);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const _mount = async (parent) => {
|
|
155
|
+
if (view.peek()) return;
|
|
156
|
+
const token = ++mountToken;
|
|
157
|
+
try {
|
|
158
|
+
const { Editor } = await import("@tiptap/core");
|
|
159
|
+
const exts = [];
|
|
160
|
+
if (starterKit) {
|
|
161
|
+
const { StarterKit } = await import("@tiptap/starter-kit");
|
|
162
|
+
exts.push(StarterKit);
|
|
163
|
+
}
|
|
164
|
+
exts.push(...userExtensions);
|
|
165
|
+
if (token !== mountToken) return;
|
|
166
|
+
const editor = new Editor({
|
|
167
|
+
element: parent,
|
|
168
|
+
extensions: exts,
|
|
169
|
+
content: hasMountedOnce ? baseJson.peek() : pendingContent,
|
|
170
|
+
editable: baseEditable.peek(),
|
|
171
|
+
autofocus,
|
|
172
|
+
editorProps: { attributes: {
|
|
173
|
+
role: "textbox",
|
|
174
|
+
"aria-multiline": "true",
|
|
175
|
+
"aria-label": ariaLabel
|
|
176
|
+
} },
|
|
177
|
+
onUpdate: ({ editor: e }) => {
|
|
178
|
+
docVersion.update((v) => v + 1);
|
|
179
|
+
if (!applyingExternal) {
|
|
180
|
+
const next = e.getJSON();
|
|
181
|
+
baseJson.set(next);
|
|
182
|
+
onChange?.(next);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
onSelectionUpdate: () => selectionVersion.update((v) => v + 1),
|
|
186
|
+
onFocus: () => focused.set(true),
|
|
187
|
+
onBlur: () => focused.set(false)
|
|
188
|
+
});
|
|
189
|
+
if (token !== mountToken) {
|
|
190
|
+
editor.destroy();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
view.set(editor);
|
|
194
|
+
hasMountedOnce = true;
|
|
195
|
+
baseJson.set(editor.getJSON());
|
|
196
|
+
docVersion.update((v) => v + 1);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
199
|
+
if (onError) onError(error);
|
|
200
|
+
else if (process.env.NODE_ENV !== "production") console.error("[Pyreon] @pyreon/rich-text failed to mount the editor:", error);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
return {
|
|
204
|
+
json,
|
|
205
|
+
html,
|
|
206
|
+
text,
|
|
207
|
+
isEmpty,
|
|
208
|
+
characterCount,
|
|
209
|
+
wordCount,
|
|
210
|
+
canUndo,
|
|
211
|
+
canRedo,
|
|
212
|
+
focused,
|
|
213
|
+
editable,
|
|
214
|
+
view,
|
|
215
|
+
isActive,
|
|
216
|
+
chain,
|
|
217
|
+
focus,
|
|
218
|
+
blur,
|
|
219
|
+
undo,
|
|
220
|
+
redo,
|
|
221
|
+
dispose,
|
|
222
|
+
_mount
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/components/rich-text.tsx
|
|
228
|
+
/**
|
|
229
|
+
* Mount component for a {@link createRichTextEditor} instance. Creates the
|
|
230
|
+
* TipTap editor inside a container `<div>` on render (lazy-loading the
|
|
231
|
+
* engine). The instance is framework-independent — create it with
|
|
232
|
+
* `createRichTextEditor`, mount it here.
|
|
233
|
+
*
|
|
234
|
+
* Lifecycle: like `@pyreon/code`'s `<CodeEditor>`, the editor instance is
|
|
235
|
+
* **user-owned** — call `editor.dispose()` in your `onCleanup` /
|
|
236
|
+
* `onUnmount` if the instance won't be reused (the component does not
|
|
237
|
+
* auto-dispose, so the same instance can be re-mounted).
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* ```tsx
|
|
241
|
+
* const editor = createRichTextEditor({ content: '<p>Hello</p>' })
|
|
242
|
+
* <RichText instance={editor} style="min-height: 12rem" />
|
|
243
|
+
* ```
|
|
244
|
+
*/
|
|
245
|
+
function RichText(props) {
|
|
246
|
+
const { instance } = props;
|
|
247
|
+
const containerRef = (el) => {
|
|
248
|
+
if (!el) return;
|
|
249
|
+
instance._mount(el);
|
|
250
|
+
};
|
|
251
|
+
return /* @__PURE__ */ jsx("div", {
|
|
252
|
+
ref: containerRef,
|
|
253
|
+
class: cx(["pyreon-rich-text", props.class]),
|
|
254
|
+
style: props.style ?? ""
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/bind-signal.ts
|
|
260
|
+
/**
|
|
261
|
+
* Two-way bind a Pyreon `Signal` to a rich-text editor's content, with
|
|
262
|
+
* built-in loop prevention — the editor mirror of `@pyreon/code`'s
|
|
263
|
+
* `bindEditorToSignal`. Use `format: 'json'` to persist/restore the
|
|
264
|
+
* structured document, or `format: 'html'` for an HTML string.
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* ```ts
|
|
268
|
+
* const draft = signal<JSONContent>({ type: 'doc', content: [] })
|
|
269
|
+
* const editor = createRichTextEditor()
|
|
270
|
+
* const binding = bindRichTextToSignal({ editor, signal: draft })
|
|
271
|
+
* onCleanup(() => binding.dispose())
|
|
272
|
+
* ```
|
|
273
|
+
*/
|
|
274
|
+
function bindRichTextToSignal(options) {
|
|
275
|
+
const { editor, signal, format = "json", onError } = options;
|
|
276
|
+
let applyingFromSignal = false;
|
|
277
|
+
let applyingFromEditor = false;
|
|
278
|
+
const signalToEditor = effect(() => {
|
|
279
|
+
const value = signal();
|
|
280
|
+
if (applyingFromEditor) return;
|
|
281
|
+
applyingFromSignal = true;
|
|
282
|
+
try {
|
|
283
|
+
if (format === "html") {
|
|
284
|
+
const e = editor.view.peek();
|
|
285
|
+
if (e && value !== e.getHTML()) e.commands.setContent(value);
|
|
286
|
+
} else if (JSON.stringify(value) !== JSON.stringify(editor.json.peek())) editor.json.set(value);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
onError?.(err);
|
|
289
|
+
} finally {
|
|
290
|
+
applyingFromSignal = false;
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
const editorToSignal = effect(() => {
|
|
294
|
+
const out = format === "html" ? editor.html() : editor.json();
|
|
295
|
+
/* v8 ignore next — defensive loop guard. The external→editor sync path
|
|
296
|
+
sets applyingFromSignal then resets it inside the same synchronous
|
|
297
|
+
block; Pyreon defers a cross-effect re-run past that block (the flag is
|
|
298
|
+
already false when this effect re-runs), so the bail is unreachable
|
|
299
|
+
under the current scheduler but kept to match @pyreon/code's guard. */
|
|
300
|
+
if (applyingFromSignal) return;
|
|
301
|
+
applyingFromEditor = true;
|
|
302
|
+
try {
|
|
303
|
+
signal.set(out);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
onError?.(err);
|
|
306
|
+
} finally {
|
|
307
|
+
applyingFromEditor = false;
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
return { dispose: () => {
|
|
311
|
+
signalToEditor.dispose();
|
|
312
|
+
editorToSignal.dispose();
|
|
313
|
+
} };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/index.ts
|
|
318
|
+
/**
|
|
319
|
+
* @pyreon/rich-text — Reactive WYSIWYG rich-text editor for Pyreon.
|
|
320
|
+
*
|
|
321
|
+
* A thin signal-backed layer over TipTap (the MIT, framework-agnostic
|
|
322
|
+
* headless editor framework built on ProseMirror) — the same adapter
|
|
323
|
+
* shape as `@pyreon/code` (CodeMirror) and `@pyreon/charts` (ECharts).
|
|
324
|
+
* The document state is exposed as signals; the TipTap engine is
|
|
325
|
+
* lazy-loaded on mount so `@tiptap/*` stays out of the initial bundle.
|
|
326
|
+
*
|
|
327
|
+
* @example
|
|
328
|
+
* ```tsx
|
|
329
|
+
* import { createRichTextEditor, RichText, bindRichTextToSignal } from '@pyreon/rich-text'
|
|
330
|
+
* import { signal } from '@pyreon/reactivity'
|
|
331
|
+
*
|
|
332
|
+
* const editor = createRichTextEditor({ content: '<p>Hello</p>' })
|
|
333
|
+
*
|
|
334
|
+
* editor.json() // reactive ProseMirror JSON
|
|
335
|
+
* editor.json.set(draft) // replace content (loop-safe)
|
|
336
|
+
* editor.text() // reactive plain text
|
|
337
|
+
* editor.chain()?.toggleBold().run() // run a command
|
|
338
|
+
*
|
|
339
|
+
* <RichText instance={editor} style="min-height: 12rem" />
|
|
340
|
+
* ```
|
|
341
|
+
*/
|
|
342
|
+
registerSingleton(name, version, import.meta.url);
|
|
343
|
+
|
|
344
|
+
//#endregion
|
|
345
|
+
export { RichText, bindRichTextToSignal, createRichTextEditor };
|
|
346
|
+
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["__pkgName","__pkgVersion"],"sources":["../package.json","../src/editor.ts","../src/components/rich-text.tsx","../src/bind-signal.ts","../src/index.ts"],"sourcesContent":["","import { computed, signal, wrapSignal } from '@pyreon/reactivity'\nimport type { AnyExtension, Editor, JSONContent } from '@tiptap/core'\nimport type { RichTextConfig, RichTextEditor } from './types'\n\nconst EMPTY_DOC: JSONContent = { type: 'doc', content: [] }\n\n// ── Pure ProseMirror-JSON walkers ──────────────────────────────────────────\n// text / character-count / word-count / isEmpty derive DIRECTLY from the\n// document JSON (`baseJson`), not the mounted TipTap engine. Three payoffs:\n// 1. They work BEFORE mount + AFTER dispose — a stored-JSON draft reports a\n// real character/word count and an accurate `isEmpty` without ever\n// loading the (lazy) engine (draft lists, SSR previews).\n// 2. They re-derive only when the DOCUMENT changes (`baseJson`), never on a\n// pure selection move — moving the cursor no longer re-runs a live\n// word-counter effect (the engine bumps one counter for BOTH update and\n// selection events; content computeds must not subscribe to selection).\n// 3. `characterCount` counts the VISIBLE characters, not the `\\n\\n` block\n// separators TipTap's `getText()` inserts between blocks (`aaa`+`bbb`\n// across two paragraphs is 6, not 8).\n// Schema-less by design (StarterKit-accurate). A custom node whose rendered\n// text differs from its concatenated text descendants may count differently\n// than its live `getText()` — documented; the 99% (StarterKit) case is exact.\n\n/** Push every text node's string (depth-first) into `acc`. */\nfunction collectTextNodes(node: JSONContent, acc: string[]): void {\n if (node.type === 'text') {\n if (node.text) acc.push(node.text)\n return\n }\n const kids = node.content\n if (kids) for (const child of kids) collectTextNodes(child, acc)\n}\n\n/** Total VISIBLE character count — sum of every text node's length. */\nfunction countChars(node: JSONContent): number {\n const acc: string[] = []\n collectTextNodes(node, acc)\n let total = 0\n for (const s of acc) total += s.length\n return total\n}\n\n/**\n * Concatenate each textblock's inline text (marks joined without a separator,\n * so a mark-split word stays one word), one string per block.\n */\nfunction collectBlockTexts(node: JSONContent, out: string[]): void {\n const kids = node.content\n if (!kids || kids.length === 0) return\n // A textblock has at least one direct text child; concatenate its inline\n // text as a single block. Otherwise it's a container — recurse into it.\n if (kids.some((c) => c.type === 'text')) {\n const acc: string[] = []\n collectTextNodes(node, acc)\n out.push(acc.join(''))\n } else {\n for (const child of kids) collectBlockTexts(child, out)\n }\n}\n\n/** Whitespace-delimited word count (block boundaries never merge words). */\nfunction countWords(node: JSONContent): number {\n const blocks: string[] = []\n collectBlockTexts(node, blocks)\n let total = 0\n for (const b of blocks) {\n const t = b.trim()\n if (t !== '') total += t.split(/\\s+/).length\n }\n return total\n}\n\n/** Best-effort plain text — blocks joined with `\\n\\n` (matches `getText`). */\nfunction extractText(node: JSONContent): string {\n const blocks: string[] = []\n collectBlockTexts(node, blocks)\n return blocks.join('\\n\\n')\n}\n\n/**\n * Create a reactive WYSIWYG rich-text editor instance.\n *\n * The document state (`json` / `html` / `text` / counts / undo-redo) is\n * backed by signals; the TipTap `Editor` (over ProseMirror) is created\n * lazily when the instance is mounted via `<RichText>` — so `@tiptap/*`\n * stays out of the initial bundle (same lazy-load shape as\n * `@pyreon/code`'s languages and `@pyreon/charts`' ECharts).\n *\n * @example\n * ```tsx\n * const editor = createRichTextEditor({ content: '<p>Hello</p>' })\n *\n * editor.json() // reactive ProseMirror JSON\n * editor.json.set(draft) // replace content (loop-safe)\n * editor.text() // reactive plain text\n * editor.chain()?.toggleBold().run()\n *\n * <RichText instance={editor} style=\"min-height: 12rem\" />\n * ```\n */\nexport function createRichTextEditor(config: RichTextConfig = {}): RichTextEditor {\n const {\n content = '',\n editable: initialEditable = true,\n ariaLabel = 'Rich text editor',\n starterKit = true,\n extensions: userExtensions = [],\n autofocus = false,\n onChange,\n onError,\n } = config\n\n // ── Reactive state ───────────────────────────────────────────────────\n const baseJson = signal<JSONContent>(\n typeof content === 'object' && content ? content : EMPTY_DOC,\n )\n const focused = signal(false)\n const view = signal<Editor | null>(null)\n const baseEditable = signal(initialEditable)\n // Two transaction counters, deliberately split. `docVersion` bumps only on a\n // CONTENT change (`onUpdate`); `selectionVersion` bumps on a selection move\n // (`onSelectionUpdate`). Content computeds that read the live engine\n // (`html` / `canUndo` / `canRedo`) subscribe to `docVersion` ONLY, so a cursor\n // move doesn't re-run them; `isActive` (mark/node state depends on BOTH the\n // marks present and where the cursor sits) subscribes to both.\n const docVersion = signal(0)\n const selectionVersion = signal(0)\n\n // `editable` is a writable facade: reads delegate to baseEditable; `.set`\n // flips the live editor's editable state (when mounted) then commits to base\n // — the runtime read-only toggle (mirrors `@pyreon/code`'s `readOnly`).\n const editable = wrapSignal(baseEditable, {\n set: (next) => {\n view.peek()?.setEditable(next)\n baseEditable.set(next)\n },\n })\n\n // Loop guard: when WE push content into the editor (`json.set`), the\n // editor's `onUpdate` fires — skip writing back to `baseJson` (we set it\n // explicitly), exactly like `@pyreon/code`'s CM↔signal compare guard.\n let applyingExternal = false\n // The content the editor is created with at mount (covers a string OR a\n // pre-mount `json.set`).\n let pendingContent: string | JSONContent = content\n // Mount-attempt generation. `_mount` lazy-imports `@tiptap/*` (async), so a\n // `dispose()` or a newer `_mount` can land WHILE an import is in flight.\n // `dispose()` bumps this; `_mount` captures it before its awaits and bails\n // (without creating a leaked editor) if it changed — closes the\n // dispose-during-pending-mount leak (orphaned ProseMirror view + DOM).\n let mountToken = 0\n // True once a mount has successfully created the editor. After that, the\n // live document lives in `baseJson` (kept current by `onUpdate` / `json.set`),\n // so a re-mount (dispose → mount the SAME instance, the documented\n // user-owned lifecycle) seeds from the CURRENT doc — not the stale\n // config-time `pendingContent`, which would silently revert edits.\n let hasMountedOnce = false\n\n // `json` is a writable facade: reads/peek/subscribe delegate to baseJson;\n // `.set` pushes into the live editor (when mounted) then commits to base.\n const json = wrapSignal(baseJson, {\n set: (next) => {\n const e = view.peek()\n if (e) {\n applyingExternal = true\n e.commands.setContent(next)\n applyingExternal = false\n } else {\n pendingContent = next\n }\n baseJson.set(next)\n },\n })\n\n const readEditor = (): Editor | null => {\n docVersion() // subscribe — re-derive on each CONTENT change (not selection)\n // pyreon-lint-disable-next-line pyreon/no-peek-in-tracked\n return view.peek()\n }\n\n // Engine-derived, CONTENT-reactive (read the live editor once mounted; skip\n // selection-only churn by subscribing to `docVersion`, not selection).\n const html = computed(() => readEditor()?.getHTML() ?? (typeof content === 'string' ? content : ''))\n const canUndo = computed(() => readEditor()?.can().undo() ?? false)\n const canRedo = computed(() => readEditor()?.can().redo() ?? false)\n\n // Document-derived (pure walkers over `baseJson`). They track the DOCUMENT,\n // not the engine — so they work before mount / after dispose, don't re-run on\n // a pure cursor move, and count visible characters (no `\\n\\n` separators).\n // `text` prefers the live engine's exact `getText()` when mounted (custom\n // node serializers), falling back to the walker before mount.\n const text = computed(() => readEditor()?.getText() ?? extractText(baseJson()))\n const characterCount = computed(() => countChars(baseJson()))\n const wordCount = computed(() => countWords(baseJson()))\n const isEmpty = computed(() => countChars(baseJson()) === 0)\n\n /**\n * Whether a mark/node is active at the current selection — reactive (reads\n * the transaction counter, so it re-derives on every edit + selection\n * move). The toolbar primitive: `editor.isActive('bold')`,\n * `editor.isActive('heading', { level: 2 })`. Read it inside a reactive\n * scope (a `() => …` thunk in JSX, an `effect`, a `computed`).\n */\n const isActive = (name: string | Record<string, unknown>, attrs?: Record<string, unknown>): boolean => {\n // Active-state depends on BOTH content (which marks/nodes exist) AND the\n // selection (where the cursor sits) — subscribe to both counters.\n docVersion()\n selectionVersion()\n // pyreon-lint-disable-next-line pyreon/no-peek-in-tracked\n const e = view.peek()\n if (!e) return false\n return typeof name === 'string' ? e.isActive(name, attrs) : e.isActive(name)\n }\n\n const chain = (): ReturnType<Editor['chain']> | null => view.peek()?.chain() ?? null\n const focus = (): void => {\n view.peek()?.commands.focus()\n }\n const blur = (): void => {\n view.peek()?.commands.blur()\n }\n const undo = (): void => {\n view.peek()?.chain().undo().run()\n }\n const redo = (): void => {\n view.peek()?.chain().redo().run()\n }\n const dispose = (): void => {\n // Invalidate any in-flight `_mount` so a mount whose dynamic import is\n // still loading won't create a live editor AFTER we've torn down.\n mountToken++\n const e = view.peek()\n if (e) {\n e.destroy()\n view.set(null)\n }\n }\n\n const _mount = async (parent: HTMLElement): Promise<void> => {\n if (view.peek()) return // already mounted\n\n // Claim this mount attempt. If `dispose()` (or a newer `_mount`) bumps the\n // token while the dynamic imports below are in flight, we bail before\n // creating the editor — no leaked ProseMirror view + contenteditable DOM.\n const token = ++mountToken\n\n try {\n const { Editor } = await import('@tiptap/core')\n const exts: AnyExtension[] = []\n if (starterKit) {\n const { StarterKit } = await import('@tiptap/starter-kit')\n exts.push(StarterKit)\n }\n exts.push(...userExtensions)\n\n // Superseded while loading (disposed / re-mounted) — abort cleanly.\n if (token !== mountToken) return\n\n const editor = new Editor({\n element: parent,\n extensions: exts,\n // First mount: the config content (a string lives only in\n // `pendingContent`). Re-mount: the live document from `baseJson`.\n content: hasMountedOnce ? baseJson.peek() : pendingContent,\n // honor a pre-mount `editable.set(false)` (config default otherwise).\n editable: baseEditable.peek(),\n autofocus,\n // a11y: the contenteditable content area is a labeled multiline textbox.\n editorProps: {\n attributes: {\n role: 'textbox',\n 'aria-multiline': 'true',\n 'aria-label': ariaLabel,\n },\n },\n onUpdate: ({ editor: e }) => {\n docVersion.update((v) => v + 1)\n if (!applyingExternal) {\n const next = e.getJSON()\n baseJson.set(next)\n onChange?.(next)\n }\n },\n onSelectionUpdate: () => selectionVersion.update((v) => v + 1),\n onFocus: () => focused.set(true),\n onBlur: () => focused.set(false),\n })\n\n // Disposed during the synchronous `new Editor` (defensive) — destroy the\n // just-created view rather than leaking it.\n if (token !== mountToken) {\n editor.destroy()\n return\n }\n\n view.set(editor)\n hasMountedOnce = true\n // Normalize the signal to the editor's parsed doc (covers string content).\n baseJson.set(editor.getJSON())\n docVersion.update((v) => v + 1)\n } catch (err) {\n // Mount failed (broken extension set, throwing extension, failed import).\n // Surface it instead of leaving an unhandled promise rejection: route to\n // the user `onError`, else log a `[Pyreon]`-prefixed message in dev.\n const error = err instanceof Error ? err : new Error(String(err))\n if (onError) {\n onError(error)\n } else if (process.env.NODE_ENV !== 'production') {\n // oxlint-disable-next-line no-console\n console.error('[Pyreon] @pyreon/rich-text failed to mount the editor:', error)\n }\n }\n }\n\n return {\n json,\n html,\n text,\n isEmpty,\n characterCount,\n wordCount,\n canUndo,\n canRedo,\n focused,\n editable,\n view,\n isActive,\n chain,\n focus,\n blur,\n undo,\n redo,\n dispose,\n _mount,\n }\n}\n","import { cx, type VNodeChild } from '@pyreon/core'\nimport type { RichTextProps } from '../types'\n\n/**\n * Mount component for a {@link createRichTextEditor} instance. Creates the\n * TipTap editor inside a container `<div>` on render (lazy-loading the\n * engine). The instance is framework-independent — create it with\n * `createRichTextEditor`, mount it here.\n *\n * Lifecycle: like `@pyreon/code`'s `<CodeEditor>`, the editor instance is\n * **user-owned** — call `editor.dispose()` in your `onCleanup` /\n * `onUnmount` if the instance won't be reused (the component does not\n * auto-dispose, so the same instance can be re-mounted).\n *\n * @example\n * ```tsx\n * const editor = createRichTextEditor({ content: '<p>Hello</p>' })\n * <RichText instance={editor} style=\"min-height: 12rem\" />\n * ```\n */\nexport function RichText(props: RichTextProps): VNodeChild {\n const { instance } = props\n\n const containerRef = (el: Element | null): void => {\n if (!el) return\n void instance._mount(el as HTMLElement)\n }\n\n return (\n <div ref={containerRef} class={cx(['pyreon-rich-text', props.class])} style={props.style ?? ''} />\n )\n}\n","import { effect } from '@pyreon/reactivity'\nimport type { JSONContent, RichTextEditor } from './types'\n\n/**\n * A signal-shaped binding source — a real Pyreon `Signal<T>` or any object\n * exposing the same `() => T` reader + `set(value)` writer (+ optional `peek`).\n */\nexport interface SignalLike<T> {\n (): T\n set: (value: T) => void\n peek?: () => T\n}\n\nexport interface BindRichTextToSignalOptions<T> {\n /** Editor instance to bind. */\n editor: RichTextEditor\n /** External state to mirror (a `Signal` or `SignalLike`). */\n signal: SignalLike<T>\n /**\n * Serialization the external signal holds:\n * - `'json'` (default) — `T` is `JSONContent` (the ProseMirror document).\n * - `'html'` — `T` is an HTML `string`.\n */\n format?: 'json' | 'html'\n /** Called if applying a value to the editor (or reading it back) throws. */\n onError?: (error: Error) => void\n}\n\nexport interface RichTextBinding {\n /** Stop the binding (disposes both directions). Call from `onCleanup`. */\n dispose: () => void\n}\n\n/**\n * Two-way bind a Pyreon `Signal` to a rich-text editor's content, with\n * built-in loop prevention — the editor mirror of `@pyreon/code`'s\n * `bindEditorToSignal`. Use `format: 'json'` to persist/restore the\n * structured document, or `format: 'html'` for an HTML string.\n *\n * @example\n * ```ts\n * const draft = signal<JSONContent>({ type: 'doc', content: [] })\n * const editor = createRichTextEditor()\n * const binding = bindRichTextToSignal({ editor, signal: draft })\n * onCleanup(() => binding.dispose())\n * ```\n */\nexport function bindRichTextToSignal<T = JSONContent>(\n options: BindRichTextToSignalOptions<T>,\n): RichTextBinding {\n const { editor, signal, format = 'json', onError } = options\n\n // Two flags break the echo race: each direction bails if the opposite\n // write is already in flight (same shape as `bindEditorToSignal`).\n let applyingFromSignal = false\n let applyingFromEditor = false\n\n // External → editor.\n const signalToEditor = effect(() => {\n const value = signal()\n if (applyingFromEditor) return\n applyingFromSignal = true\n try {\n if (format === 'html') {\n // pyreon-lint-disable-next-line pyreon/no-peek-in-tracked\n const e = editor.view.peek()\n if (e && (value as string) !== e.getHTML()) {\n e.commands.setContent(value as string)\n }\n } else {\n // pyreon-lint-disable-next-line pyreon/no-peek-in-tracked\n if (JSON.stringify(value) !== JSON.stringify(editor.json.peek())) {\n editor.json.set(value as unknown as JSONContent)\n }\n }\n } catch (err) {\n onError?.(err as Error)\n } finally {\n applyingFromSignal = false\n }\n })\n\n // Editor → external.\n const editorToSignal = effect(() => {\n const out = format === 'html' ? editor.html() : editor.json()\n /* v8 ignore next — defensive loop guard. The external→editor sync path\n sets applyingFromSignal then resets it inside the same synchronous\n block; Pyreon defers a cross-effect re-run past that block (the flag is\n already false when this effect re-runs), so the bail is unreachable\n under the current scheduler but kept to match @pyreon/code's guard. */\n if (applyingFromSignal) return\n applyingFromEditor = true\n try {\n signal.set(out as unknown as T)\n } catch (err) {\n onError?.(err as Error)\n } finally {\n applyingFromEditor = false\n }\n })\n\n return {\n dispose: () => {\n signalToEditor.dispose()\n editorToSignal.dispose()\n },\n }\n}\n","/**\n * @pyreon/rich-text — Reactive WYSIWYG rich-text editor for Pyreon.\n *\n * A thin signal-backed layer over TipTap (the MIT, framework-agnostic\n * headless editor framework built on ProseMirror) — the same adapter\n * shape as `@pyreon/code` (CodeMirror) and `@pyreon/charts` (ECharts).\n * The document state is exposed as signals; the TipTap engine is\n * lazy-loaded on mount so `@tiptap/*` stays out of the initial bundle.\n *\n * @example\n * ```tsx\n * import { createRichTextEditor, RichText, bindRichTextToSignal } from '@pyreon/rich-text'\n * import { signal } from '@pyreon/reactivity'\n *\n * const editor = createRichTextEditor({ content: '<p>Hello</p>' })\n *\n * editor.json() // reactive ProseMirror JSON\n * editor.json.set(draft) // replace content (loop-safe)\n * editor.text() // reactive plain text\n * editor.chain()?.toggleBold().run() // run a command\n *\n * <RichText instance={editor} style=\"min-height: 12rem\" />\n * ```\n */\n\nimport { name as __pkgName, version as __pkgVersion } from '../package.json' with { type: 'json' }\nimport { registerSingleton } from '@pyreon/reactivity'\n\n// Singleton sentinel — fail-loud detection of duplicate @pyreon/rich-text\n// instances in the same heap. Name + version derived from this package's own\n// package.json (single source of truth; the build inlines the literals).\nregisterSingleton(__pkgName, __pkgVersion, import.meta.url)\n\n// Core\nexport { createRichTextEditor } from './editor'\n// Mount component\nexport { RichText } from './components/rich-text'\n// Signal binding\nexport { bindRichTextToSignal } from './bind-signal'\nexport type {\n BindRichTextToSignalOptions,\n RichTextBinding,\n SignalLike,\n} from './bind-signal'\n\n// Types\nexport type {\n JSONContent,\n RichTextConfig,\n RichTextEditor,\n RichTextProps,\n} from './types'\n"],"mappings":"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"}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { Computed, Signal } from "@pyreon/reactivity";
|
|
2
|
+
import { AnyExtension, Editor, JSONContent, JSONContent as JSONContent$1 } from "@tiptap/core";
|
|
3
|
+
import { VNodeChild } from "@pyreon/core";
|
|
4
|
+
|
|
5
|
+
//#region src/types.d.ts
|
|
6
|
+
/** Configuration for {@link createRichTextEditor}. */
|
|
7
|
+
interface RichTextConfig {
|
|
8
|
+
/**
|
|
9
|
+
* Initial document. An HTML string (`'<p>Hi</p>'`) or ProseMirror JSON
|
|
10
|
+
* (`{ type: 'doc', content: [...] }`). Defaults to an empty document.
|
|
11
|
+
*/
|
|
12
|
+
content?: string | JSONContent$1;
|
|
13
|
+
/** Whether the document is editable (default `true`). Read-only when `false`. */
|
|
14
|
+
editable?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Accessible name for the editor. The content area is a `role="textbox"`,
|
|
17
|
+
* which has no name unless one is supplied — a screen reader otherwise
|
|
18
|
+
* announces just "edit text, multiline". Defaults to `"Rich text editor"`.
|
|
19
|
+
*/
|
|
20
|
+
ariaLabel?: string;
|
|
21
|
+
/** Include the TipTap StarterKit (paragraph, headings, bold/italic, lists, …). Default `true`. */
|
|
22
|
+
starterKit?: boolean;
|
|
23
|
+
/** Extra TipTap extensions, appended after StarterKit. */
|
|
24
|
+
extensions?: AnyExtension[];
|
|
25
|
+
/** Autofocus the editor on mount (default `false`). */
|
|
26
|
+
autofocus?: boolean;
|
|
27
|
+
/** Called with the document JSON on every change. */
|
|
28
|
+
onChange?: (json: JSONContent$1) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Called if mounting the TipTap engine fails — a broken extension set
|
|
31
|
+
* (e.g. `starterKit: false` with no schema-providing extension), a
|
|
32
|
+
* throwing custom extension, or a failed dynamic import of `@tiptap/*`.
|
|
33
|
+
* Without this, a mount failure surfaces only as an unhandled promise
|
|
34
|
+
* rejection (the editor silently never mounts). When provided it takes the
|
|
35
|
+
* error; otherwise a `[Pyreon]`-prefixed message is logged in development.
|
|
36
|
+
*/
|
|
37
|
+
onError?: (error: Error) => void;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A reactive rich-text editor instance. Mirrors `@pyreon/code`'s
|
|
41
|
+
* `EditorInstance`: document state is signal-backed; the TipTap `Editor`
|
|
42
|
+
* is created on mount via the `<RichText>` component.
|
|
43
|
+
*/
|
|
44
|
+
interface RichTextEditor {
|
|
45
|
+
/**
|
|
46
|
+
* The document as ProseMirror JSON — a writable `Signal`. `editor.json()`
|
|
47
|
+
* reads reactively; `editor.json.set(next)` replaces the editor content
|
|
48
|
+
* (loop-safe). Before mount it holds the initial content.
|
|
49
|
+
*/
|
|
50
|
+
json: Signal<JSONContent$1>;
|
|
51
|
+
/** Rendered HTML (computed; reads the live editor once mounted). */
|
|
52
|
+
html: Computed<string>;
|
|
53
|
+
/** Plain-text content (computed). Exact `getText()` once mounted. */
|
|
54
|
+
text: Computed<string>;
|
|
55
|
+
/**
|
|
56
|
+
* Whether the document has no text (computed). Derived from the document
|
|
57
|
+
* JSON, so it is accurate before mount / after dispose (a media-only doc
|
|
58
|
+
* reconciles on mount).
|
|
59
|
+
*/
|
|
60
|
+
isEmpty: Computed<boolean>;
|
|
61
|
+
/**
|
|
62
|
+
* Visible character count (computed) — sums text-node lengths, excluding the
|
|
63
|
+
* `\n\n` block separators `getText()` inserts. Derived from the document JSON,
|
|
64
|
+
* so a stored-JSON draft has a real count before the (lazy) engine mounts.
|
|
65
|
+
*/
|
|
66
|
+
characterCount: Computed<number>;
|
|
67
|
+
/**
|
|
68
|
+
* Whitespace-delimited word count (computed). Derived from the document JSON
|
|
69
|
+
* (block boundaries never merge words), so it works before mount.
|
|
70
|
+
*/
|
|
71
|
+
wordCount: Computed<number>;
|
|
72
|
+
/** Whether an undo step is available (computed). */
|
|
73
|
+
canUndo: Computed<boolean>;
|
|
74
|
+
/** Whether a redo step is available (computed). */
|
|
75
|
+
canRedo: Computed<boolean>;
|
|
76
|
+
/** Focus state (reactive). */
|
|
77
|
+
focused: Signal<boolean>;
|
|
78
|
+
/**
|
|
79
|
+
* Whether the document is editable — a writable `Signal`. `editable()` reads
|
|
80
|
+
* reactively (e.g. to label a read-only toggle); `editable.set(false)` flips
|
|
81
|
+
* the live editor to read-only at runtime.
|
|
82
|
+
*/
|
|
83
|
+
editable: Signal<boolean>;
|
|
84
|
+
/** The underlying TipTap `Editor`, or `null` until mounted. */
|
|
85
|
+
view: Signal<Editor | null>;
|
|
86
|
+
/**
|
|
87
|
+
* Whether a mark/node is active at the current selection — reactive (reads
|
|
88
|
+
* the transaction counter, so it re-derives on edits + selection moves).
|
|
89
|
+
* The toolbar primitive: `editor.isActive('bold')`,
|
|
90
|
+
* `editor.isActive('heading', { level: 2 })`. Call inside a reactive scope.
|
|
91
|
+
*/
|
|
92
|
+
isActive: (name: string | Record<string, unknown>, attrs?: Record<string, unknown>) => boolean;
|
|
93
|
+
/**
|
|
94
|
+
* The TipTap command chain for the mounted editor, or `null` before mount.
|
|
95
|
+
* `editor.chain()?.toggleBold().run()`. The escape hatch for every command
|
|
96
|
+
* (toggleBold / toggleItalic / toggleHeading / toggleBulletList / …).
|
|
97
|
+
*/
|
|
98
|
+
chain: () => ReturnType<Editor['chain']> | null;
|
|
99
|
+
/** Imperatively focus the editor (no-op before mount). */
|
|
100
|
+
focus: () => void;
|
|
101
|
+
/** Imperatively blur the editor (no-op before mount). */
|
|
102
|
+
blur: () => void;
|
|
103
|
+
/** Undo the last change (no-op before mount). */
|
|
104
|
+
undo: () => void;
|
|
105
|
+
/** Redo the last undone change (no-op before mount). */
|
|
106
|
+
redo: () => void;
|
|
107
|
+
/** Tear down the editor + its DOM. Call from `onCleanup` / `onUnmount`. */
|
|
108
|
+
dispose: () => void;
|
|
109
|
+
/** @internal — mount the editor into a container element (called by `<RichText>`). */
|
|
110
|
+
_mount: (parent: HTMLElement) => Promise<void>;
|
|
111
|
+
}
|
|
112
|
+
/** Props for the `<RichText>` mount component. */
|
|
113
|
+
interface RichTextProps {
|
|
114
|
+
/** The instance from {@link createRichTextEditor}. */
|
|
115
|
+
instance: RichTextEditor;
|
|
116
|
+
/** Class for the container element. */
|
|
117
|
+
class?: string;
|
|
118
|
+
/** Inline style for the container element. */
|
|
119
|
+
style?: string;
|
|
120
|
+
}
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/editor.d.ts
|
|
123
|
+
/**
|
|
124
|
+
* Create a reactive WYSIWYG rich-text editor instance.
|
|
125
|
+
*
|
|
126
|
+
* The document state (`json` / `html` / `text` / counts / undo-redo) is
|
|
127
|
+
* backed by signals; the TipTap `Editor` (over ProseMirror) is created
|
|
128
|
+
* lazily when the instance is mounted via `<RichText>` — so `@tiptap/*`
|
|
129
|
+
* stays out of the initial bundle (same lazy-load shape as
|
|
130
|
+
* `@pyreon/code`'s languages and `@pyreon/charts`' ECharts).
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```tsx
|
|
134
|
+
* const editor = createRichTextEditor({ content: '<p>Hello</p>' })
|
|
135
|
+
*
|
|
136
|
+
* editor.json() // reactive ProseMirror JSON
|
|
137
|
+
* editor.json.set(draft) // replace content (loop-safe)
|
|
138
|
+
* editor.text() // reactive plain text
|
|
139
|
+
* editor.chain()?.toggleBold().run()
|
|
140
|
+
*
|
|
141
|
+
* <RichText instance={editor} style="min-height: 12rem" />
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
declare function createRichTextEditor(config?: RichTextConfig): RichTextEditor;
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region src/components/rich-text.d.ts
|
|
147
|
+
/**
|
|
148
|
+
* Mount component for a {@link createRichTextEditor} instance. Creates the
|
|
149
|
+
* TipTap editor inside a container `<div>` on render (lazy-loading the
|
|
150
|
+
* engine). The instance is framework-independent — create it with
|
|
151
|
+
* `createRichTextEditor`, mount it here.
|
|
152
|
+
*
|
|
153
|
+
* Lifecycle: like `@pyreon/code`'s `<CodeEditor>`, the editor instance is
|
|
154
|
+
* **user-owned** — call `editor.dispose()` in your `onCleanup` /
|
|
155
|
+
* `onUnmount` if the instance won't be reused (the component does not
|
|
156
|
+
* auto-dispose, so the same instance can be re-mounted).
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```tsx
|
|
160
|
+
* const editor = createRichTextEditor({ content: '<p>Hello</p>' })
|
|
161
|
+
* <RichText instance={editor} style="min-height: 12rem" />
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
declare function RichText(props: RichTextProps): VNodeChild;
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region src/bind-signal.d.ts
|
|
167
|
+
/**
|
|
168
|
+
* A signal-shaped binding source — a real Pyreon `Signal<T>` or any object
|
|
169
|
+
* exposing the same `() => T` reader + `set(value)` writer (+ optional `peek`).
|
|
170
|
+
*/
|
|
171
|
+
interface SignalLike<T> {
|
|
172
|
+
(): T;
|
|
173
|
+
set: (value: T) => void;
|
|
174
|
+
peek?: () => T;
|
|
175
|
+
}
|
|
176
|
+
interface BindRichTextToSignalOptions<T> {
|
|
177
|
+
/** Editor instance to bind. */
|
|
178
|
+
editor: RichTextEditor;
|
|
179
|
+
/** External state to mirror (a `Signal` or `SignalLike`). */
|
|
180
|
+
signal: SignalLike<T>;
|
|
181
|
+
/**
|
|
182
|
+
* Serialization the external signal holds:
|
|
183
|
+
* - `'json'` (default) — `T` is `JSONContent` (the ProseMirror document).
|
|
184
|
+
* - `'html'` — `T` is an HTML `string`.
|
|
185
|
+
*/
|
|
186
|
+
format?: 'json' | 'html';
|
|
187
|
+
/** Called if applying a value to the editor (or reading it back) throws. */
|
|
188
|
+
onError?: (error: Error) => void;
|
|
189
|
+
}
|
|
190
|
+
interface RichTextBinding {
|
|
191
|
+
/** Stop the binding (disposes both directions). Call from `onCleanup`. */
|
|
192
|
+
dispose: () => void;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Two-way bind a Pyreon `Signal` to a rich-text editor's content, with
|
|
196
|
+
* built-in loop prevention — the editor mirror of `@pyreon/code`'s
|
|
197
|
+
* `bindEditorToSignal`. Use `format: 'json'` to persist/restore the
|
|
198
|
+
* structured document, or `format: 'html'` for an HTML string.
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* ```ts
|
|
202
|
+
* const draft = signal<JSONContent>({ type: 'doc', content: [] })
|
|
203
|
+
* const editor = createRichTextEditor()
|
|
204
|
+
* const binding = bindRichTextToSignal({ editor, signal: draft })
|
|
205
|
+
* onCleanup(() => binding.dispose())
|
|
206
|
+
* ```
|
|
207
|
+
*/
|
|
208
|
+
declare function bindRichTextToSignal<T = JSONContent>(options: BindRichTextToSignalOptions<T>): RichTextBinding;
|
|
209
|
+
//#endregion
|
|
210
|
+
export { type BindRichTextToSignalOptions, type JSONContent, RichText, type RichTextBinding, type RichTextConfig, type RichTextEditor, type RichTextProps, type SignalLike, bindRichTextToSignal, createRichTextEditor };
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pyreon/rich-text",
|
|
3
|
+
"version": "0.46.0",
|
|
4
|
+
"description": "Reactive WYSIWYG rich-text editor for Pyreon — signal-backed layer over TipTap (ProseMirror); collaboration composes with @pyreon/sync",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/fundamentals/rich-text#readme",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/pyreon/pyreon/issues"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/pyreon/pyreon.git",
|
|
13
|
+
"directory": "packages/fundamentals/rich-text"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"main": "./lib/index.js",
|
|
18
|
+
"module": "./lib/index.js",
|
|
19
|
+
"types": "./lib/types/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"import": "./lib/index.js",
|
|
23
|
+
"types": "./lib/types/index.d.ts"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"lib",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "vl_rolldown_build",
|
|
33
|
+
"dev": "vl_rolldown_build-watch",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"test:browser": "vitest run --config ./vitest.browser.config.ts",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"lint": "oxlint ."
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@pyreon/core": "^0.46.0",
|
|
41
|
+
"@pyreon/reactivity": "^0.46.0",
|
|
42
|
+
"@pyreon/runtime-dom": "^0.46.0"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@tiptap/core": "^3.27.1",
|
|
46
|
+
"@tiptap/pm": "^3.27.1",
|
|
47
|
+
"@tiptap/starter-kit": "^3.27.1"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@happy-dom/global-registrator": "^20.9.0",
|
|
51
|
+
"@pyreon/manifest": "0.13.2",
|
|
52
|
+
"@pyreon/test-utils": "^0.13.34",
|
|
53
|
+
"@pyreon/vitest-config": "0.13.3",
|
|
54
|
+
"@vitest/browser-playwright": "^4.1.8",
|
|
55
|
+
"@vitus-labs/tools-lint": "^2.6.3"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
}
|
|
60
|
+
}
|