@morphika/andami 0.2.5 → 0.2.7

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.
@@ -0,0 +1,451 @@
1
+ "use client";
2
+
3
+ /**
4
+ * RichTextBubbleMenu.tsx
5
+ *
6
+ * Floating toolbar that appears when text is selected in the RichTextEditor.
7
+ * Provides toggle buttons for bold, italic, underline, and a link popover.
8
+ * Styled to match the builder's dark UI theme.
9
+ */
10
+
11
+ import { useState, useCallback, useEffect, useRef } from "react";
12
+ import { BubbleMenu, type Editor } from "@tiptap/react";
13
+ import { UnifiedColorPicker } from "../color-picker";
14
+ import { usePaletteSwatches } from "../ColorSwatchPicker";
15
+ import type { ColorField } from "../../../lib/sanity/types";
16
+
17
+ // ── Icon components (inline SVGs to avoid external deps) ────────────
18
+
19
+ function BoldIcon() {
20
+ return (
21
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
22
+ <path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
23
+ <path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
24
+ </svg>
25
+ );
26
+ }
27
+
28
+ function ItalicIcon() {
29
+ return (
30
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
31
+ <line x1="19" y1="4" x2="10" y2="4" />
32
+ <line x1="14" y1="20" x2="5" y2="20" />
33
+ <line x1="15" y1="4" x2="9" y2="20" />
34
+ </svg>
35
+ );
36
+ }
37
+
38
+ function UnderlineIcon() {
39
+ return (
40
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
41
+ <path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3" />
42
+ <line x1="4" y1="21" x2="20" y2="21" />
43
+ </svg>
44
+ );
45
+ }
46
+
47
+ function LinkIcon() {
48
+ return (
49
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
50
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
51
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
52
+ </svg>
53
+ );
54
+ }
55
+
56
+ function UnlinkIcon() {
57
+ return (
58
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
59
+ <path d="M18.84 12.25l1.72-1.71a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
60
+ <path d="M5.16 11.75l-1.72 1.71a5 5 0 0 0 7.07 7.07l1.72-1.71" />
61
+ <line x1="2" y1="2" x2="22" y2="22" />
62
+ </svg>
63
+ );
64
+ }
65
+
66
+ // ── Toggle button ───────────────────────────────────────────────────
67
+
68
+ function ToolbarButton({
69
+ onClick,
70
+ isActive,
71
+ title,
72
+ children,
73
+ }: {
74
+ onClick: () => void;
75
+ isActive: boolean;
76
+ title: string;
77
+ children: React.ReactNode;
78
+ }) {
79
+ return (
80
+ <div
81
+ role="button"
82
+ tabIndex={0}
83
+ onMouseDown={(e) => e.preventDefault()}
84
+ onClick={onClick}
85
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick(); }}
86
+ title={title}
87
+ className={`
88
+ flex items-center justify-center w-7 h-7 rounded cursor-pointer select-none
89
+ transition-colors duration-100
90
+ ${isActive
91
+ ? "bg-white/20 text-white"
92
+ : "text-neutral-300 hover:bg-white/10 hover:text-white"
93
+ }
94
+ `}
95
+ >
96
+ {children}
97
+ </div>
98
+ );
99
+ }
100
+
101
+ // ── Link popover ────────────────────────────────────────────────────
102
+
103
+ function LinkPopover({
104
+ editor,
105
+ onClose,
106
+ }: {
107
+ editor: Editor;
108
+ onClose: () => void;
109
+ }) {
110
+ const inputRef = useRef<HTMLInputElement>(null);
111
+ const existingHref = editor.getAttributes("link").href || "";
112
+ const existingBlank = editor.getAttributes("link").target === "_blank";
113
+
114
+ const [href, setHref] = useState(existingHref);
115
+ const [blank, setBlank] = useState(existingBlank);
116
+
117
+ useEffect(() => {
118
+ // Auto-focus the URL input on mount
119
+ requestAnimationFrame(() => inputRef.current?.focus());
120
+ }, []);
121
+
122
+ const handleSave = useCallback(() => {
123
+ if (!href.trim()) {
124
+ // Empty URL = remove link
125
+ editor.chain().focus().unsetLink().run();
126
+ } else {
127
+ editor
128
+ .chain()
129
+ .focus()
130
+ .setLink({
131
+ href: href.trim(),
132
+ target: blank ? "_blank" : null,
133
+ })
134
+ .run();
135
+ }
136
+ onClose();
137
+ }, [editor, href, blank, onClose]);
138
+
139
+ const handleRemove = useCallback(() => {
140
+ editor.chain().focus().unsetLink().run();
141
+ onClose();
142
+ }, [editor, onClose]);
143
+
144
+ const handleKeyDown = useCallback(
145
+ (e: React.KeyboardEvent) => {
146
+ e.stopPropagation();
147
+ if (e.key === "Enter") {
148
+ e.preventDefault();
149
+ handleSave();
150
+ }
151
+ if (e.key === "Escape") {
152
+ e.preventDefault();
153
+ onClose();
154
+ editor.commands.focus();
155
+ }
156
+ },
157
+ [handleSave, onClose, editor]
158
+ );
159
+
160
+ return (
161
+ <div
162
+ className="flex flex-col gap-2 p-2 min-w-[280px]"
163
+ onMouseDown={(e) => e.stopPropagation()}
164
+ >
165
+ <div className="flex items-center gap-1.5">
166
+ <input
167
+ ref={inputRef}
168
+ type="url"
169
+ value={href}
170
+ onChange={(e) => setHref(e.target.value)}
171
+ onKeyDown={handleKeyDown}
172
+ placeholder="https://example.com"
173
+ className="flex-1 bg-neutral-700 text-white text-xs rounded px-2 py-1.5
174
+ border border-neutral-600 placeholder-neutral-400
175
+ focus:outline-none focus:border-blue-500"
176
+ />
177
+ <div
178
+ role="button"
179
+ tabIndex={0}
180
+ onClick={handleSave}
181
+ onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }}
182
+ className="px-2 py-1.5 text-xs bg-blue-600 text-white rounded
183
+ hover:bg-blue-500 transition-colors cursor-pointer select-none"
184
+ >
185
+ OK
186
+ </div>
187
+ {existingHref && (
188
+ <div
189
+ role="button"
190
+ tabIndex={0}
191
+ onClick={handleRemove}
192
+ onKeyDown={(e) => { if (e.key === "Enter") handleRemove(); }}
193
+ title="Remove link"
194
+ className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-400/10
195
+ rounded transition-colors cursor-pointer select-none"
196
+ >
197
+ <UnlinkIcon />
198
+ </div>
199
+ )}
200
+ </div>
201
+ <label className="flex items-center gap-1.5 text-xs text-neutral-300 cursor-pointer select-none">
202
+ <input
203
+ type="checkbox"
204
+ checked={blank}
205
+ onChange={(e) => setBlank(e.target.checked)}
206
+ className="rounded border-neutral-500 bg-neutral-700 text-blue-500
207
+ focus:ring-blue-500 focus:ring-offset-0 w-3.5 h-3.5"
208
+ />
209
+ Open in new tab
210
+ </label>
211
+ </div>
212
+ );
213
+ }
214
+
215
+ // ── Color icon — "A" with colored underline bar (standard text color icon) ──
216
+
217
+ function ColorIcon({ activeColor }: { activeColor?: string }) {
218
+ return (
219
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
220
+ {/* Letter A */}
221
+ <path
222
+ d="M5 18L9.5 6h5L19 18"
223
+ stroke="currentColor"
224
+ strokeWidth="2"
225
+ strokeLinecap="round"
226
+ strokeLinejoin="round"
227
+ />
228
+ <path
229
+ d="M7.5 14h9"
230
+ stroke="currentColor"
231
+ strokeWidth="2"
232
+ strokeLinecap="round"
233
+ />
234
+ {/* Color indicator bar */}
235
+ <rect
236
+ x="2"
237
+ y="20"
238
+ width="20"
239
+ height="3"
240
+ rx="1"
241
+ fill={activeColor || "currentColor"}
242
+ />
243
+ </svg>
244
+ );
245
+ }
246
+
247
+ // ── Main BubbleMenu ─────────────────────────────────────────────────
248
+
249
+ export default function RichTextBubbleMenu({ editor }: { editor: Editor }) {
250
+ const [showLinkPopover, setShowLinkPopover] = useState(false);
251
+ const [showColorPicker, setShowColorPicker] = useState(false);
252
+ const swatches = usePaletteSwatches();
253
+
254
+ // Store the editor selection before opening the color picker modal,
255
+ // so we can restore it when the user confirms a color (the modal steals focus).
256
+ const savedSelectionRef = useRef<{ from: number; to: number } | null>(null);
257
+
258
+ // Close link popover when selection changes away
259
+ useEffect(() => {
260
+ const handleSelectionChange = () => {
261
+ // Don't close if color picker modal is open (selection is saved)
262
+ if (showColorPicker) return;
263
+ if (editor.view.state.selection.empty) {
264
+ if (showLinkPopover) setShowLinkPopover(false);
265
+ }
266
+ };
267
+ document.addEventListener("selectionchange", handleSelectionChange);
268
+ return () => document.removeEventListener("selectionchange", handleSelectionChange);
269
+ }, [showLinkPopover, showColorPicker, editor]);
270
+
271
+ // Register Cmd+K / Ctrl+K for link dialog
272
+ useEffect(() => {
273
+ const handleKeyDown = (e: KeyboardEvent) => {
274
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
275
+ e.preventDefault();
276
+ e.stopPropagation();
277
+ if (!editor.state.selection.empty) {
278
+ setShowLinkPopover((prev) => !prev);
279
+ }
280
+ }
281
+ };
282
+ const editorEl = editor.view.dom;
283
+ editorEl.addEventListener("keydown", handleKeyDown);
284
+ return () => editorEl.removeEventListener("keydown", handleKeyDown);
285
+ }, [editor]);
286
+
287
+ // Get current inline color for the icon indicator.
288
+ // For mixed-color selections, this returns the color at the anchor point.
289
+ const activeColor = editor.getAttributes("textStyle").color || "";
290
+
291
+ // Check if ANY part of the selection has color (for the remove button)
292
+ const selectionHasColor = editor.isActive("textStyle");
293
+
294
+ // Handlers for the UnifiedColorPicker modal
295
+ const handleColorChange = useCallback(
296
+ (value: ColorField) => {
297
+ const hex = typeof value === "string" ? value : "";
298
+ if (!hex) return;
299
+
300
+ // Restore saved selection, then apply color
301
+ const sel = savedSelectionRef.current;
302
+ if (sel) {
303
+ editor
304
+ .chain()
305
+ .focus()
306
+ .setTextSelection({ from: sel.from, to: sel.to })
307
+ .setColor(hex)
308
+ .run();
309
+ } else {
310
+ editor.chain().focus().setColor(hex).run();
311
+ }
312
+ setShowColorPicker(false);
313
+ savedSelectionRef.current = null;
314
+ },
315
+ [editor]
316
+ );
317
+
318
+ const handleColorClose = useCallback(() => {
319
+ setShowColorPicker(false);
320
+ // Restore selection on cancel too
321
+ const sel = savedSelectionRef.current;
322
+ if (sel) {
323
+ editor
324
+ .chain()
325
+ .focus()
326
+ .setTextSelection({ from: sel.from, to: sel.to })
327
+ .run();
328
+ } else {
329
+ editor.commands.focus();
330
+ }
331
+ savedSelectionRef.current = null;
332
+ }, [editor]);
333
+
334
+ const handleRemoveColor = useCallback(() => {
335
+ editor.chain().focus().unsetColor().run();
336
+ }, [editor]);
337
+
338
+ return (
339
+ <>
340
+ <BubbleMenu
341
+ editor={editor}
342
+ tippyOptions={{
343
+ duration: 150,
344
+ placement: "top",
345
+ zIndex: 9999,
346
+ appendTo: () => document.body,
347
+ // Tippy cleanup is handled gracefully — the removeChild error
348
+ // in dev mode (HMR) is cosmetic and doesn't affect functionality.
349
+ }}
350
+ className="flex items-center gap-0.5 p-1 bg-neutral-800 rounded-lg shadow-xl
351
+ border border-neutral-700/50"
352
+ >
353
+ {/* Formatting toggles */}
354
+ <ToolbarButton
355
+ onClick={() => editor.chain().focus().toggleBold().run()}
356
+ isActive={editor.isActive("bold")}
357
+ title="Bold (Ctrl+B)"
358
+ >
359
+ <BoldIcon />
360
+ </ToolbarButton>
361
+
362
+ <ToolbarButton
363
+ onClick={() => editor.chain().focus().toggleItalic().run()}
364
+ isActive={editor.isActive("italic")}
365
+ title="Italic (Ctrl+I)"
366
+ >
367
+ <ItalicIcon />
368
+ </ToolbarButton>
369
+
370
+ <ToolbarButton
371
+ onClick={() => editor.chain().focus().toggleUnderline().run()}
372
+ isActive={editor.isActive("underline")}
373
+ title="Underline (Ctrl+U)"
374
+ >
375
+ <UnderlineIcon />
376
+ </ToolbarButton>
377
+
378
+ {/* Separator */}
379
+ <div className="w-px h-4 bg-neutral-600 mx-0.5" />
380
+
381
+ {/* Color button — saves selection, then opens UnifiedColorPicker modal */}
382
+ <ToolbarButton
383
+ onClick={() => {
384
+ // Save current selection before modal steals focus
385
+ const { from, to } = editor.state.selection;
386
+ savedSelectionRef.current = { from, to };
387
+ setShowColorPicker(true);
388
+ setShowLinkPopover(false);
389
+ }}
390
+ isActive={!!activeColor}
391
+ title="Text Color"
392
+ >
393
+ <ColorIcon activeColor={activeColor} />
394
+ </ToolbarButton>
395
+
396
+ {/* Remove color button — shows when any part of selection has color */}
397
+ {selectionHasColor && (
398
+ <div
399
+ role="button"
400
+ tabIndex={0}
401
+ onMouseDown={(e) => e.preventDefault()}
402
+ onClick={handleRemoveColor}
403
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleRemoveColor(); }}
404
+ title="Remove color"
405
+ className="flex items-center justify-center w-5 h-5 rounded cursor-pointer select-none
406
+ text-neutral-400 hover:text-red-400 hover:bg-red-400/10
407
+ transition-colors duration-100 text-[10px]"
408
+ >
409
+
410
+ </div>
411
+ )}
412
+
413
+ {/* Separator */}
414
+ <div className="w-px h-4 bg-neutral-600 mx-0.5" />
415
+
416
+ {/* Link button */}
417
+ <ToolbarButton
418
+ onClick={() => {
419
+ setShowLinkPopover((prev) => !prev);
420
+ }}
421
+ isActive={editor.isActive("link")}
422
+ title="Link (Ctrl+K)"
423
+ >
424
+ <LinkIcon />
425
+ </ToolbarButton>
426
+
427
+ {/* Link popover — renders below the toolbar buttons */}
428
+ {showLinkPopover && (
429
+ <div className="absolute top-full left-0 mt-1 bg-neutral-800 rounded-lg shadow-xl
430
+ border border-neutral-700/50 z-10">
431
+ <LinkPopover
432
+ editor={editor}
433
+ onClose={() => setShowLinkPopover(false)}
434
+ />
435
+ </div>
436
+ )}
437
+ </BubbleMenu>
438
+
439
+ {/* Color picker modal — uses the app's full UnifiedColorPicker */}
440
+ {showColorPicker && (
441
+ <UnifiedColorPicker
442
+ value={activeColor || "#ffffff"}
443
+ onChange={handleColorChange}
444
+ onClose={handleColorClose}
445
+ swatches={swatches}
446
+ confirmLabel="Apply Color"
447
+ />
448
+ )}
449
+ </>
450
+ );
451
+ }
@@ -0,0 +1,223 @@
1
+ "use client";
2
+
3
+ /**
4
+ * RichTextEditor.tsx
5
+ *
6
+ * Tiptap-based rich text editor for the visual page builder.
7
+ * Replaces the plain contentEditable in LiveTextEditor with full
8
+ * inline formatting support: bold, italic, underline, and links.
9
+ *
10
+ * Integrates with the builder's Zustand store using the same
11
+ * debounce + snapshot pattern as the original LiveTextEditor.
12
+ */
13
+
14
+ import { useRef, useCallback, useEffect, useMemo } from "react";
15
+ import { useEditor, EditorContent, type Editor } from "@tiptap/react";
16
+ import StarterKit from "@tiptap/starter-kit";
17
+ import Underline from "@tiptap/extension-underline";
18
+ import Link from "@tiptap/extension-link";
19
+ import TextStyle from "@tiptap/extension-text-style";
20
+ import Color from "@tiptap/extension-color";
21
+ import { useBuilderStore } from "../../../lib/builder/store";
22
+ import { portableTextToTiptap } from "../../../lib/editor/portableToTiptap";
23
+ import { tiptapToPortableText } from "../../../lib/editor/tiptapToPortable";
24
+ import RichTextBubbleMenu from "./RichTextBubbleMenu";
25
+ import type { TextBlock, ContentBlock } from "../../../lib/sanity/types";
26
+
27
+ /** Hook to get page-level text color for default block text rendering */
28
+ export function usePageTextColor(): string {
29
+ return useBuilderStore((s) => s.pageSettings.text_color) || "#0a0a0a";
30
+ }
31
+
32
+ /** Resolve fontSize: supports new numeric px and legacy string enum */
33
+ function resolveTextFontSize(fontSize?: number | string): string {
34
+ if (typeof fontSize === "number") return `${fontSize}px`;
35
+ const legacyMap: Record<string, string> = {
36
+ small: "12px", base: "14px", large: "20px",
37
+ xl: "24px", "2xl": "32px", "3xl": "48px",
38
+ };
39
+ return legacyMap[fontSize || "base"] || "14px";
40
+ }
41
+
42
+ /** Resolve fontWeight: supports new string numbers and legacy names */
43
+ function resolveTextFontWeight(fw?: string): number {
44
+ if (!fw) return 400;
45
+ const num = parseInt(fw, 10);
46
+ if (!isNaN(num)) return num;
47
+ if (fw === "bold") return 700;
48
+ if (fw === "medium") return 500;
49
+ return 400;
50
+ }
51
+
52
+ interface RichTextEditorProps {
53
+ block: TextBlock;
54
+ editable?: boolean;
55
+ }
56
+
57
+ export default function RichTextEditor({ block, editable = false }: RichTextEditorProps) {
58
+ const pageTextColor = usePageTextColor();
59
+ // Select only the stable function references we need — avoids re-renders on every store change
60
+ const updateBlockDebounced = useBuilderStore((s) => s.updateBlockDebounced);
61
+ const pushSnapshot = useBuilderStore((s) => s._pushSnapshot);
62
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
63
+ const snapshotPushedRef = useRef(false);
64
+ // Track block key to detect when we switch to a different block
65
+ const blockKeyRef = useRef(block._key);
66
+
67
+ const style = block.style || {};
68
+ const cols = block.columns && block.columns > 1 ? block.columns : undefined;
69
+
70
+ // Memoize initial content from Portable Text
71
+ const initialContent = useMemo(
72
+ () => portableTextToTiptap(block.text),
73
+ // Only recompute when block identity changes, not on every text edit
74
+ // eslint-disable-next-line react-hooks/exhaustive-deps
75
+ [block._key]
76
+ );
77
+
78
+ // Commit Tiptap content to builder store as Portable Text
79
+ const commitContent = useCallback(
80
+ (ed: Editor | null) => {
81
+ if (!ed) return;
82
+ const doc = ed.getJSON();
83
+ const portableText = tiptapToPortableText(doc);
84
+ updateBlockDebounced(block._key, {
85
+ text: portableText,
86
+ } as Partial<ContentBlock>);
87
+ },
88
+ [block._key, updateBlockDebounced]
89
+ );
90
+
91
+ const editor = useEditor({
92
+ extensions: [
93
+ StarterKit.configure({
94
+ // We keep bold, italic, strike, heading, blockquote from StarterKit
95
+ // Disable features we don't need in the builder
96
+ code: false,
97
+ codeBlock: false,
98
+ bulletList: false,
99
+ orderedList: false,
100
+ listItem: false,
101
+ horizontalRule: false,
102
+ dropcursor: false,
103
+ gapcursor: false,
104
+ }),
105
+ Underline,
106
+ TextStyle,
107
+ Color,
108
+ Link.configure({
109
+ openOnClick: false, // Don't navigate when clicking links in editor
110
+ autolink: false, // Don't auto-detect URLs while typing
111
+ HTMLAttributes: {
112
+ class: "underline cursor-pointer", // Inherits text color (inline or block-level)
113
+ },
114
+ }),
115
+ ],
116
+ content: initialContent,
117
+ editable,
118
+ // Debounced update on every content change
119
+ onUpdate: ({ editor }) => {
120
+ if (debounceRef.current) clearTimeout(debounceRef.current);
121
+ debounceRef.current = setTimeout(() => {
122
+ commitContent(editor);
123
+ debounceRef.current = null;
124
+ }, 500);
125
+ },
126
+ onFocus: () => {
127
+ if (!snapshotPushedRef.current) {
128
+ pushSnapshot();
129
+ snapshotPushedRef.current = true;
130
+ }
131
+ },
132
+ onBlur: () => {
133
+ // Flush pending debounce immediately on blur
134
+ if (debounceRef.current) {
135
+ clearTimeout(debounceRef.current);
136
+ debounceRef.current = null;
137
+ }
138
+ snapshotPushedRef.current = false;
139
+ commitContent(editor);
140
+ },
141
+ // Prevent Tiptap from adding its own classes to the editor root
142
+ editorProps: {
143
+ attributes: {
144
+ style: "outline: none;",
145
+ },
146
+ handleKeyDown: (_view, event) => {
147
+ // Stop propagation for normal typing so builder shortcuts don't fire
148
+ if (!event.metaKey && !event.ctrlKey) {
149
+ event.stopPropagation();
150
+ return false; // Let Tiptap handle the key
151
+ }
152
+ // Allow Ctrl+S, Ctrl+Z, Ctrl+Y to bubble to builder
153
+ if (event.key === "s" || event.key === "z" || event.key === "Z" || event.key === "y") {
154
+ return false;
155
+ }
156
+ // Stop propagation for other Ctrl combos (Ctrl+B, Ctrl+I, etc.)
157
+ // so they stay within Tiptap
158
+ event.stopPropagation();
159
+ return false;
160
+ },
161
+ },
162
+ });
163
+
164
+ // Sync content when block changes externally (undo/redo, different block selected)
165
+ useEffect(() => {
166
+ if (!editor || editor.isFocused) return;
167
+
168
+ // If block key changed, we're editing a different block
169
+ if (blockKeyRef.current !== block._key) {
170
+ blockKeyRef.current = block._key;
171
+ const newContent = portableTextToTiptap(block.text);
172
+ editor.commands.setContent(newContent);
173
+ return;
174
+ }
175
+
176
+ // External content change (e.g. undo/redo) — update without focus
177
+ const newContent = portableTextToTiptap(block.text);
178
+ const currentJSON = JSON.stringify(editor.getJSON());
179
+ const newJSON = JSON.stringify(newContent);
180
+ if (currentJSON !== newJSON) {
181
+ editor.commands.setContent(newContent);
182
+ }
183
+ }, [editor, block._key, block.text]);
184
+
185
+ // Cleanup debounce timer on unmount
186
+ useEffect(() => {
187
+ return () => {
188
+ if (debounceRef.current) clearTimeout(debounceRef.current);
189
+ };
190
+ }, []);
191
+
192
+ // Compute inline styles matching the block's style settings
193
+ const computedStyle: React.CSSProperties = {
194
+ fontSize: resolveTextFontSize(style.fontSize),
195
+ fontWeight: resolveTextFontWeight(style.fontWeight),
196
+ textAlign: style.alignment || "left",
197
+ color: style.color || pageTextColor,
198
+ opacity: style.opacity ?? 1,
199
+ lineHeight: style.lineHeight || "1.6",
200
+ letterSpacing: style.letterSpacing || "normal",
201
+ maxWidth: style.maxWidth || "none",
202
+ textTransform: style.textTransform || "none",
203
+ fontFamily: "inherit",
204
+ whiteSpace: "pre-wrap",
205
+ wordBreak: "break-word",
206
+ minHeight: "1em",
207
+ ...(cols
208
+ ? {
209
+ columnCount: cols,
210
+ columnGap: "var(--grid-gutter, 24px)",
211
+ }
212
+ : {}),
213
+ };
214
+
215
+ if (!editor) return null;
216
+
217
+ return (
218
+ <div style={computedStyle} className="rich-text-editor-root">
219
+ {editable && <RichTextBubbleMenu editor={editor} />}
220
+ <EditorContent editor={editor} />
221
+ </div>
222
+ );
223
+ }
@@ -1,5 +1,7 @@
1
1
  export { default as LiveTextEditor } from "./LiveTextEditor";
2
2
  export { usePageTextColor } from "./LiveTextEditor";
3
+ export { default as RichTextEditor } from "./RichTextEditor";
4
+ export { default as RichTextBubbleMenu } from "./RichTextBubbleMenu";
3
5
  export { default as LiveImagePreview } from "./LiveImagePreview";
4
6
  export { default as LiveImageGridPreview } from "./LiveImageGridPreview";
5
7
  export { default as LiveVideoPreview } from "./LiveVideoPreview";
@@ -0,0 +1,2 @@
1
+ export { portableTextToTiptap } from "./portableToTiptap";
2
+ export { tiptapToPortableText } from "./tiptapToPortable";