@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 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
@@ -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
+ }