@morphika/andami 0.2.4 → 0.2.6
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/components/blocks/ProjectGridBlockRenderer.tsx +50 -29
- package/components/blocks/TextBlockRenderer.tsx +27 -2
- package/components/blocks/TypewriterRichText.tsx +7 -1
- package/components/builder/CanvasToolbar.tsx +11 -17
- package/components/builder/SectionV2Canvas.tsx +3 -2
- package/components/builder/SectionV2Column.tsx +7 -3
- package/components/builder/SortableBlock.tsx +7 -6
- package/components/builder/SortableRow.tsx +36 -11
- package/components/builder/editors/TextBlockEditor.tsx +2 -6
- package/components/builder/live-preview/LiveTextEditor.tsx +117 -198
- package/components/builder/live-preview/RichTextBubbleMenu.tsx +451 -0
- package/components/builder/live-preview/RichTextEditor.tsx +223 -0
- package/components/builder/live-preview/index.ts +2 -0
- package/components/builder/settings-panel/SectionV2Settings.tsx +4 -0
- package/lib/builder/types.ts +10 -0
- package/lib/editor/index.ts +2 -0
- package/lib/editor/portableToTiptap.ts +156 -0
- package/lib/editor/tiptapToPortable.ts +238 -0
- package/package.json +223 -212
- package/sanity/schemas/blocks/textBlock.ts +13 -0
|
@@ -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";
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
SettingsSection,
|
|
19
19
|
} from "../editors/shared";
|
|
20
20
|
import { findGaps } from "../../../lib/builder/cascade";
|
|
21
|
+
import { isSectionBlockSection } from "../../../lib/builder/types";
|
|
21
22
|
import {
|
|
22
23
|
getSectionV2SettingValue,
|
|
23
24
|
hasSectionV2SettingOverride,
|
|
@@ -65,6 +66,9 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
|
|
|
65
66
|
const addColumnV2 = useBuilderStore((s) => s.addColumnV2);
|
|
66
67
|
const currentPreset = section.settings.preset;
|
|
67
68
|
|
|
69
|
+
// Section-level blocks own the full column — hide layout presets and add column
|
|
70
|
+
if (isSectionBlockSection(section)) return null;
|
|
71
|
+
|
|
68
72
|
const allPresets = currentPreset === "custom"
|
|
69
73
|
? [...PRESETS, CUSTOM_PRESET]
|
|
70
74
|
: PRESETS;
|